Gareth Wilson Gareth Wilson

Guide to BlindPay Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationInstance Webhooks tab, or POST /v1/instances/{instance}/webhook-endpoints
Rolesowner, admin or developer on the instance
EndpointsUp to 25 per instance, HTTPS only, no local or private addresses
SubscriptionA list of events, or an empty events array for all
Creation rate limit5 requests per 60 seconds per user
Delivery platformSvix
Headerssvix-id, svix-timestamp (Unix seconds), svix-signature
SignatureStandard Webhooks HMAC-SHA256, v1,<base64>, space-separated
Secretwhsec_<base64>, one per endpoint, retrievable at any time
BodyFlat JSON: webhook_event plus the resource's fields
Retries"With backoff over the following hours". No schedule published
OrderingNot guaranteed
ReplayEvents dashboard per endpoint, or the hosted Svix portal
Source IPsNot 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.

EventTriggered WhenCommon Use Cases
customer.newA customer is createdSync customer records
customer.updateA customer is updated, including a KYC status change from BlindPay's compliance reviewUnlock features after KYC approval
customer.deleteA customer is deletedRemove local records
bankAccount.newA bank account is added to a customerOffer it as a payout destination
blockchainWallet.newA blockchain wallet is added to a customerOffer it as a payout destination
blockchainWallet.updateThe self-custody answer of a blockchain wallet is savedCompliance bookkeeping
tos.acceptA customer accepts the terms of serviceRecord acceptance
payin.newA payin is created (any payment method, or a deposit into a virtual account)Show a pending deposit
payin.updateThe payin advances through an intermediate step, such as an arrival check or manual reviewProgress display
payin.completeThe payin finishes: sent to the destination wallet, refunded, or failedCredit the customer, branch on status
payout.newA payout is created and the source funds have been capturedShow a pending payout
payout.updateThe payout advances (document checks, manual review, bank send), or reaches failedProgress display, failure handling
payout.completeThe payout finishes successfully: confirmed at the destination, or refundedMark paid out
virtualAccount.newA virtual account is created or requested for a customerShow "pending approval"
virtualAccount.completeThe virtual account is approved and an account number is issuedShow deposit details
transfer.newA stablecoin transfer is createdTrack in-flight transfers
transfer.updateThe transfer advances (for example a cross-chain burn confirmation), or fails to confirmProgress display
transfer.completeThe transfer confirms on-chainMark settled
wallet.newA managed wallet is created for a customerStore the wallet id
wallet.updateA managed wallet's yield status changesUpdate balances shown
wallet.inboundA stablecoin deposit is detected in a managed walletCredit the deposit
limitIncrease.newA customer requests a transaction limit increaseNotify operations
limitIncrease.updateBlindPay's compliance review approves or rejects the requestNotify the customer
payable.newA payable (bill) is registered as a draftTrack bills
payable.updateThe bill changes: claimed by a payout (processing), failed or refunded (back to draft), or deleted (canceled)Sync bill state
payable.completeThe payout executing the payable finishes and the bill is paidMark 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 status for every payment event, not on the event name alone.
  • Subscribe to payout.update if 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.

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_creation_rate_limit_exceeded. 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.