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
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: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 setsconsecutive_failuresback to 0.
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:
- Rotate:
{"overlap_seconds": 3600}. - Deploy the new secret to your verifier before
previous_secret_expires_at. - After that time, only the new secret signs.
{"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/retrywithfrom(required) and, optionally,to,typeandendpoint, the same as the log’s filters. Each is one string,typewith several values separated by commas in it. A list or an object answers422and nothing is sent: a filter is never dropped.
id and payload, so your deduplication on id still holds.
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.confirmedcan arrive beforeoperation.approved. Read the current state withGET /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 /transfersandGET /transactionslist what happened, in the same bodies, andGET /runslists the workflow runs: see Catch up after missed webhooks. Deliveries that failed can also be sent again.