API Reference

Webhooks

The full webhook contract — signed delivery envelope, the 14-event catalog, transaction pushes identical to the polling API, and JWE encrypted card secrets.

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 2xx to 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

EventFires whenKey data fields
order.completedAny order (issue / topup / close / freeze / unfreeze) reaches Completedorder_uuid, type, held_amount, currency, card_uuid, completed_at
order.failedAny order reaches Failed; held funds released back to walletorder_uuid, type, card_uuid (nullable), failure_reason, failed_at
card.issued.secretsCard issued + workspace has an RSA public key (see below)card_uuid, secrets_jwe, key_fingerprint
card.transaction.authorizedAuthorization hold placedcard_uuid, pan_last4, transaction
card.transaction.capturedFunds settledsame shape
card.transaction.reversedHold reversed by the merchantsame shape
card.transaction.declinedAuthorization declined (transaction.decline_reason)same shape
card.transaction.refundedMerchant refund postedsame shape
card.transaction.chargeback_openedChargeback openedsame shape
card.frozenCard frozen — by you, by platform risk, or by the issuer (origin tells which)card base + origin, reason, frozen_at
card.unfrozenCard unfrozencard base + reason, unfrozen_at
card.close_initiatedClose started (stage 1 of 2): spend stops now, refund comes latercard base + origin, reason, close_initiated_at
card.closedClose terminal: residual balance refunded to walletcard base + refund_amount, currency, closed_at
card.risk.penalty_appliedConfirmed-fraud ruling deducted card fundscard 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.issued or card.topped_up webhook. The order.completed receipt is the single acknowledgement for every write operation, and for issue orders it carries the card_uuid.
  • Authorization expiry produces no event. Every real-money hold terminates in a captured or reversed push; 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..."
  }
}
FieldDescription
secrets_jweJWE compact serialization (RSA-OAEP-256 + A256GCM). Decrypt with your private key to obtain PAN / CVV / expiry.
key_fingerprintSHA-256 fingerprint of the public key used to encrypt, so you know which private key to use for decryption.

Setting up

  1. Upload your RSA public key in Developer Settings → Webhook Keys.
  2. Register your endpoint URL and subscribe it to card.issued.secrets.
  3. 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