Skip to main content

Overview

Webhooks deliver real-time HTTP callbacks when events happen in your Conduit account. Instead of polling the API, register an endpoint and Conduit pushes events to you. Conduit guarantees at-least-once delivery — your endpoint may receive the same event more than once. Clients SHOULD dedup by id and order by createdAt. We do not guarantee strict transport ordering.

Setting Up

1. Create an endpoint

The response includes a secret. Save it securely, it is only shown once and cannot be retrieved later. subscription is a tagged union:
  • { "mode": "all" } (the default if subscription is omitted) subscribes the endpoint to every event type.
  • { "mode": "selected", "eventTypes": ["..."] } subscribes only to the listed event types. eventTypes must be non-empty.
To change the subscription later, PATCH /v2/webhooks/endpoints/:id with the same subscription shape.

2. Verify signatures

Every webhook request includes a X-Conduit-Signature header in the format:
Where each v1 is HMAC-SHA256(<unix-timestamp>.<raw-body>, secret), computed over the raw request bytes before any JSON parsing. A delivery normally carries a single v1. While you are rotating an endpoint’s signing secret, deliveries carry two v1 values for a grace period — one signed with your new secret and one with the previous one — so deliveries keep verifying while you roll your secret over. Verify by recomputing the digest for your secret and accepting the delivery if it matches any v1 value. Once the grace period ends, only the current secret is used. We recommend rejecting deliveries where t is older than 300 seconds to guard against replay attacks.
The whsec_ prefix is part of the HMAC key — do not strip it. Your signing secret is shaped whsec_<64-hex>. Pass the FULL string verbatim, including the whsec_ prefix, as the HMAC-SHA256 key. Stripping the prefix produces a different digest and every valid delivery fails verification — the failure mode is identical to a tampered signature (silent 401, no diagnostic).
Node.js / Bun
Python 3
Always verify signatures before processing webhook payloads. Reject requests where the signature does not match or the timestamp is stale.

Common verification mistakes

3. Rotate your signing secret

If your signing secret is leaked — or you rotate secrets on a schedule — call:
The response returns a new secret once, in the same shape as create ({ ...endpoint, "secret": "whsec_...", "signature": { ... } }). Store it immediately; it is never shown again.
This request requires an Idempotency-Key header. Rotation is destructive — it replaces your current secret — so a retried request that reused no key could rotate twice and discard the secret you just deployed. With a key, a retry replays the original response instead of rotating again. Use a fresh key per intentional rotation.
Rotation does not cut over instantly. For a grace period (about 48 hours) every delivery is signed with both the new and the previous secret — two v1 values in X-Conduit-Signature (see Verify signatures). This lets you roll your verification over without dropping events:
  1. Call rotate and store the new secret.
  2. Deploy the new secret to your verifier. Because you accept any matching v1, deliveries keep verifying throughout — under the old secret before you deploy, under the new one after.
  3. Once the grace period ends, only the new secret signs. Any verifier still on the old secret stops verifying — which is the forcing function that completes the rollover.
The grace deadline is not exposed on endpoint reads; size your rollout to complete within the window.

Webhook Headers

Every webhook request includes these headers:

Payload Format

The data object varies by event type. Use the type field to determine how to process the payload.

Tracking transaction progress via webhooks

transaction.created carries a stage field — the same progress signal as GET /v2/transactions/:id’s stage (see Progress: the stage field). Narrower events that fire between creation and a terminal outcome (transaction.processing, transaction.awaiting_signature, transaction.signature_collected, transaction.quorum_met, transaction.awaiting_sender_information) do not carry stage — the event itself is a more specific progress signal than stage would add. Terminal events (transaction.completed, .cancelled, .failed) don’t carry it either since status already conveys the outcome. If you need the current stage between those events, poll GET /v2/transactions/:id or GET /v2/payouts/:id.
For a non-custodial payout, transaction.awaiting_signature is discriminated by signingMode. A wallet in the passkey_required mode carries the signer’s verificationUrl (use the latest attempt’s URL and discard earlier ones); for how to obtain that link, re-fetch it if you missed the delivery, and the one request pattern to avoid, see Getting the signing link. A wallet in a programmatic mode carries a signingRequestId — see Machine-signer stamping — plus an optional verificationUrl when its roster has a human passkey signer who may also approve on the verify page (a machine-only roster omits it).

Event Reference

Use GET /v2/webhooks/event-types for the current list of available events, including example payloads for each event type.

Paired events

A single business transition can emit more than one event. See the per-event descriptions in GET /v2/webhooks/event-types for the canonical dedupe rule on each pair.

