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:
| Setup | Endpoints involved | When to use |
|---|---|---|
/merchant/webhook-endpoints (recommended) | POST, GET, GET/PATCH/DELETE /{id}, PATCH /{id}/secret, POST /{id}/test | All new integrations. Supports multiple endpoints per merchant, per-endpoint event subscriptions, signing-secret rotation, and explicit test sends. |
PATCH /merchant (legacy) | PATCH /merchant | Existing 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_secretimmediately. This is the only time it's returned in plaintext. Bakkt sends it back to your endpoint in theAuthorizationheader on every webhook delivery, and you compare it in constant time (see Verify webhook authenticity). If you lose it, rotate viaPATCH /merchant/webhook-endpoints/{id}/secret— the previous secret is invalidated as soon as the rotation succeeds.
List, inspect, update, and delete
| Action | Endpoint |
|---|---|
List all endpoints (optionally filter by status) | GET /merchant/webhook-endpoints |
| Get one endpoint | GET /merchant/webhook-endpoints/{id} |
Update URL, status (active / disabled), or subscribed_events | PATCH /merchant/webhook-endpoints/{id} |
Rotate signing_secret | PATCH /merchant/webhook-endpoints/{id}/secret |
| Delete endpoint (cannot be re-enabled) | DELETE /merchant/webhook-endpoints/{id} |
Endpoints can be in one of three statuses:
| Status | Meaning |
|---|---|
active | Receiving webhook deliveries. |
disabled | Manually disabled by you via PATCH. |
auto_disabled | Automatically 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"
}
}| Field | Description |
|---|---|
type | Event family (see Event types below). |
subType | Specific status or sub-event within the family. |
uuid | Identifier of the affected entity — user_uuid for KYC events, corporate_uuid for KYB events, transaction uuid for transaction events, etc. |
data | Event-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.
type | What it covers | Documented in |
|---|---|---|
KYC | Individual user verification status. | This guide |
KYB | Corporate entity verification status. | This guide |
fiatToCrypto | On-ramp transaction state changes. | Stablecoin / Conversions guide |
cryptoToFiat | Off-ramp transaction state changes. | Stablecoin / Conversions guide |
unblockBankAccount | Virtual IBAN / bank account events. | Accounts guide |
linkBankAccount | External bank-account linking events. | Accounts guide |
linkedBankAccountProfile | Updates to a linked bank account profile. | Accounts guide |
walletCreated | A wallet has been provisioned for the user / corporate. | Accounts guide |
entityStatusUpdate | Cross-entity status changes (suspension, unblock, T&C re-acceptance required). | Accounts guide; Terms and Conditions Hashes for MISSING_TERMS_AND_CONDITIONS_SIGNED |
AML | AML screening hits and outcomes. | Compliance / AML guide |
duplicateUser | A duplicate user was detected. | User Management |
senderNameMismatch | Mismatch between the expected and actual sender name on an inbound transfer. | Accounts guide |
otpNotification | OTP-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):
subType | Meaning |
|---|---|
KYC_NEEDED | User exists; KYC not yet started. |
PENDING_KYC_DATA | KYC application created; documents not yet collected. |
KYC_PENDING | Documents submitted; under review. |
SOFT_KYC_FAILED | Recoverable failure; user can retry. |
HARD_KYC_FAILED | Terminal failure; contact support. |
FULL_USER | KYC approved; user can transact. |
SUSPENDED | User 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.
subType | Meaning |
|---|---|
CREATED | Corporate entity has been successfully created. |
KYB_PENDING | KYB submitted; under review. |
ACTIVE | KYB approved; corporate can transact. |
REJECTED | KYB 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-endpointswith multiple endpoints, look up the rightexpectedSecretbased on the URL the request was delivered to (each endpoint has its own secret). Reject any request whoseAuthorizationheader 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
Authorizationheader doesn't match your stored secret. - Acknowledge fast, then process out-of-band. The handler returns
200immediately after verification. The actual work is dispatched viasetImmediateso it runs after the response has been flushed and does not occupy the request lifecycle. - For production, prefer a real job queue.
setImmediatekeeps work in-process — if the worker crashes between ack and processing, the event is lost and Bakkt won't retry (the200already 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
.catchon 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 |
|---|---|
| 1 | 3 seconds |
| 2 | 66 seconds (~1 minute) |
| 3 | 731 seconds (~12 minutes) |
| 4 | 4,098 seconds (~1 hour 8 minutes) |
| 5 | 15,627 seconds (~4 hours 20 minutes) |
| 6 | 46,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_disabledstatus. Re-enable by PATCHingstatus: "active"viaPATCH /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
typecorrectly.
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
Authorizationheader before doing any work — there's no other defence against forged webhook calls. - Compare in constant time (
crypto.timingSafeEqualin Node,hmac.compare_digestin 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_disabledendpoints. - Subscribe selectively. Set
subscribed_eventsto 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 withGET /merchant/webhook-endpointsorGET /merchant/webhook-endpoints/{id}.) - URL is publicly reachable over HTTPS with a valid certificate? Test with
curlfrom outside your network. - Endpoint is returning
2xxwithin Bakkt's timeout? Check your access logs. - The
Authorizationheader value matches thesigning_secret(or legacywebhook_secret) you stored — exactly, including theAPI-Keyprefix? - 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
typeyou expect is in the endpoint'ssubscribed_events(orsubscribed_eventsis omitted, meaning all)?
Next steps
- KYC & KYB Verification — full status lifecycles, rejection reasons, and recovery flows for KYC/KYB events.
- User Management and Corporate Management — the entities those events relate to.
- Authentication — how to obtain the API key used for setup calls.
- API Reference — Webhook setup
- API Reference — Test webhook
Updated 3 days ago
