Webhooks

For engineers building the endpoint that receives Quant Flow Platform events. How to register a destination, verify a delivery's signature, deduplicate, read delivery history and replay missed events.

Who this is for

Integration engineers building the receiving end of the platform's events, and security reviewers checking how an event is authenticated. This page covers registration, the event types, the request the platform sends, signature verification with code, secret rotation, delivery history, replay and ordering.

Why webhooks

Operations on the platform complete asynchronously. POST /api/transactions answers 202 on acceptance; the operation then moves through policy, approval and settlement. A webhook tells your system when an outcome is reached, for example transaction.confirmed, so it can post to the core banking ledger without polling.

Webhooks are a notification. The API remains the record: when your system needs the current state of a resource, read it. See Reconcile against your core banking.

Use events to update your integration safely

Treat receipt and business processing as separate steps. The receiver verifies the delivery and durably stores the event; a worker deduplicates it, reads the affected resource where current state is needed, and updates your bank's instruction record. Return success after durable receipt rather than waiting for a slow core-banking posting.

For a redemption, an authenticated confirmation event can prompt the worker to establish the burn's final outcome and perform the payout once. Retain the event ID and transaction ID, and enforce the once-only booking in your own store. An authentic duplicate event must not pay the customer again.

If a callback never arrives, read the operation and investigate delivery history. Request redelivery if useful; do not submit another mint, burn or transfer as a way to recover a notification. Event arrival order is not a guarantee of state order, so an older event must not overwrite a final outcome already recorded.

Receive, verify and reconcile an event

%%{init: {"theme": "neutral"}}%%
flowchart TB
    Event["Platform business event"] --> Delivery["Signed HTTPS delivery"]
    Delivery --> Verify{"Signature and timestamp valid?"}
    Verify -->|No| Reject["Reject / do not process"]
    Verify -->|Yes| Save["Persist eventId and payload durably"]
    Save --> Ack["Return 2xx promptly"]
    Save --> Dedupe["Worker deduplicates by eventId"]
    Dedupe --> Read["Read affected resource from API"]
    Read --> Apply["Apply current state to your records"]

Acknowledge only after durable receipt. Retries can repeat an event, and events can arrive out of order. Resource reads establish current state; delivery history supports recovery.

Register receiver · Delivery history · Read transaction

Register a destination

POST /api/webhooks with the URL and the event types it should receive.

FieldRequiredNotes
urlYesMust use https://. The host must resolve to a publicly routable address. Private ranges, loopback, link-local and carrier-grade NAT addresses are refused.
eventTypesYesAt least one. An unknown value is a 400 naming every offender.
signatureSchemeNoHMAC_SHA256 (the default) or JWS_ES256. Fixed for the life of the registration. An unknown value is rejected with 400.

Rules to know:

  • Store the secret at once. On HMAC_SHA256 the 201 response carries the generated secret: 32 random bytes, base64url encoded without padding, 43 characters. The platform generates it; you cannot supply one. No read returns it again. If you lose it, rotate. On the platform side, the secret is encrypted with a dedicated key before it is stored, and is decrypted only in memory when a delivery is signed.
  • One registration per URL per organisation. Registering the same URL again is a 409.
  • The URL cannot be changed. To move events elsewhere, delete the registration and register the new URL. You get a new identifier and a new secret.
  • The stored URL is normalised. Scheme and host are lowercased and a default :443 is dropped, so a later read may differ in case.
  • Redirects are not followed at delivery time.
  • A test delivery is sent on registration. It carries the webhook.test event type and appears in delivery history. Send another at any time with POST /api/webhooks/{id}/test, which answers 200 with the outcome of the attempt.

Agree inbound network access with the operator before registering the receiver. The public API does not promise client certificates or a fixed published egress-IP list.

Manage a registration

TaskEndpointNotes
ListGET /api/webhooksNewest first, cursor paged.
ReadGET /api/webhooks/{id}Never returns the secret.
Change status or event typesPATCH /api/webhooks/{id}An omitted field is left alone. eventTypes replaces the whole set; [] subscribes to nothing while keeping the registration. Past events are not backfilled.
Rotate the secretPOST /api/webhooks/{id}/secret/rotateHMAC_SHA256 only. See Rotate the secret.
DeleteDELETE /api/webhooks/{id}Queued deliveries become CANCELLED. History stays readable. The URL is freed.

