Webhooks

Overview

Bakkt sends webhooks to your servers to notify you of asynchronous events — KYC/KYB status changes, transaction state transitions, account events, and more. Use webhooks instead of polling for low-latency, scalable integrations.

There are two ways to register webhook endpoints:

SetupEndpoints involvedWhen to use
/merchant/webhook-endpoints (recommended)POST, GET, GET/PATCH/DELETE /{id}, PATCH /{id}/secret, POST /{id}/testAll new integrations. Supports multiple endpoints per merchant, per-endpoint event subscriptions, signing-secret rotation, and explicit test sends.
PATCH /merchant (legacy)PATCH /merchantExisting integrations on a single endpoint with a shared secret. New work should migrate to /merchant/webhook-endpoints.

Before you begin

  • Sandbox base URL: https://sandbox.api.bakkt.com
  • Production base URL: https://api.bakkt.com
  • Authentication for setup calls: Authorization: <YOUR_API_KEY> only — no user session required.
  • Configure separately in Sandbox and Production. Use distinct secrets per environment.

Set up an endpoint (recommended)

Register a webhook endpoint with a URL, a description, and the list of event types you want to subscribe to.

Endpoint: POST /merchant/webhook-endpoints — returns 201 Created with the new endpoint, including a one-time signing_secret.

Required fields:

  • url — HTTPS URL where Bakkt will deliver webhook payloads.
  • description — short description of the endpoint (max 255 characters).
  • subscribed_events (optional) — array of event types this endpoint should receive. If omitted, the endpoint receives all event types.
curl -X POST https://sandbox.api.bakkt.com/merchant/webhook-endpoints \
  -H "Authorization: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/webhooks/bakkt",
    "description": "Onboarding events for the main app",
    "subscribed_events": ["KYC", "KYB"]
  }'

Example response:

{
  "uuid": "f33e4cbd-61cb-49cb-aa6a-9b7a471f11db",
  "status": "active",
  "url": "https://api.example.com/webhooks/bakkt",
  "signing_secret": "whsec_5c2d1f5b7e9a4f8c1234abcd5678ef90"
}

Store signing_secret immediately. This is the only time it's returned in plaintext. Bakkt sends it back to your endpoint in the Authorization header on every webhook delivery, and you compare it in constant time (see Verify webhook authenticity). If you lose it, rotate via PATCH /merchant/webhook-endpoints/{id}/secret — the previous secret is invalidated as soon as the rotation succeeds.

List, inspect, update, and delete

ActionEndpoint
List all endpoints (optionally filter by status)GET /merchant/webhook-endpoints
Get one endpointGET /merchant/webhook-endpoints/{id}
Update URL, status (active / disabled), or subscribed_eventsPATCH /merchant/webhook-endpoints/{id}
Rotate signing_secretPATCH /merchant/webhook-endpoints/{id}/secret
Delete endpoint (cannot be re-enabled)DELETE /merchant/webhook-endpoints/{id}

Endpoints can be in one of three statuses:

StatusMeaning
activeReceiving webhook deliveries.
disabledManually disabled by you via PATCH.
auto_disabledAutomatically disabled by Bakkt after repeated delivery failures. Re-enable by PATCHing status back to active.

Send a test webhook

Verify your endpoint can receive and authenticate a Bakkt-sent webhook before any real events fire.

Endpoint: POST /merchant/webhook-endpoints/{id}/test — sandbox only.

curl -X POST https://sandbox.api.bakkt.com/merchant/webhook-endpoints/$ENDPOINT_ID/test \
  -H "Authorization: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "KYC",
    "subType": "FULL_USER",
    "user_uuid": "5594401c-0072-4df2-be9c-d491c0754c21"
  }'

The subType must be a valid value for the chosen type. You must include either user_uuid or corporate_uuid.

Set up an endpoint (legacy)

If you're maintaining an older integration that uses a single shared webhook URL and secret, use PATCH /merchant. For new work, prefer /merchant/webhook-endpoints above.

curl -X PATCH https://sandbox.api.bakkt.com/merchant \
  -H "Authorization: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook": "https://api.example.com/webhooks/bakkt",
    "webhook_secret": "your_secure_secret"
  }'

Webhook payload structure

All webhooks share a common envelope:

{
  "type": "KYC",
  "subType": "FULL_USER",
  "uuid": "550e8400-e29b-41d4-a716-446655440000",
  "data": {
    "...": "event-specific fields"
  }
}
FieldDescription
typeEvent family (see Event types below).
subTypeSpecific status or sub-event within the family.
uuidIdentifier of the affected entity — user_uuid for KYC events, corporate_uuid for KYB events, transaction uuid for transaction events, etc.
dataEvent-specific payload. See per-type sections below.

Webhooks always reflect the latest state. If your endpoint was unreachable when an intermediate state fired and a newer state has since been reached, only the newer state will be retried. Don't assume you'll see every transition.

