Register an endpoint in the console settings or with POST /webhook_endpoints: an https:// URL and, optionally, the event types it wants (none = all). Citadel generates its signing secret (whsec_…).

Events

Bodies per type are in the API reference under Webhooks. Citadel reports an on-chain transaction once, when it is final and a reorganization can no longer undo it. There is no notice while a transaction is still pending, and status in a blockchain_event.* body is always confirmed. Credit a deposit on blockchain_event.confirmed. The body is a transfer, the same one GET /transfers lists, without aml: the webhook leaves before any compliance screening, so read the transfer again for the screenings your workflows run on it. chain is the family, instance the chain it landed on. A token leg names its token twice, as GET /assets does: asset is the symbol, contract the token itself. Credit on contract; two tokens can share a symbol.

The request

Answer any 2xx within 10 seconds. Do the work after you answer.

Verify the signature

v1 is the hex HMAC-SHA256 of "<t>.<raw body>" keyed with your endpoint secret. Verify over the raw bytes, before parsing, and reject a stale t. While a rotated secret still signs next to the new one, the header carries one v1 per secret, the new secret’s first: t=1791374400,v1=<new>,v1=<old>. Accept the delivery when any v1 matches the secret you hold:

Retries

A non-2xx, a timeout, or a refused connection is retried on a fixed schedule: immediately, then after 30 s, 2 min, 5 min, 15 min, 30 min, 1 h, 2 h, 6 h, 12 h, 24 h — 11 attempts over about two days. Each attempt is signed afresh with a new timestamp; id stays the same. After 5 deliveries in a row exhaust their attempts, the endpoint is disabled. Once it is healthy, resume it in the console or with the API, then send what failed again.

Manage endpoints

Your backend can manage its endpoints itself, for example from infrastructure code:
The signing secret is shown once, in this answer: store it now. Reads never show it. If you lose it, rotate it. Your organization has one endpoint per URL. Adding a URL it already has answers 409 endpoint_exists and adds nothing, so an add sent twice (a lost response, a retried script) never makes every event arrive twice. Read the endpoint with GET /webhook_endpoints, change it with PATCH. PATCH changes only the fields it names, all of them or none: a refused change leaves the endpoint as it was. event_types: [] hears every event; description: null clears it. The calls refuse a field they do not take (secret, network, …), a value of the wrong shape and a field sent in the query string instead of the body with 422, rather than ignore them. A url is an https:// URL with a host: https:// alone, or a URL with a space in its host, is refused with 422. The endpoints take no network: an endpoint hears every network.

Pause and resume

state is active, paused (you paused it) or disabled (Citadel turned it off after 5 deliveries in a row exhausted their attempts). A paused or disabled endpoint gets no deliveries, and in-flight ones fail.
  • PATCH /webhook_endpoints/{id} with {"state": "paused"} pauses it.
  • {"state": "active"} resumes it, paused or disabled, and sets consecutive_failures back to 0.
Resuming sends nothing by itself. The deliveries that failed stay failed until you send them again.

Test an endpoint

POST /webhook_endpoints/{id}/test posts a test.ping with an empty data, signed and retried like any delivery. The answer is that delivery (202); follow it in the delivery log. A paused or disabled endpoint answers 409 endpoint_disabled.

Rotate the signing secret

POST /webhook_endpoints/{id}/rotate_secret generates a new secret and shows it once, in the answer. The old secret keeps signing next to it for overlap_seconds: one day by default, at most 7 days (604800). Until previous_secret_expires_at, every delivery carries two v1 signatures, so a verifier holding either secret accepts it:
  1. Rotate: {"overlap_seconds": 3600}.
  2. Deploy the new secret to your verifier before previous_secret_expires_at.
  3. After that time, only the new secret signs.
Only two secrets sign at once: rotating again during an overlap retires the older one at once. {"overlap_seconds": 0} retires the old secret now, for a secret you believe leaked. Deliveries already on their way may then fail your check once; they are retried, signed with the new secret. A rotation is not idempotent: every call makes a new secret. Rotate again after a lost answer, and the secret it replaces is the one in the lost answer, so the secret your verifier holds stops signing at once. Deploy the secret from the last answer you got: until then deliveries fail your check and are retried. Reading endpoints needs the read scope; adding, changing, removing, testing and rotating, webhooks.write. A key limited to vaults is refused with 403 vault_limited: an endpoint serves every vault.

Delivery log

GET /webhook_deliveries lists what Citadel posted, or still tries to post, to your endpoints, newest first: one row per event and endpoint.
id, type, resource and data are the body your endpoint receives, without timestamp: a row you missed can go through the same handler, deduplicated on id. status is pending while attempts remain, delivered once your endpoint answered 2xx, and failed when every attempt failed or the endpoint was paused or disabled with the delivery still in flight. Filter with type and status (several values separated by commas), endpoint (the endpoint.id of a row) and from / to. The log takes no network: an endpoint hears every network. It pages like the other lists: see Lists and pagination. Removing an endpoint removes its deliveries.

Send failed deliveries again

  • One delivery: POST /webhook_deliveries/{id}/retry.
  • Every failed delivery of a period: POST /webhook_deliveries/retry with from (required) and, optionally, to, type and endpoint, the same as the log’s filters. Each is one string, type with several values separated by commas in it. A list or an object answers 422 and nothing is sent: a filter is never dropped.
A retried delivery goes back in line with the whole schedule ahead of it, first attempt now. It keeps its id and payload, so your deduplication on id still holds.
A period goes oldest first, at most 500 deliveries per call: while more is true, send the same call again. Calling again is safe, because what one call sent is pending by the next, not failed. A failed delivery whose endpoint is paused or disabled stays failed and counts in skipped: resume the endpoint, then call again. Only a failed delivery is sent again. Retrying one that is pending (already on its way) or delivered answers 409 delivery_not_failed; one whose endpoint is paused or disabled, 409 endpoint_disabled. Reading the log needs the read scope; retrying, webhooks.write. A key limited to vaults is refused both with 403 vault_limited: an endpoint serves every vault.
A paused or disabled endpoint gets no deliveries, so an event that happened meanwhile has no row to send again. Read those from GET /transfers and GET /transactions: see Catch up after missed webhooks.

Handle duplicates and order

  • At least once. Deduplicate on id.
  • Not ordered. operation.confirmed can arrive before operation.approved. Read the current state with GET /transactions/{id} when order matters.
  • A notification, not the truth. The API is the source of truth; a webhook tells you when to look. Missed some, or the endpoint was disabled? GET /transfers and GET /transactions list what happened, in the same bodies, and GET /runs lists the workflow runs: see Catch up after missed webhooks. Deliveries that failed can also be sent again.