Delivery Lifecycle

Each webhook delivery goes through these statuses:

Retries

Failed deliveries are retried with increasing delays: You can also manually retry a failed delivery:

Managing Endpoints

The status query parameter accepts pending, processing, succeeded, or failed (case-insensitive); unknown values return 400. Filters compose with endpointId. The eventType query parameter accepts an exact lowercase match against the event type (e.g. transaction.failed or order.failed). Unknown event types return an empty page. All three filters (endpointId, status, eventType) are optional and may be combined freely.

failureMessage symmetry contract

The failureMessage value is consistent across three surfaces: the database row, the polled GET /v2/transactions/{id} response, and the transaction.failed webhook payload. Applies equally to order.failed. The polled GET and the webhook payload never diverge, so an integrator can rely on either as the source of truth. On the last row the two agree with each other and carry the same fixed text however such a transfer ends, which is what makes them safe to branch on: there is no code to switch on, so treat the transfer as not completed and read GET /v2/transactions?type=deposit_return to see whether the funds went back.

Pausing an Endpoint

Set status: "disabled" (via PATCH /v2/webhooks/endpoints/:id) to stop receiving new deliveries on an endpoint. The endpoint is excluded from event fan-out — no new deliveries are enqueued. In-flight deliveries already queued at the moment of the flip continue to retry per the retry schedule and are not cancelled. Set it back to status: "active" to resume receiving new deliveries.

Best Practices

  • Respond quickly. Return a 2xx status within 5 seconds. Process the event asynchronously after acknowledging receipt.
  • Deduplicate. Use the event id to detect and skip duplicate deliveries.
  • Verify signatures. Always validate X-Conduit-Signature before processing the payload.
  • Handle unknown events. Your endpoint may receive new event types as the API evolves. Return 2xx for events you don’t recognize — don’t reject them.
  • Use HTTPS. Webhook endpoint URLs must use HTTPS.

application.approved

Fired when an application is approved. applicationType discriminates the variant — route on it: customer_onboarding — customer is now active; paired with customer.created (same applicationId, customerId, clientReferenceId); dedupe on (applicationId, customerId) if your handler reacts to either; idempotent re-approval does not re-emit the pair. virtual_account — virtual account has been created; asset carries the asset code/chain; the VA activates asynchronously and fires virtual_account.activated when ready. crypto_wallet — customer is now eligible for POST /v2/customers/:customerId/wallets/claim-non-custodial; no wallets have been provisioned yet. customer_update — the customer data change was accepted. organization_onboarding — organization is approved; no customerId is present.

application.rejected

Fired when an application is rejected. applicationType discriminates the variant. failureCode (machine-readable) and failureMessage (human-readable) are present when a specific reason is available. customerId is present for virtual_account, crypto_wallet, and customer_update rejections (customer exists by rejection time); absent for organization_onboarding; optional for customer_onboarding (absent when rejection occurs before customer creation). For crypto_wallet: jurisdiction-ineligible requests are refused synchronously with 422 at request time and do not create an application row, so no webhook fires for that case.

claim.completed

Fired when a non-custodial claim resolves: every roster signer has enrolled and the wallets are activated. Carries the claimId returned by POST /v2/customers/:id/wallets/claim-non-custodial plus the activated wallet IDs, so you can close the loop on a claim you were polling.

claim.failed

Fired when a non-custodial claim fails during provisioning — after the claim was accepted (202). Carries the claimId and a human-readable failure reason to surface to your user. (Synchronous validation failures — roster/threshold/jurisdiction — are returned inline on the POST as a 4xx and do NOT fire this webhook.)

crypto_wallet.completed

Fired when the customer’s end user has finished onboarding their non-custodial wallets and the wallets are ready to receive funds. Carries the customerId; fetch the wallets with GET /v2/customers/:id/wallets. To close the loop on a specific claim with the activated wallet IDs, use claim.completed.

customer.created

Fired when a customer is created after onboarding approval. Paired with application.approved (applicationType=customer_onboarding) on the first approval (same applicationId, customerId, clientReferenceId); on idempotent re-approval the customer already exists so the pair is not re-emitted. Dedupe on (applicationId, customerId) if your handler reacts to either.

customer.restricted

Fired when a restriction is placed on a customer. The customer is blocked from initiating the affected money-movement capabilities. The restriction reason and type are internal-only and are never included.
Fired when a hosted identity-verification link is created for a person on an application — one event per person. Subscribe to this event to deliver the link through your own channels. It is emitted regardless of whether Conduit also emails the person directly; that is a separate account setting and does not affect this event. url is a one-time credential: treat it as a secret and do not log it. Person referenceIds are listed on the application (persons[]), and a fresh link can be fetched at any time via POST /v2/applications//persons//idv-link.

