Create a webhook subscription
POST/v1/webhooks
Register an HTTPS endpoint to receive signed event notifications. Requires the
connector:webhooks:create scope and an Idempotency-Key header.
Request
urlmust be an absolutehttps://URL with a host. Plainhttp://is rejected.delivery_formatmust bejsonapi.iso20022+xmlis reserved and currently rejected with400 INVALID_FIELD_FORMAT.security.signature.secretis a shared secret that your bank chooses (the platform does not generate one). It is write-only and is never returned. Use a long random value (for example 32 random bytes, hex or base64 encoded). The platform uses the UTF-8 bytes of this string, exactly as sent, as the HMAC key.security.signature.secret_versionis your label for this secret (1 to 64 printable ASCII characters, no spaces, for examplev1). It is echoed on every delivery in theX-DAN-Secret-Versionheader so you know which secret to verify with.event_typeslists the event types to receive. Values are matched exactly. An empty array receives no business events (only the verification handshake). Values are not validated: a misspelled type is accepted but never matches.
What happens next
The response is 202 Accepted with an operation. The subscription is created
immediately: its webhook_id is the relationships.resource.data.id of
GET /v1/operations/{operation_id}. The new subscription starts in
PENDING_VERIFICATION and the platform immediately sends one webhook.verification
event to url (the verification handshake). When your endpoint answers it with any
2xx status, the subscription becomes ACTIVE and starts receiving the event types it
subscribed to. Events that occur while a subscription is not ACTIVE are not delivered
to it later.
Retrying the request with the same Idempotency-Key and body returns the same 202
document and does not create a second subscription.
Event types
| Event type | Sent when | resource.type |
|---|---|---|
operation.updated | an operation is accepted, starts processing, or starts waiting for ledger commits | operations |
operation.manual_review | an operation starts waiting for an approval review (operation state PENDING_REVIEW) | operations |
operation.succeeded | an operation completes successfully | operations |
operation.rejected | an operation is rejected (for example by a review) | operations |
operation.failed | an operation fails | operations |
review.created | a new approval review is opened | reviews |
review.updated | an approval was recorded but more approvals are still needed | reviews |
review.approved | a review reached its approval threshold | reviews |
review.rejected | a review was rejected | reviews |
transfer.received | a transfer to your bank (as destination holder bank) completed; carries the parties' registered accounts and any sender (originator) and beneficiary details the sender submitted, for your bank to screen, resolve and credit | transfers |
beneficiary.screening.requested | your bank is asked to screen the beneficiary of an incoming transfer | transfers |
webhook.verification | handshake, sent to a subscription in PENDING_VERIFICATION; not subject to event_types | webhooks |
webhook.test | synthetic test event requested through POST /v1/webhooks/{webhook_id}/test | webhooks |
Each business event is delivered to every ACTIVE subscription of the bank the event
belongs to that lists its type. The exact body of each event is shown in the
webhookDelivery callback below.
Delivery request
Each delivery is an HTTPS POST to url with a compact JSON body
(Content-Type: application/json) and these headers:
| Header | Value |
|---|---|
X-DAN-Event-Id | UUID of the event. Part of the signed string. |
X-DAN-Event-Type | Event type, for example operation.succeeded. |
X-DAN-Webhook-Id | UUID of the subscription. |
X-DAN-Delivery-Id | UUID of this delivery (one per event and subscription; the same on every retry). |
X-DAN-Timestamp | Time the request was signed, RFC 3339 in UTC, for example 2026-09-26T10:15:30.482913+00:00. New on every attempt. |
X-DAN-Secret-Version | secret_version of the secret that produced the signature. |
X-DAN-Signature | v1= followed by the lowercase hex HMAC-SHA256 signature. |
Authorization | Bearer <access token>, only when an auth profile is bound. |
The platform only connects over HTTPS (TLS 1.2 or later, certificate verified), does not follow redirects, and refuses URLs that resolve to loopback, link-local, multicast or unspecified addresses. Connect timeout is 10 seconds; the whole request must complete within 30 seconds.
Verifying the signature
The signature is HMAC-SHA256(key = secret, message = X-DAN-Timestamp + "." + X-DAN-Event-Id + "." + raw request body), hex encoded in lowercase and prefixed
with v1=. Always compute it over the raw body bytes as received, before any JSON
parsing or re-serialisation. Compare in constant time. Pick the secret by the
X-DAN-Secret-Version header. The platform does not reject old timestamps for you; we
recommend that you reject requests whose X-DAN-Timestamp is more than 5 minutes from
your clock, to limit replay (retries are re-signed with a fresh timestamp, so this does
not break retries).
Python:
import hmac, hashlib
from datetime import datetime, timezone
SECRETS = {"v1": b"your-webhook-secret"} # keyed by secret_version
def verify(headers, raw_body: bytes) -> bool:
secret = SECRETS.get(headers["X-DAN-Secret-Version"])
if secret is None:
return False
ts = headers["X-DAN-Timestamp"]
age = datetime.now(timezone.utc) - datetime.fromisoformat(ts)
if abs(age.total_seconds()) > 300:
return False
message = ts.encode() + b"." + headers["X-DAN-Event-Id"].encode() + b"." + raw_body
expected = "v1=" + hmac.new(secret, message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers["X-DAN-Signature"])
Node.js:
const crypto = require("crypto");
const SECRETS = { v1: "your-webhook-secret" }; // keyed by secret_version
function verify(headers, rawBody /* Buffer */) {
const secret = SECRETS[headers["x-dan-secret-version"]];
if (!secret) return false;
const ts = headers["x-dan-timestamp"];
if (Math.abs(Date.now() - Date.parse(ts)) > 5 * 60 * 1000) return false;
const expected = "v1=" + crypto.createHmac("sha256", secret)
.update(`${ts}.${headers["x-dan-event-id"]}.`)
.update(rawBody)
.digest("hex");
const given = String(headers["x-dan-signature"] || "");
return given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
Responding, retries and duplicates
- Answer with any
2xxstatus to acknowledge. The response body is ignored. Respond quickly and process asynchronously. 429, any5xx, a timeout or a connection error is retried. The retry schedule is set by the platform deployment; in the standard configuration a delivery is attempted at most 5 times, with increasing delays between attempts (currently 1, 5, 15 and 30 minutes). After the last failed attempt the delivery is markedFAILEDand is not retried again.- Any other status (
3xx,400,401,403,404and other4xx) is a permanent failure: the delivery is markedFAILEDimmediately and not retried. - Delivery is at least once: the same event can arrive more than once (for example
after a timeout on a request you actually processed). De-duplicate on the body
id(orX-DAN-Delivery-Id), which stays the same across retries. - Ordering is not guaranteed, across events or even for the same resource, because
retries interleave with newer events. Use
occurred_atto order events, and fetch the resource (data.links.self) when you need its current state. - Inspect delivery history with
GET /v1/webhooks/deliveries?filter[webhook_id]=....
Request
Responses
- 202
- 400
- 401
- 403
- 409
- 422
- 429
- 503
Accepted. The operation (data.id) tracks the creation; poll
GET /v1/operations/{operation_id} to read the new webhook_id from
relationships.resource. The verification handshake is sent right after.
The request is malformed or a field fails its format rule: the URL is not a valid
https:// URL with a host, delivery_format is not jsonapi, the secret is empty,
secret_version is empty, too long or contains spaces, or the Idempotency-Key
header is missing.
The bearer token is missing, malformed, expired, signed by an unknown key, or was not issued by the platform IAM for the Lyriq Connector. Obtain a new token and retry. See the Authentication section.
The token is valid but may not perform this request: it lacks the required scope, has
no bank membership, needs an x-dan-bank-id header to choose between several
memberships, names a bank in x-dan-bank-id it has no membership for, or the caller's
bank is suspended or terminated. A new token with the same configuration fails the same
way. See the Authentication section.
The request conflicts with an earlier request or with the current state of the target:
an Idempotency-Key reused with a different body (IDEMPOTENCY_CONFLICT), a request with
the same key still in progress (IDEMPOTENCY_PENDING), or a target resource in a state
that does not allow the request (STATE_CONFLICT).
The request is well-formed JSON but cannot be processed: the body does not match the
expected shape (a missing or unknown member, a wrong type, or a wrong data.type), or it
breaks a business or cross-field rule. Correct the request before retrying.
The request was refused because a rate limit was reached (code RATE_LIMITED). No
Retry-After header is sent; retry with exponential backoff.
The request could not be served right now. Either the network is not fully operational
(OUTBOUND_HALTED or READ_ONLY: mutations are refused while read endpoints keep
working; OPERATIONAL_STATE_UNKNOWN: the state could not be determined), or a platform
dependency is temporarily unavailable. No Retry-After header is sent; retry later with
backoff. When retrying a mutation, reuse the same Idempotency-Key and body.
Callbacks
- POST webhookDelivery
POST{$request.body#/data/attributes/url}
The request the platform sends to your subscription url for every event.
Verify X-DAN-Signature over the raw body before trusting it (see
POST /v1/webhooks for the algorithm and code samples), then answer 2xx.
Every event shares one envelope:
id: event identifier,evt_followed by a UUID. Stable across retries of the same delivery; use it to de-duplicate.type: the event type (also sent inX-DAN-Event-Type).occurred_at: when the underlying change happened (RFC 3339, UTC).bank_id: the bank the event belongs to (your bank).resource:typeandidof the resource the event is about.data: a JSON:API-style resource object (type,id,attributes, and for most eventslinks.self, the API path to fetch the full resource).meta.delivery_semantics: alwaysAT_LEAST_ONCE.
Keys are serialised in alphabetical order without whitespace. Do not rely on key order; always verify the signature over the bytes you received.
Callbacks Responses
- 429
- 2XX
- 5XX
- default
Retried later, until the attempt limit is reached.
Any 2xx status acknowledges the event. The body is ignored. For a
webhook.verification event this activates the subscription.
Retried later, until the attempt limit is reached.
Any other status (including 3xx redirects and 4xx other than 429) is a
permanent failure: the delivery is marked FAILED and not retried.