Event types

The type field can be any of the following. This guide covers KYC and KYB in detail; the others are covered in their respective product guides.

typeWhat it coversDocumented in
KYCIndividual user verification status.This guide
KYBCorporate entity verification status.This guide
fiatToCryptoOn-ramp transaction state changes.Stablecoin / Conversions guide
cryptoToFiatOff-ramp transaction state changes.Stablecoin / Conversions guide
unblockBankAccountVirtual IBAN / bank account events.Accounts guide
linkBankAccountExternal bank-account linking events.Accounts guide
linkedBankAccountProfileUpdates to a linked bank account profile.Accounts guide
walletCreatedA wallet has been provisioned for the user / corporate.Accounts guide
entityStatusUpdateCross-entity status changes (suspension, unblock, T&C re-acceptance required).Accounts guide; Terms and Conditions Hashes for MISSING_TERMS_AND_CONDITIONS_SIGNED
AMLAML screening hits and outcomes.Compliance / AML guide
duplicateUserA duplicate user was detected.User Management
senderNameMismatchMismatch between the expected and actual sender name on an inbound transfer.Accounts guide
otpNotificationOTP-related notifications.Authentication guide

When you create or update an endpoint via POST /merchant/webhook-endpoints, list only the types you care about in subscribed_events. If you omit subscribed_events, the endpoint receives all event types.

KYC webhooks

KYC webhooks fire when an individual user's verification status changes. The uuid is the user's user_uuid.

subType mirrors the KYC status lifecycle (see KYC & KYB → KYC status lifecycle):

subTypeMeaning
KYC_NEEDEDUser exists; KYC not yet started.
PENDING_KYC_DATAKYC application created; documents not yet collected.
KYC_PENDINGDocuments submitted; under review.
SOFT_KYC_FAILEDRecoverable failure; user can retry.
HARD_KYC_FAILEDTerminal failure; contact support.
FULL_USERKYC approved; user can transact.
SUSPENDEDUser suspended by Bakkt compliance. The user cannot take any further action on the platform.

Approved example:

{
  "type": "KYC",
  "subType": "FULL_USER",
  "uuid": "550e8400-e29b-41d4-a716-446655440000",
  "data": {
    "status": "FULL_USER"
  }
}

Soft-failure example:

{
  "type": "KYC",
  "subType": "SOFT_KYC_FAILED",
  "uuid": "550e8400-e29b-41d4-a716-446655440000",
  "data": {
    "status": "SOFT_KYC_FAILED",
    "rejectionReason": ["UNSATISFACTORY_PHOTOS", "LOW_QUALITY"],
    "rejectionMessage": "Please provide clearer photos of your identity document."
  }
}

For the full list of rejectionReason codes and how to surface them to the user, see KYC & KYB → Rejection reasons.

KYC handler example

function handleKyc(subType, userUuid, data) {
  switch (subType) {
    case 'FULL_USER':
      db.users.update(userUuid, { kyc_status: 'approved', can_transact: true });
      sendUserNotification(userUuid, 'Verification complete', 'You can now transact.');
      break;

    case 'SOFT_KYC_FAILED':
      sendUserNotification(userUuid, 'Verification needs correction', data.rejectionMessage);
      break;

    case 'HARD_KYC_FAILED':
    case 'SUSPENDED':
      notifySupport(userUuid, subType, data.rejectionReason);
      break;

    case 'KYC_PENDING':
    case 'PENDING_KYC_DATA':
    case 'KYC_NEEDED':
      db.users.update(userUuid, { kyc_status: subType });
      break;
  }
}

KYB webhooks

KYB webhooks fire when a corporate's verification status changes. The uuid is the corporate_uuid.

KYB webhooks fire on a subset of the corporate status lifecycle — only the transitions listed below are surfaced as webhooks. For the full corporate status lifecycle (including intermediate states like KYB_NEEDED, PENDING_KYB_DATA, SOFT_KYB_FAILED, HARD_KYB_FAILED, and SUSPENDED), see KYC & KYB → KYB status lifecycle and poll GET /corporate/{corporate_uuid} when you need to observe them.

subTypeMeaning
CREATEDCorporate entity has been successfully created.
KYB_PENDINGKYB submitted; under review.
ACTIVEKYB approved; corporate can transact.
REJECTEDKYB rejected as a result of a compliance review.

Approved example:

{
  "type": "KYB",
  "subType": "ACTIVE",
  "uuid": "650e8400-e29b-41d4-a716-446655440001",
  "data": {
    "status": "ACTIVE"
  }
}

Rejected example:

{
  "type": "KYB",
  "subType": "REJECTED",
  "uuid": "650e8400-e29b-41d4-a716-446655440001",
  "data": {
    "status": "REJECTED",
    "rejectionReason": ["INCOMPLETE_DOCUMENT"],
    "rejectionMessage": "Please provide complete company registration documents."
  }
}