order.cancelled

Fired when a pending order is cancelled — either by the sweep job (reason=expired, when lock_expires_at elapses) or by the client (reason=client_cancelled, via POST /v2/orders/:id/cancel).

order.created

Fired when an order is created via POST /v2/orders. The order is in pending status with the rate locked until lockExpiresAt. The order will not move funds until it is executed — either explicitly via POST /v2/orders/:id/execute, or automatically by the platform when source funds land (if autoExecute is true). If the lock expires while the order is still pending and unclaimed, the expiry sweep cancels it and order.cancelled fires with reason: expired. When the order was created without an explicit source, depositInstructions carries the address to fund it at; that order auto-executes once the deposit lands, and the lock expiry is the funding deadline instead of a rate lock.

order.failed

Fired when an order cannot execute or execution reaches a terminal failure. This includes pending auto-execute ONRAMP orders whose fiat source deposit terminates without crediting the customer — frozen, returned, or terminated before credit (e.g. sender-info timeout). reasonCode identifies the customer-facing failure category: insufficient_funds (source funds insufficient at execution time), provider_unavailable (transient rail/provider unavailability — retry may succeed), provider_rejected (provider or screening declined the leg, including source-deposit rejection on auto-execute ONRAMP orders; retry will not help — submit with a different recipient or funding source), internal_error (Conduit-side failure — contact support), cancelled (execution cancelled mid-flight).

order.succeeded

Fired when an order completes successfully — the source amount has been debited from the customer and the destination amount has been credited and is available to spend. Carries the spawned transactionId (and txHash when a chain leg ran) so integrators can reconcile and link back to the underlying transaction without a GET round-trip.

organization.activated

Fired when an organization is activated

organization.restricted

Fired when a restriction is placed on an organization. Every customer under the org is blocked from the affected capabilities. The reason and type are internal-only.

rfi.cancelled

Fired when a published request for information is cancelled. Fetch details via GET /v2/rfis/.

rfi.deadline_extended

Fired when compliance extends the response deadline on an open request for information. Fetch the new due date via GET /v2/rfis/.

rfi.more_info_requested

Fired when compliance requests more information on an already-responded request for information, opening a new round. Fetch details via GET /v2/rfis/.

rfi.published

Fired when a request for information is published to your organization. Fetch details via GET /v2/rfis/.

rfi.resolved

Fired when a request for information is resolved. Fetch details via GET /v2/rfis/.

rfi.response_submitted

Fired when a client response to a request for information is stored. Fetch details via GET /v2/rfis/.

transaction.awaiting_sender_information

Fired when a deposit is parked waiting for sender information for the source address. Payload carries the sourceAddress (null when no on-chain sender could be attributed) and an expiresAt deadline — after which the deposit auto-rejects. Chain is on assetAmount.

transaction.awaiting_signature

Fired when the customer’s signing roster must approve before broadcast — on a payout, or on the source transfer of a conversion from a non-custodial wallet. The payload is discriminated by signingMode. passkey_required carries the shared verificationUrl (Conduit-hosted approval page the fintech distributes to its human signers). programmatic / programmatic_unattended carry a signingRequestId — a machine integration discovers the signing details with GET /v2/signing-requests/{id} and approves via POST /v2/signing-requests/{id}/approve or rejects via POST /v2/signing-requests/{id}/reject. A programmatic payload ALSO carries an optional verificationUrl when the wallet’s roster has an active passkey signer: that human signer is a member of the signing quorum and may approve the payout on the verify page alongside the machine signers. The verificationUrl is omitted for a machine-only roster. If a signing window expires before quorum, Conduit rebuilds the request (fresh nonce/fees) and re-fires this event with an incremented attempt; treat the latest attempt as authoritative. After the final attempt expires the payout fails with user_signature_expired (a conversion fails via order.failed).

transaction.awaiting_user_signature

Deprecated — superseded by transaction.awaiting_signature; still delivered during a compatibility window and removed in a future release. Fired when the customer’s signing roster must approve before broadcast — on a payout, or on the source transfer of a conversion from a non-custodial wallet. Payload carries the single shared verificationUrl (Conduit-hosted approval page distributed by the fintech to its signers), expiresAt, and attempt. If a signing window expires before quorum, Conduit rebuilds the request (fresh nonce/fees, a new verificationUrl) and re-fires this event with an incremented attempt; clients should always treat the latest verificationUrl as authoritative and discard prior links. After the final attempt expires the payout fails with user_signature_expired (a conversion fails via order.failed).

