Receiving Webhooks in Production
Everything here follows from two facts about how BlockWyre delivers: a delivery attempt times out after 30 seconds, and a delivery that fails six attempts in a row — the first and five retries, over roughly 7 to 14 minutes — disables the target, which then stops receiving anything.
A handler that is merely slow eventually becomes a handler that receives nothing.
Acknowledge first, work afterwards
Verify the signature, answer 200, and do the real work outside the request.
Anything you do before responding is spending your 30 seconds.
Verify against the raw body: register the route with the raw bytes
(express.raw), not a JSON parser. See
Validating Webhooks for verifyWebhook.
app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
const event = await verifyWebhook(req.body, req.headers); // fast, and must happen first
res.status(200).send("OK"); // acknowledge
await queue.add("process-webhook", event);
});
Expect the same webhook twice
A retry can arrive after your handler already succeeded — a response lost on the
way back looks identical to a handler that never ran. Deduplicate on
webhookId: it is unique per event per target, and stays the same across that
event's retries and resends. It is also your replay protection, because it is
part of the signed body.
async function processWebhook(payload) {
const { webhookId } = payload;
if (await db.webhooks.findOne({ webhookId })) return;
await handleWebhookEvent(payload);
await db.webhooks.create({ webhookId, processedAt: new Date() });
}
Record the id in the same transaction as the work it guards. Recorded before the work, a crash in between loses the event; recorded after it, a crash in between processes the event twice. Keep processed ids for at least as long as you would want to refuse a replay.
Do not count on the timestamp for replay protection
X-BlockWyre-Timestamp carries the time of the delivery attempt in Unix
seconds, but it is not covered by the signature, and BlockWyre sets it
fresh on every attempt. A replayed request can carry any timestamp, so a time
window does not stop replays: deduplicating on webhookId does.
Use the header only as a freshness hint — for example, to log or alert on deliveries that arrive late:
const timestamp = parseInt(headers["x-blockwyre-timestamp"], 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
logger.warn("webhook attempt arrived late or with clock skew", { webhookId: event.webhookId });
}
Do not reject on the body's timestamp either: it is when the event occurred,
and a retry or a resend keeps it.
Fail on the signature, not on your own bugs
These two failures deserve opposite answers:
| What failed | Respond | Why |
|---|---|---|
| Signature verification | 401 | The request is not ours. Retrying will not change that |
| Your own processing | 200 | Retrying replays a payload your code already could not handle, and six failed attempts disable the target |
Answer 200 and route the failure to your own alerting. Spending delivery
attempts on a bug in your handler costs you unrelated events later.
app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = await verifyWebhook(req.body, req.headers);
} catch {
return res.status(401).send("Unauthorized");
}
res.status(200).send("OK");
try {
await processWebhook(event);
} catch (error) {
logger.error("webhook processing failed", { error, webhookId: event.webhookId });
}
});
A 401 is a failed attempt like any other, so a webhook BlockWyre really sent
but your code fails to verify — for example, checked against the wrong
environment's JWKS — is retried and can disable the target. Make sure
verification works before you go live.
Watch the target, not just the handler
Your logs show what arrived. They cannot show what stopped arriving. Poll
GET /api/v2/webhooks/targets and alert on disabledAt appearing — that field
is the difference between a quiet day and a silently disabled integration.
Worth tracking as well: delivery success rate, handler processing time, and
X-BlockWyre-Retry-Count. A retry count climbing above zero is the warning
before the disable.
Testing locally
Targets must be reachable from the internet, so expose your local server with a tunnel:
ngrok http 3000
Register the tunnel URL as a target, and remember to delete it afterwards — an expired tunnel is a failing target, and one delivery that fails all six attempts disables it.
Verify your signature code against the sandbox JWKS
(https://api-sandbox.blockwyre.xyz/.well-known/jwks.json) before going near
production, and switch to the production JWKS
(https://api.blockwyre.com/.well-known/jwks.json) for production targets:
each environment signs with its own key. See
Validating Webhooks.
Troubleshooting
| Symptom | Cause |
|---|---|
| Deliveries stopped entirely | The target was disabled after a delivery failed all six attempts. Check disabledAt, fix the endpoint, then re-enable it — see Managing Webhook Targets |
| Signature verification fails | The signature is over the raw body. A framework that parses and re-serializes JSON changes the bytes and invalidates it. Capture the raw body before any parsing middleware. Also check you use the JWKS of the environment that sent the webhook: sandbox and production sign with different keys |
| Some events never arrive | A target created or edited in the dashboard receives only its selected categories: check them under Notifications › Webhook. A target created through /api/v2/webhooks/targets receives every event. Some events do not exist: there are no card events, and no separate event for an incoming deposit (it arrives as crypto_transaction_created or bank_transaction_created with details.type deposit). Otherwise reconcile against the API |
X-BlockWyre-Retry-Count keeps climbing | Your endpoint is answering outside 2xx/3xx, or taking longer than 30 seconds |
| Nothing arrives at a new target | Confirm the URL resolves to a public address (private and internal addresses are refused), presents a valid certificate, and redirects at most 3 times |
Support and Resources
If you need assistance or have any questions, our support team is here to help. You can contact our support team at support@blockwyre.com.
Stay Updated
Stay up-to-date with the latest news, updates, and features from BlockWyre by following us on social media:
- Instagram: BlockWyre Instagram
- Twitter: BlockWyre Twitter
- Facebook: BlockWyre Facebook
- LinkedIn: BlockWyre LinkedIn
We are excited to have you on board and look forward to seeing how you leverage BlockWyre's powerful tools to enhance your financial operations. Happy integrating!