KYB handler example

function handleKyb(subType, corporateUuid, data) {
  switch (subType) {
    case 'ACTIVE':
      db.corporates.update(corporateUuid, { kyb_status: 'approved', can_transact: true });
      notifyCorporateAdmin(corporateUuid, 'Business verification complete');
      break;

    case 'REJECTED':
      notifyCorporateAdmin(corporateUuid, 'KYB rejected', {
        reason: data.rejectionMessage
      });
      notifySupport(corporateUuid, subType, data.rejectionReason);
      break;

    case 'KYB_PENDING':
    case 'CREATED':
      db.corporates.update(corporateUuid, { kyb_status: subType });
      break;
  }
}

Verify webhook authenticity

Always verify that an incoming webhook actually came from Bakkt before processing it. The mechanism is the same for both setups: Bakkt sends the configured secret back to your endpoint in the Authorization header on every webhook request, and your handler compares it against the secret you stored.

Header sent on every webhook delivery:

{
  "Accept": "application/json",
  "Content-type": "application/json",
  "Authorization": "API-Key <YOUR_WEBHOOK_SECRET>"
}

For /merchant/webhook-endpoints, the secret is the per-endpoint signing_secret returned by POST /merchant/webhook-endpoints (rotatable via PATCH /merchant/webhook-endpoints/{id}/secret). For the legacy PATCH /merchant setup, the secret is the single webhook_secret you configured.

Compare in constant time on every request:

const crypto = require('crypto');

function verifyWebhook(req, expectedSecret) {
  const header = req.headers['authorization'] ?? '';
  const received = header.replace(/^API-Key\s+/i, '');

  const a = Buffer.from(received);
  const b = Buffer.from(expectedSecret);
  if (a.length !== b.length) return false;

  return crypto.timingSafeEqual(a, b);
}

If you use /merchant/webhook-endpoints with multiple endpoints, look up the right expectedSecret based on the URL the request was delivered to (each endpoint has its own secret). Reject any request whose Authorization header doesn't exactly match the secret stored for that endpoint.

Express handler example

Putting it together. The handler verifies the Authorization header, acknowledges with 200, and hands the work off so it doesn't run inside the request lifecycle:

const express = require('express');
const crypto = require('crypto');

const app = express();

app.post(
  '/webhooks/bakkt',
  express.json(),
  (req, res) => {
    const ok = verifyWebhook(req, process.env.BAKKT_WEBHOOK_SECRET);
    if (!ok) return res.status(401).send('Invalid signature');

    const event = req.body;
    res.status(200).send('OK');

    setImmediate(() => {
      processEvent(event).catch((err) => {
        console.error('Error processing webhook:', err, {
          type: event.type,
          subType: event.subType,
          uuid: event.uuid
        });
      });
    });
  }
);

async function processEvent(event) {
  const { type, subType, uuid, data } = event;
  console.log(`[Webhook] ${type} - ${subType} for ${uuid}`);

  switch (type) {
    case 'KYC':
      await handleKyc(subType, uuid, data);
      break;
    case 'KYB':
      await handleKyb(subType, uuid, data);
      break;
    case 'fiatToCrypto':
    case 'cryptoToFiat':
      await handleTransaction(type, subType, uuid, data);
      break;
    default:
      console.log('Unhandled webhook type:', type);
  }
}

app.listen(3000);

A few key patterns in the handler above:

  • Verify before doing any work. Reject any request whose Authorization header doesn't match your stored secret.
  • Acknowledge fast, then process out-of-band. The handler returns 200 immediately after verification. The actual work is dispatched via setImmediate so it runs after the response has been flushed and does not occupy the request lifecycle.
  • For production, prefer a real job queue. setImmediate keeps work in-process — if the worker crashes between ack and processing, the event is lost and Bakkt won't retry (the 200 already happened). Push the parsed event onto a durable queue (SQS, BullMQ, Kafka, etc.) inside the request handler and process it from a worker. The same shape — verify → enqueue → return 200 → process elsewhere — applies.
  • Always catch and log errors from background work. A .catch on the dispatched promise (or a job handler with structured logging) is the only place those failures will surface.
  • Don't return non-2xx for application errors. Only fail the response when the request is unauthenticated (401) or genuinely malformed (400). Use your own dead-letter queue / monitoring for processing failures — non-2xx responses make Bakkt retry unnecessarily.

Delivery and retries

A delivery is considered failed if your endpoint returns a non-2xx status, times out, or is unreachable. Bakkt will attempt delivery up to 7 times in total (the initial attempt + 6 retries). The delay between retries follows the formula n^6 + 2 seconds, where n is the retry attempt number (starting at 1):