transaction.cancelled

Fired when a transaction is cancelled before reaching its terminal-completed state. Distinct from transaction.failed: a cancelled transaction is not a failure; the client (or, in the future, an expiry sweep) terminated it intentionally. cancellationReason is client_cancelled when the client called POST /v2/payouts/:id/cancel. Reserved value expired is published when the underlying lock window elapses (future). The payload intentionally has no failureCode/failureMessage. Shares the cancellation semantics and cancellationReason vocabulary with order.cancelled (the payload itself is transaction-shaped: transactionId + nested source/destination).

transaction.completed

Fired when a transaction completes successfully. source and destination carry the same nested shape as GET /v2/transactions/:id. The settlement reference lives inside the relevant side variant: external_crypto.txHash for crypto rails, and on external_bank the real wire references as typed fields — swiftUetr, fedwireImad, fedwireOmad, achTraceNumber, rtpTransactionId, fedNowMessageId — each present only when the network exposes it (on-us transfers carry none). Fiat payout events include the selected rail for reconciliation on payout.rail.

transaction.created

Fired when a new transaction is initiated. type identifies the direction: deposit, onramp, offramp, withdrawal, conversion (a crypto-to-crypto swap or bridge between two of the customer’s wallets), or deposit_return (funds sent back from an order’s funding address to the address they came from). source and destination carry the same nested shape as GET /v2/transactions/:id — discriminated by type (wallet, deposit_address, virtual_account, external_crypto, external_bank, external_bank_inbound, internal_transfer, external_unknown) and each variant carries its own assetAmount. stage is the same client-safe progress signal as the GET response — see that endpoint’s field docs for the vocabulary and lifecycle. Transactions belonging to an order carry linkedOrderId referencing it — both the conversion the order executes and a withdrawal chained from its autoPayout; absent on transactions with no order.

transaction.failed

Fired when a transaction reaches a terminal failed state. source and destination carry the same nested shape as GET /v2/transactions/:id. The payload carries a failureCode your integration can branch on:
  • user_signature_* — recoverable: submit a new transaction.
  • chain_broadcast_failed — the on-chain broadcast or signing did not reach finality and no funds left the wallet; recoverable, submit a new payout.
  • crypto_wallet_misconfigured — the wallet’s signing configuration prevents Conduit from moving funds and the wallet has been frozen; NOT recoverable by retrying, contact support.
  • provider_rejected — chain RPC or sandbox-scenario declined the broadcast; failureMessage carries the operator/scenario-supplied reason when the underlying message was marked for public surfacing (sandbox/scenario paths). Live provider diagnostics are gated off the public surface. Adjust inputs (e.g. destination address, amount) and retry.
  • travel_rule_rejected — counterparty VASP rejected the travel-rule transfer; failureMessage carries the counterparty’s reason when supplied. Not retryable without coordinating with the receiving institution.
  • compliance_hold / compliance_review_rejected — compliance review required; not retryable without investigation.
  • returned_by_sender — fiat sender reversed the inbound transfer, or compliance marked the deposit returned before credit. A transfer sent back from a funding address carries no failureCode at all — see the RETURNED_BY_SENDER page for how to reconcile that case.
  • rail_policy_rejected / insufficient_funds_at_settle / rail_unavailable — payment-rail failure; adjust amount, recipient, or rail and retry.
  • sender_info_timeout — sender-info gate timed out; submit with sender details included. When failureCode is absent the failure has no actionable code — contact support. Order-level failures (including conversion provider unavailability) surface on order.failed with a reasonCode, not here.

transaction.processing

Fired when a payout clears compliance and moves into active processing (signature collection, co-stamp, broadcast). The public status transitions from pending to processing at the same moment. On a multi-signer non-custodial payout this fires once, before transaction.awaiting_signature; the payout may still carry a queuePosition while it waits for its per-wallet signing turn.

transaction.quorum_met

Fired when all required signer stamps are in (collected >= required). Compliance already cleared before signing began, so Conduit now co-signs and broadcasts — no further review gate stands between quorum and broadcast.

transaction.rejected

Fired when a compliance reviewer rejects the supporting document on an accepted payout, before execution. Terminal: funds are returned to the available balance. Resubmit a new payout with an acceptable document (see acceptedDocumentTypes) and a fresh idempotency key.

transaction.signature_collected

Fired once per signer stamp collected on a multi-signer payout. Track collected / required to drive a progress UI; once collected >= required, transaction.quorum_met follows.

virtual_account.activated

Fired when a virtual account is activated