Registration status

StatusSet byMeaning
ACTIVEYouReceiving deliveries.
INACTIVEYouReceiving nothing.
SUSPENDEDThe platformSet after repeated delivery failures. Deliveries stop being attempted and events queue behind it. You cannot request this status.

To resume a SUSPENDED registration, PATCH it with "status": "ACTIVE". This clears the suspension and resets the failure count, and the queued events are then delivered. A successful test delivery also resets the consecutive failure count.

The notification integration guide specifies suspension after twenty consecutive delivery failures. A success resets the consecutive-failure count.

Event types

Event typeSent when
transaction.confirmedA token operation settled.
transaction.failedA token operation failed.
transaction.rejectedA token operation was refused.
transaction.cancelledA token operation was cancelled.
transaction.awaiting_approvalA token operation is waiting for an approver.
account.provisionedAn account finished provisioning and has a wallet address.
account.provisioning_failedAccount provisioning failed.
account.status_changedAn account's status changed.
customer.status_changedA customer's status changed.
approval.requestedAn approval request was raised.
approval.grantedAn approval request was approved.
approval.rejectedAn approval request was rejected.
approval.expiredAn approval request expired.
webhook.testA test delivery.

The "Sent when" column is inferred from each event type's name. Use event names to route notifications, then read the affected resource for its actual state. Failure and rejection names do not establish a complete state-to-event mapping.

The delivery request

Each delivery is an HTTP POST to your URL with the event as a JSON body.

Headers

HeaderMeaning
Content-Typeapplication/json.
Content-DigestSHA-256 of the exact body bytes, in RFC 9530 form: sha-256=:<base64>:.
Webhook-Event-IdThe event's identifier. Your deduplication key.
Webhook-TimestampWhen the delivery was signed, in Unix epoch seconds.
Webhook-Delivery-AttemptWhich attempt this is, counting from 1.
Webhook-SignatureHMAC_SHA256 registrations only. v1,<base64 signature>.
X-JWS-SignatureJWS_ES256 registrations only. A compact claims JWS, header.payload.signature.

The notification integration guide defines the headers and signing procedure below. Verify a signed test delivery against your environment before enabling business processing.

Body

The body is the stored event:

FieldMeaning
idThe event's identifier, the same value as Webhook-Event-Id.
typeOne of the event types above.
occurredAtWhen the event happened, as yyyy-MM-dd HH:mm:ss.SSS in UTC.
correlationIdThe correlation identifier carried from the originating request. Omitted when there was none.
idempotencyKeyThe Idempotency-Key the originating request carried, where it had one. Use it to match the event to the request your system sent.
resourceWhat the event is about, as a type and an id.
dataThe event's payload. Its shape depends on the event type.

Validate the event envelope and the specific data fields your handler uses. Because no complete per-event payload schema is published, recover the current resource through its API rather than assuming an example is exhaustive.

Verify the signature

Verify every delivery before acting on it. Reject any request that fails.

HMAC_SHA256

The signature is an HMAC-SHA256 over the timestamp, a full stop and the raw body bytes, keyed with your registration's secret, then base64 encoded:

signature = base64( HMAC-SHA256( secret, Webhook-Timestamp + "." + raw body ) )

The timestamp is inside the signed content, so a captured delivery cannot be resent with a fresh timestamp.

Steps:

  1. Read the raw request body as bytes, before any JSON parsing.
  2. Read Webhook-Timestamp. Reject the request if it is more than 300 seconds from your clock, as specified by the notification integration guide. Keep your server clock synchronized.
  3. Compute the expected signature.
  4. Split Webhook-Signature on commas. It holds one or more v1,<signature> pairs. Accept the request if any v1 signature matches.
  5. Compare in constant time.

The published integration documentation does not settle whether the delivered secret is interpreted as UTF-8 bytes or decoded key material. The examples below use UTF-8. Confirm that interpretation with a signed test delivery before enabling the verifier; do not accept whichever interpretation happens to match an untrusted request.

Python:

import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300

