# Guide to BlindPay Webhooks: Features and Best Practices

[BlindPay](https://blindpay.com) 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/blindpay/latest/payin.complete.json):

```json
{
  "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](https://blindpay.com/docs/learn/api-keys) if you register endpoints from code.

Register an endpoint with the API:

```bash
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:

```bash
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](https://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](/docs/cli) during development:

```bash
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](https://blindpay.com/docs/learn/webhooks-verification): 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:

```javascript
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:

```javascript
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](/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](/docs/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](/docs/retries) 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](/webhooks/guides/implement-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](https://hookdeck.com) does that and queues each one for your ledger service. [Start with a free Hookdeck account](https://dashboard.hookdeck.com/signup).