Agent skill
BlindPay Webhooks Skill
Receive and verify BlindPay webhooks (stablecoin and fiat payins/payouts API, delivered via Svix). Use when setting up BlindPay webhook handlers, debugging svix-signature verification, or handling events like payin.complete, payout.complete, customer.update, transfer.complete and wallet.inbound.
Install this skill
npx skills add hookdeck/webhook-skills --skill blindpay-webhooks
BlindPay is a stablecoin and fiat payins/payouts API. It pushes customer, bank account, wallet, payin, payout, virtual account, transfer, limit-increase and payable changes to endpoints you register per instance, delivered through Svix, which implements Standard Webhooks. Every delivery is an HTTP POST with a JSON body and three signature headers: svix-id, svix-timestamp, and svix-signature. The body is flat: webhook_event holds the event name, followed by the resource's own fields (no wrapper object).
When to Use This Skill
- How do I receive BlindPay webhooks?
- How do I verify BlindPay webhook signatures (
svix-signature)? - How do I handle
payin.complete,payout.completeorcustomer.updateevents? - How do I detect a stablecoin deposit (
wallet.inbound) or a finished transfer (transfer.complete)? - Why is my BlindPay webhook signature verification failing, or rejecting Hookdeck retries?
Verification (core)
Signed content is {svix-id}.{svix-timestamp}.{raw body}. Svix signs it with HMAC-SHA256 using the base64-decoded secret (the part after whsec_), base64-encodes the result, and sends one or more space-separated v1,<signature> entries. Use the official svix library, which compares in constant time. Read the svix-* headers, and when svix-id is absent fall back to the webhook-* headers (webhook-id, webhook-timestamp, webhook-signature), as Hookdeck does.
import { Webhook } from 'svix'; // svix 2.x is ESM-only
const prefix = req.headers['svix-id'] ? 'svix' : 'webhook';
const id = req.headers[`${prefix}-id`];
const timestamp = req.headers[`${prefix}-timestamp`];
const signature = req.headers[`${prefix}-signature`];
// rawBody: the raw Buffer/string, never re-serialized JSON
new Webhook(process.env.BLINDPAY_WEBHOOK_SECRET).verify(rawBody, {
'svix-id': id, 'svix-timestamp': timestamp, 'svix-signature': signature,
'webhook-id': id, 'webhook-timestamp': timestamp, 'webhook-signature': signature,
}); // throws on a bad signature or a timestamp outside the window
const event = JSON.parse(rawBody.toString('utf8')); // parse only after verify passes
from svix.webhooks import Webhook, WebhookVerificationError
wh = Webhook(os.environ["BLINDPAY_WEBHOOK_SECRET"]) # "whsec_..."
wh.verify(raw_body, {"svix-id": id, "svix-timestamp": ts, "svix-signature": sig,
"webhook-id": id, "webhook-timestamp": ts, "webhook-signature": sig})
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
The signing secret has the form whsec_<base64>. Fetch it with GET /v1/instances/{instance}/webhook-endpoints/{we_ (returns { "key": "whsec_..." }) or from the dashboard (instance > Webhooks > the endpoint's menu > Get secret). Keep the full whsec_... value in BLINDPAY_WEBHOOK_SECRET. Handlers fail closed: with the variable unset they return 500 instead of accepting anything. BlindPay stresses using the exact raw body: "Never modify the request body before verification".
Hookdeck retries and replays hit the 5-minute window
The svix library always enforces a fixed 5-minute timestamp window, and it can't be configured. Hookdeck retries and replays resend the original svix-timestamp, so the library rejects any retry or replay that happens more than 5 minutes after the original delivery. If you verify behind Hookdeck (or replay old events), use the manual verifier in references/verification.md, where the window can be dropped or widened. Deduplicate on svix-id (stable across retries) instead of relying on the timestamp.
Event Types
The handlers route on the top-level webhook_event field. BlindPay documents 26 events (the list may not be exhaustive, so acknowledge unknown events with 200):
| Resource | Events |
|---|---|
| Customer | customer.new, customer.update, customer.delete |
| Bank account | bankAccount.new |
| Blockchain wallet | blockchainWallet.new, blockchainWallet.update |
| Terms of service | tos.accept |
| Payin | payin.new, payin.update, payin.complete |
| Payout | payout.new, payout.update, payout.complete |
| Virtual account | virtualAccount.new, virtualAccount.complete |
| Transfer | transfer.new, transfer.update, transfer.complete |
| Managed wallet | wallet.new, wallet.update, wallet.inbound |
| Limit increase | limitIncrease.new, limitIncrease.update |
| Payable | payable.new, payable.update, payable.complete |
payin.complete fires when a payin finishes in any way (sent, refunded or failed), while a failed payout fires payout.update, not payout.complete. Check status on the payload. See references/overview.md for when each event fires and the payload shape.
Environment Variables
# Signing secret for the webhook endpoint (Svix format, starts with whsec_)
BLINDPAY_WEBHOOK_SECRET=whsec_xxxxx
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 blindpay --path /webhooks/blindpay
Register the tunnel URL as a webhook endpoint on your instance (BlindPay requires https) and copy the signing secret into .env. See references/setup.md.
Reference Materials
- references/overview.md - BlindPay webhook concepts, events, payload, delivery rules
- references/setup.md - Register an endpoint (API or dashboard), get the signing secret
- references/verification.md - Svix verification details, header fallback, manual verifier, timestamp window