def verify_webhook(secret: str, headers: dict, raw_body: bytes) -> bool:
    timestamp = headers.get("Webhook-Timestamp")
    signature_header = headers.get("Webhook-Signature")
    if not timestamp or not signature_header:
        return False

    try:
        sent_at = int(timestamp)
    except ValueError:
        return False
    if abs(time.time() - sent_at) > TOLERANCE_SECONDS:
        return False

    signed_content = timestamp.encode("utf-8") + b"." + raw_body
    digest = hmac.new(secret.encode("utf-8"), signed_content, hashlib.sha256).digest()
    expected = base64.b64encode(digest).decode("ascii")

    # "v1,<sig>" or, during a secret rotation, "v1,<new>,v1,<previous>"
    parts = signature_header.split(",")
    for version, candidate in zip(parts[0::2], parts[1::2]):
        if version == "v1" and hmac.compare_digest(candidate, expected):
            return True
    return False

JavaScript (Node.js):

const crypto = require("crypto");

const TOLERANCE_SECONDS = 300;

function verifyWebhook(secret, headers, rawBody) {
  // headers: lower-cased header names, as Node.js provides them
  // rawBody: a Buffer holding the exact request body bytes
  const timestamp = headers["webhook-timestamp"];
  const signatureHeader = headers["webhook-signature"];
  if (!timestamp || !signatureHeader) return false;

  const sentAt = Number.parseInt(timestamp, 10);
  if (!Number.isFinite(sentAt)) return false;
  if (Math.abs(Date.now() / 1000 - sentAt) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", Buffer.from(secret, "utf8"))
    .update(Buffer.concat([Buffer.from(timestamp + ".", "utf8"), rawBody]))
    .digest("base64");
  const expectedBytes = Buffer.from(expected, "utf8");

  // "v1,<sig>" or, during a secret rotation, "v1,<new>,v1,<previous>"
  const parts = signatureHeader.split(",");
  for (let i = 0; i + 1 < parts.length; i += 2) {
    if (parts[i] !== "v1") continue;
    const candidate = Buffer.from(parts[i + 1], "utf8");
    if (
      candidate.length === expectedBytes.length &&
      crypto.timingSafeEqual(candidate, expectedBytes)
    ) {
      return true;
    }
  }
  return false;
}

module.exports = { verifyWebhook };

In an Express application, capture the raw body with express.raw({ type: "application/json" }) on the webhook route. Re-serialising parsed JSON changes the bytes and breaks verification.

JWS_ES256

X-JWS-Signature is a compact JWS in the form header.payload.signature, signed with ES256. Its payload is a claims set, not the raw event body. The body_hash claim binds the exact body bytes to the token. This replaces the older detached-JWS form; a receiver expecting header..signature will reject current deliveries.

Obtain the trusted issuer and key-set URL for your environment before enabling this scheme. The notification integration documentation provides a staging key-set location, but it does not establish a universal production location. Signing-key rotation is not currently implemented.

FieldRequired check
Protected header alg, kidRequire ES256 and verify using the matching key from the trusted key set. Do not follow a key URL supplied by the token.
iss, subMatch the configured platform issuer for your environment.
audMatch your webhook registration's endpoint ID.
iat, expEnforce expiry and your clock-skew allowance; iat matches Webhook-Timestamp. The documented default lifetime is 60 seconds.
jtiReject an already-used attempt ID and retain it at least until expiry. A retry uses a new attempt ID.
htm, htuRequire POST and the full HTTPS URL registered for your receiver, not just its path.
body_hash_alg, body_hashRequire SHA-256 and compare unpadded base64url SHA-256 of the raw request bytes.

Verify the signature and all required claims before accepting the event. Deduplicate business processing on Webhook-Event-Id, not jti: the first identifies the event, while the second identifies one delivery attempt. Continue to use API reads for recovery when an event is missing.

Rotate the secret

POST /api/webhooks/{id}/secret/rotate returns a new secret once, with previousSecretExpiresAt.

  • Until that moment, every delivery carries two signatures in one header, newest first: v1,<new>,v1,<previous>. The code above accepts either.
  • The overlap window is 24 hours by default. Deploy the new secret before previousSecretExpiresAt.
  • Rotating twice inside the window retires the older secret immediately. Only one previous secret is held.
  • A JWS_ES256 registration has no secret; rotation returns 404.

Respond, deduplicate and order

Respond with a 2xx as soon as you have stored the event, then process it. A delivery counts as delivered when your endpoint answers with a success status. A slow answer is recorded as TIMEOUT and retried, so processing before answering turns latency into duplicates. The documented read timeout is five seconds. Acknowledge only after durable acceptance, with business processing handled asynchronously.

Deduplicate on the event id. The same event can arrive more than once: after a retry, a timeout, or a replay you requested. Treat a repeat id as a no-op.

Keep what you verified if you rely on it as evidence. Store the signature header, the timestamp and the raw body together. The signature can only be re-checked against the exact bytes that were signed.

Do not rely on arrival order. The API Reference states no ordering guarantee for deliveries. Order events by occurredAt, and when the sequence matters, read the resource's current state from the API. No delivery-order guarantee is published. Deduplicate by event identity and recover the resource's current state when order matters.

Delivery history and retries

Every event queued for your organisation, and every attempt to deliver it, is readable.

TaskEndpoint
List events with their deliveriesGET /api/webhook-events
Read one event with every deliveryGET /api/webhook-events/{id}

GET /api/webhook-events is newest first on occurredAt, cursor paged, and filters by type, from (inclusive), to (exclusive), delivery status and webhookId. An event that arrived while nothing was subscribed to its type produced no delivery and is not listed; reading it by id returns it with an empty deliveries array.

Each delivery has a status:

StatusMeaningSettled
PENDINGQueued, not yet attempted.No
RETRY_PENDINGAttempted, failed, waiting for its next attempt (nextRetryAt).No
DELIVEREDAccepted by your endpoint.Yes
PARKEDGiven up after exhausting its attempts. Carries failureClass, failureReason and parkedAt.Yes
CANCELLEDThe destination was deleted before it was sent.Yes

A failed attempt records failureClass: UNREACHABLE (no connection), TIMEOUT (no answer in time) or REJECTED (a status that is not a success).

The documented retry budget is eleven attempts, starting at 30 seconds, doubling to a one-hour cap with ±20% jitter. Network failures, timeouts, 5xx, 408 and 429 are retried; other 4xx park after two attempts. A parked delivery remains in history for investigation and replay.

Replay

A replay creates a new delivery at the next generation, PENDING with attempts back to zero. The original delivery is never changed, so the record of the first attempt survives. Only a delivery whose latest generation is settled can be replayed.

FormEndpointBehaviour
One eventPOST /api/webhook-events/{id}/redeliverStrict. Replays to every destination that received the event, or to the one webhookId in the body. Anything that cannot be replayed fails the whole call with 404 or 409.
By filterPOST /api/webhook-events/redeliverLenient. Takes the history filters (at least one is required). Deliveries that cannot be replayed are listed in skipped with a reason; the rest are queued.

Limits on replay:

  • A filtered call queues at most 500 events and examines at most 2,000. truncated: true means matches remain; call again to continue.
  • An organisation may queue at most 1,000 replays per hour. Beyond that, the call returns 429 and queues nothing.

After an outage at your end, replay with "status": "PARKED" to resend the deliveries that exhausted their attempts while you were down.

Own the receiver and the recovery journal

Your bank owns endpoint availability, trusted-key/secret configuration, durable intake and duplicate-safe business processing. Quant Flow supplies signed deliveries, the documented retry process and history/replay APIs. Acknowledge durable receipt first; process cash/customer state through a recoverable worker rather than making webhook retries repeat a posting.

Measure receiver intake and business-worker backlog separately. After an outage, compare stored event IDs and unresolved instruction IDs, replay eligible deliveries and read current resource state. Do not assume subscription changes backfill missing history. Include receiver failure, signature rejection and duplicates in Production readiness; monitor suspension through Daily operations and monitoring.

Worked example

Register a destination

POST /api/webhooks HTTP/1.1
Host: api.qfp.quantnetwork.net
Authorization: Bearer eyJraWQiOiJ...
API-Version: 1.0.0
Content-Type: application/json
Idempotency-Key: 0a6f3e2d-8c17-4b59-9e40-7d1c5b2a8f93

{
  "url": "https://bank.example.com/quant/events",
  "eventTypes": ["transaction.confirmed", "transaction.failed"],
  "signatureScheme": "HMAC_SHA256"
}
HTTP/1.1 201 Created
Location: /api/webhooks/9f2a1c44-6e3b-4f8a-9d21-7c5e0b8a4d13
Content-Type: application/json

{
  "id": "9f2a1c44-6e3b-4f8a-9d21-7c5e0b8a4d13",
  "url": "https://bank.example.com/quant/events",
  "signatureScheme": "HMAC_SHA256",
  "eventTypes": ["transaction.confirmed", "transaction.failed"],
  "secret": "8Kd2ZqL0vXmB7nR4tYcJ1sWfA6gH3pE9uQ5xN0iVrTk",
  "createdAt": "2026-08-28 09:15:11.584",
  "updatedAt": "2026-08-28 09:15:11.584"
}

Receive a delivery

Illustrative example built from the schema.

POST /quant/events HTTP/1.1
Host: bank.example.com
Content-Type: application/json
Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
Webhook-Event-Id: 0198c2f7-aadd-8fcc-e0a1-3c4d5e6f7a8b
Webhook-Timestamp: 1786445118
Webhook-Delivery-Attempt: 1
Webhook-Signature: v1,3yaYdiOtHumjEbUex4COrjzu4UBErhOoAqopPQbr+OI=

{
  "id": "0198c2f7-aadd-8fcc-e0a1-3c4d5e6f7a8b",
  "type": "transaction.confirmed",
  "occurredAt": "2026-08-28 09:15:11.584",
  "correlationId": "c0ffee00-0000-4000-8000-000000000001",
  "idempotencyKey": "bank-mint-2026-08-28-0042",
  "resource": {
    "type": "transaction",
    "id": "3f7c1a90-5d2e-4b08-9a41-6c2b8e5d7f13"
  },
  "data": {
    "operation": "MINT",
    "amount": "10.50"
  }
}

Your endpoint verifies the signature, stores the event keyed on id, and answers:

HTTP/1.1 204 No Content

Replay parked deliveries after an outage

POST /api/webhook-events/redeliver HTTP/1.1
Host: api.qfp.quantnetwork.net
Authorization: Bearer eyJraWQiOiJ...
API-Version: 1.0.0
Content-Type: application/json
Idempotency-Key: 5b0d7e41-2f9c-4a63-8e15-c4a7d2f0b981

{
  "status": "PARKED",
  "from": "2026-08-28T09:00:00Z",
  "to": "2026-08-28T17:00:00Z"
}

Illustrative example built from the schema.

HTTP/1.1 202 Accepted
Content-Type: application/json

{
  "queued": [
    {
      "id": "7d1b4e02-3a95-4c17-8f6d-2b0e9a5c1d84",
      "webhookId": "9f2a1c44-6e3b-4f8a-9d21-7c5e0b8a4d13",
      "generation": 1,
      "status": "PENDING",
      "attempts": 0,
      "createdAt": "2026-08-28 17:02:40.118"
    }
  ],
  "skipped": [
    {
      "eventId": "4a1e9b73-2c86-4d05-9e71-8f3b0d6c2a54",
      "webhookId": "3c8d5b21-9a47-4e60-b1f3-6d2a8e4c7f90",
      "reason": "The latest delivery is still queued"
    }
  ],
  "truncated": false
}

View in API Reference

Errors and edge cases

SituationResponseAction
URL not https, or resolves to a private address400Use a public https URL.
URL already registered409Use the existing registration.
Rotating a JWS_ES256 secret404Not applicable to that scheme.
Test delivery to an INACTIVE registration409Activate it first.
Replay while the latest delivery is still queued409Wait for it to settle.
Replay to a deleted destination404Register a destination and replay by filter.
Replay budget exhausted429Retry later. Nothing was queued.
Filtered replay with no filter400Supply at least one filter.

Edge cases:

  • Timestamps in this area use yyyy-MM-dd HH:mm:ss.SSS in UTC. The from and to filters accept ISO 8601 or that format.
  • A 302 from your endpoint is a failed attempt. Redirects are not followed.
  • An unsubscribed event is not delivered later. Changing eventTypes takes effect from that moment; past events are not backfilled.
  • Changing scheme means deleting the registration and registering again.

Follow a Recipe

Step through the code in JavaScript or Python, inspect illustrative responses, and open the connected endpoint schemas.

API Reference

Open an endpoint for request fields, response examples and the interactive API console.

Related pages

Implementation resources

Delivery history code recipes.

Use Implement a backend client for request handling, durable idempotency and outcome tracking. The developer integration coverage map links every public operation to its API Reference and contract-derived code recipe. Replace example identifiers with values from your assigned environment.


Did this page help you?