Webhooks
Introduction
Bitnob emits real-time webhook events that inform your system of all important activities and lifecycle events related to your virtual cards.
Each webhook provides structured data about:
Transaction outcomes (approved, failed, reversed, refunded),
Card lifecycle changes (created, terminated, frozen, regularized, expired),
Operational risk events (failed authorizations, frozen-card declines, cross-border activity).
Webhooks are delivered to your registered callback URL via HTTPS POST requests.
Webhook Structure
All webhook payloads share the same top-level envelope:
All JSON keys and identifier values use snake_case. The event name itself stays dot-delimited (e.g. virtualcard.transaction.debit) for routing. Amounts are integers in micro-units — 1 USD = 1,000,000. Transaction events also include display_amount as the same value expressed as a major-unit float.
Wire Conventions for Transaction Events
transaction_type carries Bitnob's internal classification (stable). Branch on this for routing. Values: authorization, settlement, pre_auth_approved, authorization_declined, refund_authorization, refund_settled, reversal_settled, cross_border_pending, cross_border_settled, cross_border_reversal, contactless_pending, contactless_settled, verification.
status carries the wire-level outcome. completed for settled debits / credits / reversals / verifications. pending for cross-border auth still awaiting settlement. decline for declines. terminated for card-terminated declines.
reference is the local Bitnob reference (CARD_*-prefixed for backend-generated ones). It's the join key against the matching ledger entry.
event_id is unique per delivery — use it as the idempotency key when persisting received webhooks. Retries reuse the same event_id.
Webhook Categories
category | description |
|---|---|
Card Lifecycle | When a card is created, fails to create, is terminated, regularized, or expires |
Top-Ups & Withdrawals | When a card is loaded or funds are withdrawn |
Transaction Activity | Authorizations, settlements, pre-auth holds, contactless, refunds, reversals, cross-border, verification |
Declines & Risk Events | Generic declines, decline-rule violation fees, frozen-card declines, terminated-card declines, authorization failures, issuer expiration |
KYC & Activation | Customer KYC outcomes and contactless activation |
Refunds on Terminated Cards | Per-transaction refunds posted as part of card termination cleanup |
Card Lifecycle Webhooks
virtualcard.created.completed
Fired when a virtual card is successfully created at the provider and is active.
virtualcard.created.failed
Fired when the provider rejects the card creation attempt.
virtualcard.terminated.refund
Fired when a card is terminated and any remaining balance is refunded.
virtualcard.regularized
Card moved out of a degraded or indeterminate state into a stable status.
virtualcard.expiration
Card has reached its expiry date and can no longer be used.
Card Funding Webhooks
virtualcard.topup.completed
Card top-up (load funds) succeeded.
virtualcard.topup.failed
Top-up failed (e.g. insufficient balance, card status invalid). Payload shape matches the success event with status: "failed".
Card Withdrawal Webhooks
virtualcard.withdrawal.completed
A successful withdrawal was made from the card.
virtualcard.withdrawal.failed
Withdrawal attempt failed (e.g. below minimum balance, card status invalid). Payload shape matches the success event with status: "failed".
Transaction Webhooks
All transaction events include transaction_type (Bitnob's stable enum), status, display_amount, and merchant_* fields where applicable.
virtualcard.transaction.debit
Generic settled debit shape. The transaction_type will be one of settlement, authorization, pre_auth_approved, contactless_settled, or contactless_pending. The dedicated event names below are what is actually emitted on the wire — branch on event first.
virtualcard.transaction.authorization
Authorization hold placed by merchant; settlement pending. transaction_type: authorization.
virtualcard.transaction.settlement
Merchant settled a previously authorized transaction. transaction_type: settlement.
virtualcard.transaction.pre-auth.approved
Pre-authorization hold (e.g., hotel, gas pump). transaction_type: pre_auth_approved.
virtualcard.transaction.contactless
Contactless / tap-to-pay payment. Same event string fires for both the pending authorization (transaction_type: contactless_pending) and the final settled state (transaction_type: contactless_settled) — distinguish via transaction_type.
virtualcard.transaction.credit
Generic credit posting (refund, adjustment).
virtualcard.transaction.refund
Merchant-initiated refund. Same event string fires for the refund authorization (transaction_type: refund_authorization) and the refund settlement (transaction_type: refund_settled).
virtualcard.transaction.reversed
Authorization reversed before settlement. Also emitted for cross-border reversals — transaction_type is reversal_settled for auth reversals and cross_border_reversal for cross-border.
virtualcard.transaction.verification
Zero-dollar verification authorization (e.g., Uber, Netflix card-on-file checks). transaction_type: verification, amount: 0.
virtualcard.transaction.crossborder
Cross-border transactions emit this event twice with different transaction_type values:
transaction_type | where the money came from |
|---|---|
cross_border_pending | Company wallet. |
cross_border_settled | Card balance. |
Correlate the two via the reference field (CARD_CROSSBORD_*).
If Miden's verification API was unreachable when the settlement webhook arrived, the row is recorded at pending and the wire status is "pending" instead of "completed". The transaction_type stays cross_border_settled. Recovery is via the awaiting-verification reconciler.
virtualcard.transaction.terminated.refund
Per-transaction refund posted as part of card termination cleanup. Funds returned to the float or master account.
Declines & Risk Event Webhooks
virtualcard.transaction.declined
Generic decline (e.g., insufficient funds, CVV mismatch). status: "declined" with a reason string.
virtualcard.transaction.declined.charge
Decline-rule violation fee charged to your USD wallet after repeated declines. Includes fee_amount and violation_count. See the Decline Rule Policy for full details.
virtualcard.transaction.declined.frozen
Authorization declined because the card is frozen (usually a fraud rule).
virtualcard.transaction.declined.terminated
Authorization attempted on a terminated card. Includes balance_before_termination.
virtualcard.transaction.authorization.failed
Authorization rejected by the issuer (e.g., bad PAN/CVV, AVS failure). Includes reason.
virtualcard.transaction.issuerexpiration
Transaction declined because the card has expired at the issuer.
KYC & Activation Webhooks
virtualcard.user.kyc.completed
KYC verification succeeded for a customer. kyc_passed: true.
virtualcard.user.kyc.failed
KYC verification failed. kyc_passed: false with a reason.
virtualcard.contactless.activation
Customer KYC + card activation for contactless support completed.
Best Practices for Handling Webhooks
practice | why it matters |
|---|---|
Acknowledge with HTTP 200 | Prevent retries and duplication. Wand retries up to 3 times with exponential backoff on non-2xx responses. |
Deduplicate by event_id | Retries reuse the same event_id — store it and reject duplicates to keep handlers idempotent. |
Branch on event first, then transaction_type | Several events (contactless, crossborder, refund, reversed) share an event string but carry different transaction_type values. |
Verify the HMAC signature | Each delivery is signed hmac_sha256(secret, body) and sent in the X-Bitnob-Signature header. |
Ignore unknown fields | Event names are stable but new optional fields may be added to data without a version bump. |
Log and reconcile | Persist every event for audit. Cross-check against /api/cards/:cardId/transactions for periodic reconciliation. |
Virtual Card Webhooks – Reference Table
event name | category | description |
|---|---|---|
virtualcard.created.completed | Card Lifecycle | Card was successfully created at the provider and is now active. |
virtualcard.created.failed | Card Lifecycle | Card creation rejected by provider. Check `reason` in payload. |
virtualcard.terminated.refund | Card Lifecycle | Card was terminated; remaining balance refunded. |
virtualcard.regularized | Card Lifecycle | Card moved out of a degraded state into a stable status. |
virtualcard.expiration | Card Lifecycle | Card reached its expiry date. |
virtualcard.topup.completed | Top-Up | Card was funded successfully. |
virtualcard.topup.failed | Top-Up | Card funding attempt failed. |
virtualcard.withdrawal.completed | Withdrawal | Withdrawal from card completed. |
virtualcard.withdrawal.failed | Withdrawal | Withdrawal failed (e.g. limit, balance, status). |
virtualcard.transaction.debit | Transaction | Generic settled debit shape (settlement / authorization / pre_auth_approved / contactless). |
virtualcard.transaction.credit | Transaction | Generic credit posting on the card. |
virtualcard.transaction.authorization | Transaction | Auth approved by provider; settlement pending. |
virtualcard.transaction.settlement | Transaction | Previously-authorized transaction now settled. |
virtualcard.transaction.verification | Transaction | Zero-amount card-on-file verification authorization. |
virtualcard.transaction.pre-auth.approved | Transaction | Pre-authorization hold (hotel, gas pump, etc.). |
virtualcard.transaction.reversed | Transaction | Authorization reversed before settlement. Also fires for cross-border reversals. |
virtualcard.transaction.refund | Transaction | Merchant refund. Fires for both refund authorization and settlement — branch on `transaction_type`. |
virtualcard.transaction.crossborder | Transaction | Cross-border activity. `cross_border_pending` = wallet fee debit; `cross_border_settled` = card-balance debit. |
virtualcard.transaction.contactless | Transaction | Contactless / tap-to-pay. Fires for both pending and settled — branch on `transaction_type`. |
virtualcard.transaction.terminated.refund | Transaction | Per-transaction refund posted during card termination cleanup. |
virtualcard.transaction.declined | Decline | Generic decline. See `reason` in payload. |
virtualcard.transaction.declined.charge | Decline | Decline-rule violation fee charged to USD wallet. |
virtualcard.transaction.declined.frozen | Decline | Authorization declined because the card is frozen. |
virtualcard.transaction.declined.terminated | Decline | Authorization attempted on a terminated card. |
virtualcard.transaction.authorization.failed | Decline | Authorization rejected (bad PAN/CVV, AVS failure, etc.). |
virtualcard.transaction.issuerexpiration | Decline | Authorization declined because card expired at the issuer. |
virtualcard.user.kyc.completed | KYC | Customer KYC verification succeeded. |
virtualcard.user.kyc.failed | KYC | Customer KYC verification failed. |
virtualcard.contactless.activation | Activation | Customer activated for contactless / NFC support. |
Next Steps
You can test webhook delivery via the Bitnob API or by manually triggering test events from your dashboard.
Subscribe to sandbox events first to validate your implementation against the new payload shape.
For production use, log all failed deliveries and monitor retry queues.