Skip to content

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.

All requests target the production host:

https://api.qeychain.xyz

Every webhook endpoint requires a valid Bearer JWT access token in the Authorization header. A request with no resolvable session returns 401.

Terminal window
curl https://api.qeychain.xyz/api/v1/webhooks \
-H "Authorization: Bearer $QEYCHAIN_ACCESS_TOKEN"

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://

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

POST /api/v1/webhooks  ·  Auth required

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.
Terminal window
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"
}'

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": { "...": "..." }
}

GET /api/v1/webhooks  ·  Auth required

Name Type Required Description
page integer no Page number, 1-indexed. Default 1.
per_page integer no Items per page, 1100. Default 50.
include_inactive boolean no Include deactivated webhooks. Default false.
Terminal window
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 /api/v1/webhooks/{webhook_id}  ·  Auth required

Name Type Required Description
webhook_id UUID yes ID returned when the webhook was created.
Terminal window
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.

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.

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.
Terminal window
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 /api/v1/webhooks/{webhook_id}  ·  Auth required

Terminal window
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.

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.

Terminal window
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": { "...": "..." }
}

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.

Terminal window
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.

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.

Name Type Required Description
page integer no Page number, 1-indexed. Default 1.
per_page integer no Items per page, 1100. 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.
Terminal window
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 }
}

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.

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.

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.

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...c2b1
  • t — 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).

import hashlib
import hmac
import json
import 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).