Webhooks
Webhooks let you receive real-time notifications from Qeychain instead of polling. You register an HTTPS endpoint, subscribe it to one or more event types, and Qeychain POSTs a signed JSON payload to your URL whenever a matching event occurs.
All webhook-management endpoints live under /api/v1/webhooks and require
authentication.
Base URL and authentication
Section titled “Base URL and authentication”All requests target the production host:
https://api.qeychain.xyzEvery webhook endpoint requires a valid Bearer JWT access token in the
Authorization header. A request with no resolvable session returns 401.
curl https://api.qeychain.xyz/api/v1/webhooks \ -H "Authorization: Bearer $QEYCHAIN_ACCESS_TOKEN"Response envelope
Section titled “Response envelope”Webhook endpoints use the standard Qeychain v1 envelope. Single-object responses look like this:
{ "success": true, "data": { "...": "..." }, "meta": { "request_id": "3f2a9c8e-1b7d-4a5e-9c11-2d6f0a7b8c34", "timestamp": "2026-07-29T12:34:56.789Z", "version": "1.0.0" }}List responses add a pagination block:
{ "success": true, "data": [ { "...": "..." } ], "meta": { "...": "..." }, "pagination": { "page": 1, "per_page": 50, "total": 3, "total_pages": 1, "has_more": false }}Errors set success to false and carry a machine-readable code:
{ "success": false, "error": { "code": "WEBHOOK_NOT_FOUND", "message": "Webhook 9c1f... not found" }, "meta": { "...": "..." }}Common webhook error codes:
| Code | HTTP | Meaning |
|---|---|---|
UNAUTHORIZED |
401 | Missing or invalid authentication |
WEBHOOK_NOT_FOUND |
404 | No webhook with the given ID |
INVALID_EVENT_TYPE |
400 | One or more events values are unknown (a details.valid_events list is included) |
INVALID_WEBHOOK_URL |
400 | url does not start with http:// or https:// |
Event types
Section titled “Event types”Subscribe a webhook to any combination of the following event types. You can
also fetch this list at runtime from
GET /api/v1/webhooks/events/available.
| Event | Description |
|---|---|
transaction.new |
New transaction detected for a watched address |
lot.created |
New tax lot created from an inflow transaction |
lot.consumed |
Tax lot partially or fully consumed |
wash_sale.detected |
Wash-sale rule triggered |
price.alert |
Price threshold crossed for a watched asset |
calculation.complete |
Background calculation job completed |
Register a webhook
Section titled “Register a webhook”POST /api/v1/webhooks · Auth required
Request body
Section titled “Request body”| Field | Type | Required | Description |
|---|---|---|---|
url |
string | yes | Receiving endpoint. Must start with http:// or https://; max 2048 chars. Use HTTPS in production. |
events |
string[] | yes | One or more event types to subscribe to. Must be non-empty. |
addresses_filter |
string[] | no | If set, only deliver events for these wallet addresses. Addresses are normalized (lowercased) on save. Omit to receive events for all addresses. |
description |
string | no | Free-text label for your own reference. |
headers |
object | no | Custom HTTP headers (string to string) added to every delivery request to your endpoint. |
curl -X POST https://api.qeychain.xyz/api/v1/webhooks \ -H "Authorization: Bearer $QEYCHAIN_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/qeychain", "events": ["transaction.new", "lot.created"], "addresses_filter": ["0xabc0000000000000000000000000000000000001"], "description": "Ledger sync worker" }'Response
Section titled “Response”The signing secret is returned only in this response (and on rotation).
Store it securely — you cannot retrieve it again later.
{ "success": true, "data": { "id": "9c1f6d2a-4e8b-4b0a-8f3d-1a2b3c4d5e6f", "url": "https://example.com/hooks/qeychain", "events": ["transaction.new", "lot.created"], "addresses_filter": ["0xabc0000000000000000000000000000000000001"], "is_active": true, "description": "Ledger sync worker", "total_deliveries": 0, "successful_deliveries": 0, "failed_deliveries": 0, "last_delivery_at": null, "created_at": "2026-07-29T12:34:56.000Z", "updated_at": "2026-07-29T12:34:56.000Z", "secret": "whsec_9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0" }, "meta": { "...": "..." }}List webhooks
Section titled “List webhooks”GET /api/v1/webhooks · Auth required
Query parameters
Section titled “Query parameters”| Name | Type | Required | Description |
|---|---|---|---|
page |
integer | no | Page number, 1-indexed. Default 1. |
per_page |
integer | no | Items per page, 1–100. Default 50. |
include_inactive |
boolean | no | Include deactivated webhooks. Default false. |
curl "https://api.qeychain.xyz/api/v1/webhooks?per_page=20" \ -H "Authorization: Bearer $QEYCHAIN_ACCESS_TOKEN"Returns a paginated list of webhook objects (same shape as the create
response, without the secret field).
Get a webhook
Section titled “Get a webhook”GET /api/v1/webhooks/{webhook_id} · Auth required
Path parameters
Section titled “Path parameters”| Name | Type | Required | Description |
|---|---|---|---|
webhook_id |
UUID | yes | ID returned when the webhook was created. |
curl https://api.qeychain.xyz/api/v1/webhooks/9c1f6d2a-4e8b-4b0a-8f3d-1a2b3c4d5e6f \ -H "Authorization: Bearer $QEYCHAIN_ACCESS_TOKEN"{ "success": true, "data": { "id": "9c1f6d2a-4e8b-4b0a-8f3d-1a2b3c4d5e6f", "url": "https://example.com/hooks/qeychain", "events": ["transaction.new", "lot.created"], "addresses_filter": ["0xabc0000000000000000000000000000000000001"], "is_active": true, "description": "Ledger sync worker", "total_deliveries": 12, "successful_deliveries": 11, "failed_deliveries": 1, "last_delivery_at": "2026-07-29T11:59:02.000Z", "created_at": "2026-07-20T09:00:00.000Z", "updated_at": "2026-07-29T11:59:02.000Z" }, "meta": { "...": "..." }}Returns 404 with WEBHOOK_NOT_FOUND if the ID does not exist.
Update a webhook
Section titled “Update a webhook”PUT /api/v1/webhooks/{webhook_id} · Auth required
Every field is optional; send only what you want to change. Fields you omit are left unchanged.
Request body
Section titled “Request body”| Field | Type | Description |
|---|---|---|
url |
string | New endpoint URL (must start with http:// or https://). |
events |
string[] | Replace the subscribed event list. |
addresses_filter |
string[] | Replace the address filter (normalized on save). |
description |
string | Replace the description. |
headers |
object | Replace the custom headers. |
is_active |
boolean | Enable or disable delivery without deleting the webhook. |
curl -X PUT https://api.qeychain.xyz/api/v1/webhooks/9c1f6d2a-4e8b-4b0a-8f3d-1a2b3c4d5e6f \ -H "Authorization: Bearer $QEYCHAIN_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "events": ["transaction.new"], "is_active": false }'The response is the updated webhook object in the standard envelope.
Delete a webhook
Section titled “Delete a webhook”DELETE /api/v1/webhooks/{webhook_id} · Auth required
curl -X DELETE https://api.qeychain.xyz/api/v1/webhooks/9c1f6d2a-4e8b-4b0a-8f3d-1a2b3c4d5e6f \ -H "Authorization: Bearer $QEYCHAIN_ACCESS_TOKEN"{ "success": true, "data": { "deleted": true, "webhook_id": "9c1f6d2a-4e8b-4b0a-8f3d-1a2b3c4d5e6f" }, "meta": { "...": "..." }}Deleting a webhook also removes its delivery logs.
Rotate the signing secret
Section titled “Rotate the signing secret”POST /api/v1/webhooks/{webhook_id}/rotate-secret · Auth required
Generates a fresh signing secret and returns it once. The old secret becomes invalid immediately, so update your verification code before or right after rotating.
curl -X POST https://api.qeychain.xyz/api/v1/webhooks/9c1f6d2a-4e8b-4b0a-8f3d-1a2b3c4d5e6f/rotate-secret \ -H "Authorization: Bearer $QEYCHAIN_ACCESS_TOKEN"{ "success": true, "data": { "webhook_id": "9c1f6d2a-4e8b-4b0a-8f3d-1a2b3c4d5e6f", "secret": "whsec_0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9", "message": "Secret rotated successfully. Store the new secret securely." }, "meta": { "...": "..." }}Send a test event
Section titled “Send a test event”POST /api/v1/webhooks/{webhook_id}/test · Auth required
Delivers a synthetic event to your endpoint immediately and returns the resulting delivery log — the best way to validate connectivity and signature verification end to end.
curl -X POST https://api.qeychain.xyz/api/v1/webhooks/9c1f6d2a-4e8b-4b0a-8f3d-1a2b3c4d5e6f/test \ -H "Authorization: Bearer $QEYCHAIN_ACCESS_TOKEN"Your endpoint receives a POST with this body (the event value is test, not
one of the subscription event types):
{ "event": "test", "event_id": "5d4c3b2a-1908-47f6-8e5d-4c3b2a190876", "timestamp": "2026-07-29T12:40:00.000Z", "data": { "message": "This is a test event from Qeychain", "webhook_id": "9c1f6d2a-4e8b-4b0a-8f3d-1a2b3c4d5e6f" }}The API response summarizes the delivery attempt:
{ "success": true, "data": { "test_result": "success", "log": { "id": "7a6b5c4d-3e2f-4109-8877-665544332211", "event_type": "test", "event_id": "5d4c3b2a-1908-47f6-8e5d-4c3b2a190876", "attempt": 1, "status": "success", "status_code": 200, "response_time_ms": 142, "error_message": null, "delivered_at": "2026-07-29T12:40:00.142Z", "created_at": "2026-07-29T12:40:00.000Z" } }, "meta": { "...": "..." }}test_result is success when your endpoint responds with a 2xx status,
otherwise failed.
Delivery logs
Section titled “Delivery logs”GET /api/v1/webhooks/{webhook_id}/logs · Auth required
Each delivery attempt — including every retry and every test — is recorded. Use the logs to debug failing endpoints.
Query parameters
Section titled “Query parameters”| Name | Type | Required | Description |
|---|---|---|---|
page |
integer | no | Page number, 1-indexed. Default 1. |
per_page |
integer | no | Items per page, 1–100. Default 50. |
event_type |
string | no | Filter to a single event type (e.g. transaction.new). |
status |
string | no | Filter by delivery status: pending, success, failed, or retrying. |
curl "https://api.qeychain.xyz/api/v1/webhooks/9c1f6d2a-4e8b-4b0a-8f3d-1a2b3c4d5e6f/logs?status=failed" \ -H "Authorization: Bearer $QEYCHAIN_ACCESS_TOKEN"{ "success": true, "data": [ { "id": "7a6b5c4d-3e2f-4109-8877-665544332211", "event_type": "transaction.new", "event_id": "1122aabb-ccdd-4eef-8899-001122334455", "attempt": 2, "status": "failed", "status_code": 500, "response_time_ms": 87, "error_message": "HTTP 500", "delivered_at": null, "created_at": "2026-07-29T11:58:00.000Z" } ], "meta": { "...": "..." }, "pagination": { "page": 1, "per_page": 50, "total": 1, "total_pages": 1, "has_more": false }}Delivery payload
Section titled “Delivery payload”When an event fires, Qeychain POSTs a JSON body with a fixed top-level shape.
The data object varies by event type; the envelope does not.
{ "event": "transaction.new", "event_id": "1122aabb-ccdd-4eef-8899-001122334455", "timestamp": "2026-07-29T11:57:59.000Z", "data": { "...event-specific fields..." }}| Field | Type | Description |
|---|---|---|
event |
string | The event type (or test for test deliveries). |
event_id |
string | Unique ID for this event. Use it to deduplicate — the same event_id may arrive more than once across retries. |
timestamp |
string | ISO 8601 UTC time the event was generated. |
data |
object | Event-specific payload. |
Delivery request headers
Section titled “Delivery request headers”Every delivery to your endpoint carries these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
Qeychain-Webhook/1.0 |
X-Qeychain-Event |
The event type, e.g. transaction.new |
X-Qeychain-Event-ID |
The event’s event_id |
X-Qeychain-Signature |
HMAC signature — present when signing is enabled (see below) |
Any custom headers you set on the webhook are merged in as well.
Retries
Section titled “Retries”A delivery counts as successful when your endpoint returns a 2xx status
within the timeout. Non-2xx responses, timeouts, and connection errors are
retried. Defaults:
- Timeout: 30 seconds per attempt
- Max attempts: 3 (so up to two inter-attempt delays in the immediate path)
- Backoff between attempts: 10s, then 30s (a longer requeue delay applies if a delivery is later re-queued for retry)
Each attempt is written to the delivery log with its own attempt number.
Verifying signatures
Section titled “Verifying signatures”When webhook signing is enabled on the Qeychain server, each delivery includes
an X-Qeychain-Signature header so you can confirm the request genuinely came
from Qeychain and was not tampered with.
The header format is:
X-Qeychain-Signature: t=1769690279,v1=3a5f...c2b1t— the Unix timestamp (seconds) used when signing.v1— the hex HMAC-SHA256 signature.
The signature is computed over the string "{t}.{payload}", where payload
is the JSON body re-serialized with sorted keys and no whitespace
(compact separators). The key is your webhook’s signing secret (the
whsec_... value).
Verification example (Python)
Section titled “Verification example (Python)”import hashlibimport hmacimport jsonimport time
def verify(secret: str, raw_body: bytes, signature_header: str, tolerance: int = 300) -> bool: # Parse "t=<ts>,v1=<sig>" parts = dict(p.split("=", 1) for p in signature_header.split(",")) timestamp = int(parts.get("t", 0)) received = parts.get("v1", "")
# Reject stale or future-dated requests if abs(int(time.time()) - timestamp) > tolerance: return False
# Canonicalize exactly as the sender does payload = json.loads(raw_body) payload_str = json.dumps(payload, sort_keys=True, separators=(",", ":")) signed = f"{timestamp}.{payload_str}"
expected = hmac.new( secret.encode(), signed.encode(), hashlib.sha256 ).hexdigest()
return hmac.compare_digest(expected, received)Qeychain rejects a signature whose timestamp is more than 300 seconds old
or in the future; mirror that tolerance in your verifier to guard against
replay. Always compare signatures in constant time (hmac.compare_digest).