wallet_ceremony.awaiting_admin_approval

Fired when a non-custodial wallet ceremony (roster add/remove, quorum change) is waiting for a customer admin’s passkey co-stamp to reach quorum. Carries adminVerificationUrl — the Conduit-hosted approval page to route the admin to — and expiresAt, after which the ceremony auto-fails and must be resubmitted. For promote/remove ceremonies this webhook is the only channel that delivers the approval URL. (A passkey signer added to a live roster parks on a separate wallet_signer.awaiting_admin_approval event instead.)

wallet_ceremony.completed

Fired when a non-custodial wallet ceremony completes successfully. All roster or quorum changes requested by the ceremony are now in effect.

wallet_ceremony.failed

Fired when a non-custodial wallet ceremony expires or fails. Any reserved resources have been released. status is failed on expiry or unrecoverable error; cancelled when the ceremony was explicitly cancelled.

wallet_signer.added

Fired when a wallet signer row is created on the roster (immediately at invite time, status pending_activation) — co-emitted with wallet_signer.invited from the same outbox transaction. Distinct from wallet_signer.invited (which carries the verification URL) and wallet_signer.enrolled (sent later when the signer completes credential enrollment).

wallet_signer.awaiting_admin_approval

Fired when a passkey signer added to a live roster has enrolled their passkey and now parks for a customer admin’s co-stamp. Carries signerId + customerId (the signer’s identity — no walletId, since a signer’s roster can back several wallets), adminVerificationUrl — the Conduit-hosted approval page to route an admin to — and expiresAt, after which the pending add auto-fails. This is the only channel that delivers this admin link; if it is lost, recover it with POST /v2/customers/:customerId/wallet-signers/:signerId/reissue-admin-approval. (A ceremony-backed roster/quorum change instead uses wallet_ceremony.awaiting_admin_approval.)

wallet_signer.demoted

Fired when an admin is demoted to signer via the two-step demote ceremony (root-quorum update + tag update). Validates min-2-admins floor before starting.

wallet_signer.enrolled

Fired when a wallet signer completes credential enrollment via the verification URL. passkeyCount reflects the number of passkeys registered for passkey signers.

wallet_signer.enrollment_approved

Fired when a customer admin’s co-stamp approves a signer’s credential ceremony — a newcomer’s first passkey, or an existing signer adding a device. The post-approval counterpart to wallet_ceremony.awaiting_admin_approval. Carries approvedByWalletSignerId (the approving admin) and approvedAt. Distinct from wallet_signer.enrolled, which fires when the signer submits their credential; this fires only once the admin approves it.

wallet_signer.invited

Fired when a wallet signer is invited to enroll their credential. Payload carries the per-signer enrollment verificationUrl the fintech distributes out-of-band, plus expiresAt — after which the invitation auto-expires.

wallet_signer.promoted

Fired when a wallet signer is promoted to admin via the two-step promote ceremony (tag update + root-quorum update). A passkey admin must have at least 2 passkeys enrolled; a machine (api_key) admin needs none and is allowed only in the fully-automated signing mode.

wallet_signer.removed

Fired when a wallet signer is removed from the roster. reason discriminates between customer-initiated removal (customer_removed) and internal ops removal (ops_removed). Pending payouts carrying the removed signer’s stamp are voided with failureCode roster_changed.

wallet.created

Fired when a crypto wallet is created. Not delivered for the Conduit-managed funding address behind a deposit-funded order — that address is not a wallet resource, and the order’s depositInstructions publishes it instead.

wallet.deleted

Fired when a crypto wallet is deleted (currently only via a crypto-wallet claim reset). Integrators mirroring wallet state must remove the corresponding record. Not delivered for a Conduit-managed deposit address, whose creation was never announced either.

wallet.rotated

Fired when a crypto wallet is rotated. Not delivered for the Conduit-managed funding address behind a deposit-funded order: Conduit may replace that address at any time without notice, so read it off each order rather than tracking it per customer.

wallet.threshold_changed

Fired when a wallet’s signing threshold changes — the number of admin stamps required to authorize a payout. Triggered by a completed SIGNING_QUORUM_CHANGE ceremony or an operator quorum override.

whitelist_recipient.registered

Fired when compliance approves a pending intercompany whitelist registration. The recipient can now receive purpose=intercompany payouts for this customer.

whitelist_recipient.rejected

Fired when compliance rejects a pending registration.

whitelist_recipient.revoked

Fired when an entry is revoked (terminal; client-initiated or compliance action).

whitelist_recipient.suspended

Fired when an active entry is suspended — it no longer satisfies intercompany payouts.