Guide to BlindPay Webhooks: Features and Best Practices
BlindPay is an API for moving money between fiat rails and stablecoins: payins that collect a local bank payment and send stablecoins to a wallet, payouts that go the other way, virtual accounts, managed wallets and on-chain transfers. Most of that work finishes minutes or hours after the API call, and some of it waits on BlindPay's compliance team. Webhooks are how your application learns that a payin landed, a payout cleared, or a customer passed KYC.
The sections below cover BlindPay's 26 events and its flat payload, registering endpoints per instance, verifying the Svix signature, and the gaps in BlindPay's delivery documentation.
What are BlindPay webhooks?
BlindPay webhooks belong to an instance, the container for your customers and transactions. Each instance can have up to 25 HTTPS endpoints, and each endpoint subscribes to a list of events or, with an empty list, to all of them. BlindPay delivers through Svix, so requests carry svix-id, svix-timestamp and svix-signature headers and follow the Standard Webhooks signing scheme.
The body has no wrapper. It is the resource itself, with one extra field, webhook_event, naming the event. The resource's id (for example pi_... for a payin) appears on every event in that resource's life, so payin.new, payin.update and payin.complete for one payin share an id.
BlindPay webhook features
| Feature | Details |
|---|---|
| Configuration | Instance Webhooks tab, or POST /v1/instances/{instance}/webhook-endpoints |
| Roles | owner, admin or developer on the instance |
| Endpoints | Up to 25 per instance, HTTPS only, no local or private addresses |
| Subscription | A list of events, or an empty events array for all |
| Creation rate limit | 5 requests per 60 seconds per user |
| Delivery platform | Svix |
| Headers | svix-id, svix-timestamp (Unix seconds), svix-signature |
| Signature | Standard Webhooks HMAC-SHA256, v1,<base64>, space-separated |
| Secret | whsec_<base64>, one per endpoint, retrievable at any time |
| Body | Flat JSON: webhook_event plus the resource's fields |
| Retries | "With backoff over the following hours". No schedule published |
| Ordering | Not guaranteed |
| Replay | Events dashboard per endpoint, or the hosted Svix portal |
| Source IPs | Not published |
Events and payloads
BlindPay documents 26 events, grouped by the resource that triggers them. The legacy receiver.* events were retired when receivers became customers in July 2026 and no longer fire.
| Event | Triggered When | Common Use Cases |
|---|---|---|
customer.new | A customer is created | Sync customer records |
customer.update | A customer is updated, including a KYC status change from BlindPay's compliance review | Unlock features after KYC approval |
customer.delete | A customer is deleted | Remove local records |
bankAccount.new | A bank account is added to a customer | Offer it as a payout destination |
blockchainWallet.new | A blockchain wallet is added to a customer | Offer it as a payout destination |
blockchainWallet.update | The self-custody answer of a blockchain wallet is saved | Compliance bookkeeping |
tos.accept | A customer accepts the terms of service | Record acceptance |
payin.new | A payin is created (any payment method, or a deposit into a virtual account) | Show a pending deposit |
payin.update | The payin advances through an intermediate step, such as an arrival check or manual review | Progress display |
payin.complete | The payin finishes: sent to the destination wallet, refunded, or failed | Credit the customer, branch on status |
payout.new | A payout is created and the source funds have been captured | Show a pending payout |
payout.update | The payout advances (document checks, manual review, bank send), or reaches failed | Progress display, failure handling |
payout.complete | The payout finishes successfully: confirmed at the destination, or refunded | Mark paid out |
virtualAccount.new | A virtual account is created or requested for a customer | Show "pending approval" |
virtualAccount.complete | The virtual account is approved and an account number is issued | Show deposit details |
transfer.new | A stablecoin transfer is created | Track in-flight transfers |
transfer.update | The transfer advances (for example a cross-chain burn confirmation), or fails to confirm | Progress display |
transfer.complete | The transfer confirms on-chain | Mark settled |
wallet.new | A managed wallet is created for a customer | Store the wallet id |
wallet.update | A managed wallet's yield status changes | Update balances shown |
wallet.inbound | A stablecoin deposit is detected in a managed wallet | Credit the deposit |
limitIncrease.new | A customer requests a transaction limit increase | Notify operations |
limitIncrease.update | BlindPay's compliance review approves or rejects the request | Notify the customer |
payable.new | A payable (bill) is registered as a draft | Track bills |
payable.update | The bill changes: claimed by a payout (processing), failed or refunded (back to draft), or deleted (canceled) | Sync bill state |
payable.complete | The payout executing the payable finishes and the bill is paid | Mark the bill paid |
The .complete names don't always mean success. payin.complete fires when a payin is sent on, refunded or failed, so read status. A failed payout never reaches payout.complete at all: it arrives as payout.update with a failed status.
A fiat integration mostly sees virtualAccount.complete, payin.complete, payout.new, payout.complete and bankAccount.new. A stablecoin one sees wallet.inbound, blockchainWallet.new, payin.complete, payout.complete, transfer.new and transfer.complete.
payin.complete is the only event with a published example. This copy is from the webhook samples library:
{
"webhook_event": "payin.complete",
"id": "pi_000000000000",
"status": "completed",
"customer_id": "re_000000000000",
"receiver_amount": 10000,
"sender_amount": 10000,
"payment_method": "ach",
"partner_fee": 0,
"billing_fee_amount": 0,
"transaction_fee_amount": 250,
"blindpay_bank_details": {
"bank_name": "Example Bank, N.A.",
"account_number": "000000000000",
"routing_number": "000000000"
},
"memo_code": "BP-AB12CD",
"pix_code": null,
"clabe": null,
"tracking_complete": {
"step": "completed",
"completed_at": "2026-06-01T12:00:00.000Z"
},
"tracking_payment": {
"step": "completed",
"completed_at": "2026-06-01T11:58:00.000Z"
},
"tracking_transaction": {
"step": "completed",
"transaction_hash": "0x0000000000000000000000000000000000000000000000000000000000000",
"completed_at": "2026-06-01T11:59:00.000Z"
}
}
For the other resources, the fields after webhook_event match the resource in BlindPay's API reference.
Setting up BlindPay webhooks
You need an instance, one of the owner, admin or developer roles on it, and an API key if you register endpoints from code.
Register an endpoint with the API:
curl --request POST \
--url https://api.blindpay.com/v1/instances/in_000000000000/webhook-endpoints \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{ "url": "https://example.com/webhooks/blindpay", "events": ["payin.complete", "payout.update", "payout.complete"] }'
The response contains the endpoint ID (we_...). Use it to fetch that endpoint's secret:
curl --request GET \
--url https://api.blindpay.com/v1/instances/in_000000000000/webhook-endpoints/we_000000000000/secret \
--header 'Authorization: Bearer YOUR_API_KEY'
# { "key": "whsec_..." }
In the dashboard, the same steps live under app.blindpay.com: pick the instance, open Webhooks, add the endpoint, then use the ellipsis next to it and choose Get secret. Store the whole value, prefix included, as BLINDPAY_WEBHOOK_SECRET.
The endpoint's Events dashboard in the same tab lists every attempt with its response code and lets you replay an event. For teammates who need the same view, GET /v1/instances/{instance}/webhook-endpoints/portal-access returns a link to Svix's hosted portal. That link covers the whole instance, not one endpoint.
BlindPay rejects local addresses, so use a tunnel such as the Hookdeck CLI during development:
npx hookdeck-cli listen 3000 blindpay --path /webhooks/blindpay
With Hookdeck. Add a source of type BlindPay, paste the endpoint's whsec_ secret, and register the Hookdeck source URL as the endpoint in BlindPay. The source accepts POST only. If a signature doesn't match, BlindPay still receives a 200; Hookdeck flags the request as failed verification and holds it back from your destination.
Securing BlindPay webhooks
BlindPay prints the construction on its verification page: join svix-id, svix-timestamp and the raw body with dots, HMAC-SHA256 them with the base64-decoded part of the secret after whsec_, and compare the base64 result with each v1, entry in svix-signature. During a secret rotation there can be more than one entry, and any match is enough. The verifier below reads svix-* first and falls back to webhook-* headers:
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const prefix = headers['svix-id'] ? 'svix' : 'webhook';
const id = headers[`${prefix}-id`];
const timestamp = headers[`${prefix}-timestamp`];
const signature = headers[`${prefix}-signature`];
if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
!/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
const key = decodeBase64(secret.replace(/^whsec_/, ''));
if (!key) return false;
const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
Then branch on webhook_event and status. Payment events should move money in your ledger exactly once, so record svix-id in the same transaction as the ledger change:
const express = require('express');
// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
try {
const value = JSON.parse(rawBody.toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
const app = express();
app.post('/webhooks/blindpay', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.BLINDPAY_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
const payload = parseJsonObject(req.body);
if (!payload) return res.sendStatus(400); // signed, but not a JSON object
if (typeof payload.webhook_event !== 'string') return res.sendStatus(400);
const messageId = req.headers['svix-id'] || req.headers['webhook-id'];
try {
if (payload.webhook_event === 'payin.complete' && payload.status === 'completed') {
await ledger.creditOnce(messageId, payload.customer_id, payload.receiver_amount);
} else if (payload.webhook_event === 'payout.update' && payload.status === 'failed') {
await payouts.markFailed(payload.id);
}
} catch {
return res.sendStatus(503); // not processed, so don't acknowledge
}
return res.sendStatus(200);
});
ledger.creditOnce and payouts.markFailed represent your own code.
BlindPay suggests rejecting timestamps more than 5 minutes from your clock, and the svix library does that by default. Hookdeck doesn't apply a tolerance window, and when it retries or replays a BlindPay event the original svix-timestamp goes with it, so a 5-minute check behind Hookdeck turns a delayed, genuine payment event into a rejection. The function above skips the age check. If you want one for traffic that comes straight from BlindPay, make it an option and leave it off for traffic that comes through Hookdeck.
BlindPay's own sample passes the two signatures straight to crypto.timingSafeEqual, which throws when their lengths differ. The version above checks lengths first, so a malformed header returns 401 instead of a 500.
Make BlindPay webhooks production-ready. Hookdeck Event Gateway checks BlindPay's Svix signature, keeps every payin and payout event, and retries delivery to your ledger service until it succeeds.
BlindPay webhook limitations and pain points
"Complete" can mean failed, and failure can skip "complete"
The Problem: A handler credits a customer on payin.complete, or waits for payout.complete to learn about failures, and gets it wrong.
Why It Happens: payin.complete covers completed, refunded and failed payins. Payout failures arrive on payout.update instead of payout.complete.
Workarounds:
- Branch on
statusfor every payment event, not on the event name alone. - Subscribe to
payout.updateif you need to know about failed payouts.
How Hookdeck Can Help: Hookdeck filters can match on both webhook_event and status, so failures can go to a separate destination from successes.
The retry policy isn't published
The Problem: You can't tell how long an outage can last before BlindPay gives up on an event.
Why It Happens: BlindPay only says that it retries "with backoff over the following hours". It doesn't state the number of attempts, the timeout per attempt, or whether endpoints are disabled after repeated failures.
Workarounds:
- Respond within a few seconds and do the ledger work afterwards.
- After an incident, check the Events dashboard and replay failed events.
- Reconcile payins and payouts against the API periodically.
How Hookdeck Can Help: Hookdeck accepts each event on arrival and stores it, so BlindPay's unknown schedule stops mattering. Retries to your service follow a policy you set.
Only one example payload
The Problem: You have to guess at the field layout for 25 of the 26 events.
Why It Happens: BlindPay's catalogue shows a body for payin.complete only and refers you to each resource's API reference for the rest.
Workarounds:
- Read the resource schema in the API reference and treat optional fields as missing until you've seen them.
- Capture one real delivery per event type in a test instance before you rely on a field.
How Hookdeck Can Help: Every request that reaches a Hookdeck source is kept with its headers and body, so the first real delivery of each event becomes your reference.
Portal links expose the whole instance
The Problem: A teammate given the Svix portal link can see every endpoint's traffic, not just theirs.
Why It Happens: The portal-access link is scoped to the instance.
Workarounds:
- Generate links only for people who should see all of the instance's events, and treat them as credentials.
How Hookdeck Can Help: Hookdeck's dashboard gives teammates request and event history with your own team's access controls.
Best practices
Verify the exact bytes
BlindPay warns that even reformatting whitespace breaks the signature. Read the raw body with express.raw({ type: '*/*' }), await request.text() in a Next.js route handler, or await request.body() in FastAPI, and switch off CSRF middleware on the webhook route.
Use the right secret for the endpoint
Every endpoint has its own secret. A staging endpoint's secret will never verify production deliveries, so fetch the secret by we_ ID and label it clearly.
Deduplicate on svix-id
svix-id stays the same across retries and dashboard replays. A replay resends the original payload rather than creating a new event, so it is safe to replay into a handler that deduplicates. The guide to webhook idempotency has more detail on this pattern.
Track state by resource ID, not by arrival order
BlindPay doesn't promise ordering. Keep the latest known status per id, and don't let a late payin.update overwrite a payin.complete.
Mind the creation rate limit in scripts
Registering more than 5 endpoints a minute returns 429 with webhook_. Add a pause to provisioning scripts that set up many instances.
Conclusion
BlindPay's events follow each payin, payout, transfer and compliance decision, and the signature is a standard Svix one that BlindPay documents clearly. The care goes into the handler: branch on status as well as the event name, watch payout.update for failures, deduplicate on svix-id, and leave the timestamp check out when another system sits in front of you.
Because BlindPay doesn't say how long its retries last, it helps to have something that accepts every payment event straight away. Hookdeck Event Gateway does that and queues each one for your ledger service. Start with a free Hookdeck account.