Retry attempt (n)Delay before retry
13 seconds
266 seconds (~1 minute)
3731 seconds (~12 minutes)
44,098 seconds (~1 hour 8 minutes)
515,627 seconds (~4 hours 20 minutes)
646,658 seconds (~13 hours)

After all 7 attempts fail, the webhook for that specific event is dropped and not retried again.

Other delivery semantics:

  • Endpoints that fail consistently are eventually moved to auto_disabled status. Re-enable by PATCHing status: "active" via PATCH /merchant/webhook-endpoints/{id} once the endpoint is healthy.
  • Webhooks reflect the latest state. If an older state was missed and a newer state has since occurred, only the newer state is retried — design your handler so it doesn't rely on seeing every intermediate transition.
  • At-least-once delivery. Bakkt may deliver the same webhook more than once. Make your handler idempotent.

Idempotency

Your handler may receive duplicates. Use a stable key to dedupe:

const seen = new Set(); // in production: a TTL'd cache like Redis

function dedupeKey(rawBody) {
  return crypto.createHash('sha256').update(rawBody).digest('hex');
}

async function processWithDedupe(event, rawBody) {
  const key = dedupeKey(rawBody);
  if (seen.has(key)) return;
  seen.add(key);
  await actuallyProcess(event);
}

The hash of the raw request body is a safe idempotency key because Bakkt re-sends the identical payload on retry. If you can't keep the raw body around, a (type, subType, uuid, data.transactionUuid) tuple works for transaction events, and (type, subType, uuid) works for status-update events.

Testing

From the platform

Use POST /merchant/webhook-endpoints/{id}/test (sandbox only) to fire a synthetic webhook at one of your registered endpoints with a type + sub_type + entity uuid that you control. This is the fastest way to confirm:

  • Your endpoint is reachable from the public internet.
  • You can verify the signature.
  • Your handler dispatches on type correctly.

From a local machine via tunnelling

For development, expose a local server with a tunnelling tool (e.g. ngrok), register the public URL via POST /merchant/webhook-endpoints, then trigger events:

node webhook-server.js
ngrok http 3000

curl -X POST https://sandbox.api.bakkt.com/merchant/webhook-endpoints \
  -H "Authorization: SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://abc123.ngrok.io/webhooks/bakkt",
    "description": "Local dev",
    "subscribed_events": ["KYC", "KYB"]
  }'

Triggering real KYC/KYB events in sandbox

Use the sandbox-only PATCH endpoints to drive status transitions and observe the resulting webhooks:

curl -X PATCH https://sandbox.api.bakkt.com/user/kyc/verification \
  -H "Authorization: SANDBOX_API_KEY" \
  -H "bakkt-session-id: SESSION_ID" \
  -H "Content-Type: application/json" \
  -d '{ "status": "FULL_USER" }'

curl -X PATCH https://sandbox.api.bakkt.com/corporate/$CORPORATE_UUID/kyb/verification \
  -H "Authorization: SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "ACTIVE" }'

Inspecting payloads without writing code

For quick payload inspection, register a webhook.site URL as a webhook endpoint, then trigger sandbox events as above.

Best practices

  • Always verify the Authorization header before doing any work — there's no other defence against forged webhook calls.
  • Compare in constant time (crypto.timingSafeEqual in Node, hmac.compare_digest in Python). A naïve === comparison leaks timing information about the secret.
  • Make handlers idempotent. Hash the raw body or use a (type, subType, uuid) tuple as the dedupe key.
  • Acknowledge with 2xx fast. Process in the background — see Express handler example.
  • Use distinct secrets per environment (sandbox vs production).
  • Rotate signing secrets periodically via PATCH /merchant/webhook-endpoints/{id}/secret, and immediately on suspected compromise.
  • Log all incoming webhooks with type, subType, uuid, and a request id for forensic / replay purposes.
  • Monitor for failed authorization checks (potential attack), 5xx responses from your handler, sudden volume changes, and auto_disabled endpoints.
  • Subscribe selectively. Set subscribed_events to only the types you handle — fewer events means less risk and less noise.

Debugging checklist

When webhooks aren't being received:

  • Endpoint is registered and status === 'active'? (Check with GET /merchant/webhook-endpoints or GET /merchant/webhook-endpoints/{id}.)
  • URL is publicly reachable over HTTPS with a valid certificate? Test with curl from outside your network.
  • Endpoint is returning 2xx within Bakkt's timeout? Check your access logs.
  • The Authorization header value matches the signing_secret (or legacy webhook_secret) you stored — exactly, including the API-Key prefix?
  • You're checking the right environment (sandbox vs production keys / endpoints)?
  • If the endpoint moved to auto_disabled, did you re-enable it after fixing the underlying issue?
  • The type you expect is in the endpoint's subscribed_events (or subscribed_events is omitted, meaning all)?

Next steps