Webhooks
Everything the API lets you do also reports back by webhook. Register an endpoint in Developer Settings, subscribe to event codes, and the platform POSTs each event to your URL. Polling stays available as a fallback, but a fully event-driven integration needs zero polling loops.
New to the flow? Start with the Integration guide — it walks the whole journey (order matching, transaction lifecycle, card close) end to end.
Delivery contract (all events)
Envelope
Every delivery is a JSON body with the same envelope:
{
"event": "card.transaction.captured",
"event_id": 918273,
"occurred_at": "2026-09-12T08:30:00+00:00",
"workspace_id": 12,
"data": { "…event-specific payload…" }
}
event_id is stable across redeliveries — use it (or the nested business uuid) as your idempotency key.
Signature
Every request carries a signature header:
X-Dotva-Signature: t=<unix_ts>,v1=<lowercase_hex(hmac_sha256(secret, t + "." + raw_body))>
Verify against the raw request bytes using the endpoint secret (shown once at endpoint creation). Reject deliveries when |now − t| > 300s. Redeliveries reuse the exact same body bytes but are re-signed with a fresh timestamp.
Node.js example:
import { createHmac, timingSafeEqual } from 'crypto'
function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(',').map(kv => kv.split('=')))
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
return timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
}
Acknowledge & retry
- Reply any
2xxto acknowledge. Anything else is retried with exponential back-off. - Respond fast and defer heavy work to a queue — an endpoint that keeps failing is auto-disabled (the workspace owner is notified by email).
- Duplicate deliveries are possible; make your handler idempotent.
Event catalog
| Event | Fires when | Key data fields |
|---|---|---|
order.completed | Any order (issue / topup / close / freeze / unfreeze) reaches Completed | order_uuid, type, held_amount, currency, card_uuid, completed_at |
order.failed | Any order reaches Failed; held funds released back to wallet | order_uuid, type, card_uuid (nullable), failure_reason, failed_at |
card.issued.secrets | Card issued + workspace has an RSA public key (see below) | card_uuid, secrets_jwe, key_fingerprint |
card.transaction.authorized | Authorization hold placed | card_uuid, pan_last4, transaction |
card.transaction.captured | Funds settled | same shape |
card.transaction.reversed | Hold reversed by the merchant | same shape |
card.transaction.declined | Authorization declined (transaction.decline_reason) | same shape |
card.transaction.refunded | Merchant refund posted | same shape |
card.transaction.chargeback_opened | Chargeback opened | same shape |
card.frozen | Card frozen — by you, by platform risk, or by the issuer (origin tells which) | card base + origin, reason, frozen_at |
card.unfrozen | Card unfrozen | card base + reason, unfrozen_at |
card.close_initiated | Close started (stage 1 of 2): spend stops now, refund comes later | card base + origin, reason, close_initiated_at |
card.closed | Close terminal: residual balance refunded to wallet | card base + refund_amount, currency, closed_at |
card.risk.penalty_applied | Confirmed-fraud ruling deducted card funds | card base + penalty_amount, currency, balance_after |
"Card base" = card_uuid, workspace_id, cardholder_type, pan_last4, status.
Not pushed, by design
- 3-D Secure OTP codes are never webhookable. They are relayed by email directly to the cardholder and cannot be subscribed by any endpoint. This is a security ruling, not a missing feature.
- There is no
card.issuedorcard.topped_upwebhook. Theorder.completedreceipt is the single acknowledgement for every write operation, and for issue orders it carries thecard_uuid. - Authorization expiry produces no event. Every real-money hold terminates in a
capturedorreversedpush; the only holds that quietly expire are $0 card-verification probes.
Transaction pushes: same row as the polling API
data.transaction in every card.transaction.* webhook is the exact row shape of GET /cards/{uuid}/transactions. One new transaction row = one event. Upsert by transaction.uuid and your local feed stays identical whether it was pushed or polled — the two sources can never diverge.
{
"event": "card.transaction.captured",
"event_id": 918273,
"occurred_at": "2026-09-12T08:30:00+00:00",
"workspace_id": 12,
"data": {
"card_uuid": "8f1c...",
"pan_last4": "4242",
"transaction": {
"uuid": "c0a8...",
"type": "capture",
"status": "posted",
"amount": "30.00",
"currency": "USD",
"merchant_amount": "27.50",
"merchant_currency": "EUR",
"fx_rate": "1.0909",
"merchant": { "raw_name": "COFFEE SHOP", "canonical_merchant_id": 17, "canonical_name": "Coffee Shop", "mcc": "5814", "country": "US" },
"auth_code": "A1B2",
"decline_reason": null,
"occurred_at": "2026-09-12T08:29:58+00:00",
"posted_at": "2026-09-12T08:30:00+00:00",
"created_at": "2026-09-12T08:30:00+00:00"
}
}
}
card.issued.secrets — encrypted card secrets
After a card is issued, if the workspace has configured an RSA public key and has an endpoint subscribed to card.issued.secrets, the platform delivers this webhook.
Encrypted card secrets are delivered only via this webhook — they never appear in regular API responses.
Payload
{
"event": "card.issued.secrets",
"workspace_id": 12,
"data": {
"card_id": 345,
"card_uuid": "8f1c...",
"card_last4": "4242",
"card_bin": "411111",
"secrets_jwe": "<JWE compact string>",
"key_fingerprint": "sha256:9c4d..."
}
}
| Field | Description |
|---|---|
secrets_jwe | JWE compact serialization (RSA-OAEP-256 + A256GCM). Decrypt with your private key to obtain PAN / CVV / expiry. |
key_fingerprint | SHA-256 fingerprint of the public key used to encrypt, so you know which private key to use for decryption. |
Setting up
- Upload your RSA public key in Developer Settings → Webhook Keys.
- Register your endpoint URL and subscribe it to
card.issued.secrets. - After issuing a card via
POST /cards, wait for the webhook delivery (typically within seconds of the order completing).
Decrypting secrets_jwe
The JWE uses RSA-OAEP-256 for key encryption and A256GCM for content encryption. Most modern crypto libraries support this out of the box.
Node.js example (using jose):
import { compactDecrypt } from 'jose'
import { createPrivateKey } from 'crypto'
const privateKey = createPrivateKey({ key: process.env.PRIVATE_KEY_PEM })
const { plaintext } = await compactDecrypt(secrets_jwe, privateKey)
const secrets = JSON.parse(new TextDecoder().decode(plaintext))
// secrets.pan, secrets.cvv, secrets.expiry_month, secrets.expiry_year