Guide to Bolt Webhooks: Features and Best Practices
Bolt webhooks tell your store what happened to a payment after checkout: a transaction cleared fraud review, failed authorization, was captured, refunded or voided. If you take payments through Bolt, the one-click checkout platform at boltapp.com, webhooks are how those status changes reach your order management system without polling Bolt's API. (This is Bolt the checkout company, not the ride-hailing app or bolt.new.)
This guide covers how to register Bolt webhook endpoints, how the X-Bolt-Hmac-Sha256 signature works, the transaction events you can subscribe to, the response format Bolt expects, and the practices that keep a Bolt receiver reliable.
What are Bolt webhooks?
A Bolt webhook is an HTTPS endpoint registered on a division. When a subscribed event happens, Bolt sends a JSON POST to it. Webhooks are separate from Bolt's Merchant Callbacks, which Bolt calls synchronously during checkout and which are configured somewhere else in the dashboard. A webhook informs you after the fact; nothing in checkout waits for it.
Every transaction webhook has the same three top-level keys: type names the event, object is transaction, and data is the transaction itself. The shape of data changes with type.
Each request is signed. Bolt computes an HMAC-SHA256 of the raw request body with your Signing Secret and sends the base64-encoded result in X-Bolt-Hmac-Sha256. Bolt signs Merchant Callback requests the same way, with the same secret.
Bolt webhook features
| Feature | Details |
|---|---|
| Configuration | Merchant Dashboard (Administration > Developers > Webhooks) or POST /v1/webhooks |
| Endpoints | Up to ten per division on multi-endpoint (V2) divisions; one URL on legacy divisions |
| Subscription | event_group: "all" or an explicit events array, per endpoint |
| Signature header | X-Bolt-Hmac-Sha256 |
| Scheme | HMAC-SHA256, base64 digest |
| Signed content | The raw request body, nothing else |
| Secret | The merchant Signing Secret from Administration > API, used as a plain string |
| Replay window | None. No timestamp is sent or signed |
| Success response | HTTP 200 with a valid JSON body |
| Retries | Exponential back-off on any other response |
| Failure alerts | An optional email after 10 failed attempts |
| Resend | Failed deliveries can be resent from Administration > Webhooks |
Bolt publishes no webhook source IP list. Its docs say to ask Bolt support for the current egress ranges if a firewall or WAF sits in front of your endpoint.
Common events
| Event | Fires when | Transaction status |
|---|---|---|
pending | Pre-authorization succeeded and the transaction is pending fraud review | Pending |
failed_payment | The payment failed pre-authorization | Failed Authorization |
payment | With automatic capture, the transaction passed authorization and fraud review and the funds were captured | Completed |
auth | With manual capture, the transaction passed authorization and fraud review and is ready to capture | Authorized |
rejected_irreversible | The transaction failed fraud review, with no appeal | Permanently Rejected |
rejected_reversible | The transaction was flagged in fraud review and a re-review can be requested | Recently Rejected |
capture | A manual capture succeeded | Completed |
credit | A refund or credit was processed | Refunded |
void | The transaction was voided | Cancelled |
newsletter_subscription | The customer subscribed to the merchant's newsletter | |
risk_insights | Risk information from the fraud review is available | |
credit_card_deleted | A customer removed a saved card (beta, custom cart implementations only) |
Two details matter when you plan your handler. Authorization webhooks only arrive for transactions that went to manual review: when Bolt's automated risk review approves a transaction, you get the status in the API response instead. And Virtual Terminal transactions do not send webhooks at all.
Some transaction endpoints accept skip_, which suppresses the webhook for that call. If your own code triggers captures or refunds, check whether it sets that flag before you rely on the matching webhook.
Bolt does not publish a complete webhook body, only a per-field schema. Built from that schema, a payment event looks like this (trimmed):
{
"type": "payment",
"object": "transaction",
"data": {
"id": "T1c3p4yBuVYJ9",
"type": "cc_payment",
"processor": "adyen_gateway",
"date": 1615407159447,
"reference": "LBLJ-TWW7-R9VC",
"status": "completed",
"amount": { "amount": 754, "currency": "USD", "currency_symbol": "$" }
}
}
data can also carry from_user, from_credit_card, order, authorization, captures, refund_transactions, risk_signals and capture_type. Amounts are integers in cents, and date is in milliseconds. data.type is the kind of transaction (cc_payment, cc_credit, paypal_payment and so on), not the webhook event, so route on the top-level type.
Setting up Bolt webhooks
- In the Bolt Merchant Dashboard, go to Administration > API and copy the Signing Secret. Store it server-side, for example as
BOLT_SIGNING_SECRET. Sandbox and production dashboards have separate secrets. - Go to Administration, and under Developers select Webhooks. If the page is missing, your role lacks write access to webhook settings.
- In the Endpoints card, select Add endpoint, enter your HTTPS URL and choose the events.
To register an endpoint from code, call the webhooks API with your API key:
curl -X POST "https://api-sandbox.boltapp.com/v1/webhooks" \
-H "Content-Type: application/json" \
-H "X-API-Key: $BOLT_API_KEY" \
-d '{
"division_id": "YOUR_DIVISION_PUBLIC_ID",
"url": "https://example.com/webhooks/bolt",
"event_group": "all"
}'
division_id is the Division Public ID from the Identifiers card on the API page. Send exactly one of event_group or events; both or neither returns HTTP 400. The response includes a webhook_id. There is no update operation, so to change an endpoint you create the new one, confirm it receives events, and then DELETE /v1/webhooks/{webhook_ the old one. A 422 on create means another webhook already sends the same events to that URL.
Divisions run in one of two modes. Multi-endpoint (V2) divisions hold up to ten endpoints. Older divisions hold a single URL, and the create call returns 400 saying the WebhooksV2 feature must be enabled. Bolt support can switch a division to multi-endpoint mode.
Securing Bolt webhooks
Compute an HMAC-SHA256 of the raw request body with the Signing Secret as the key, base64-encode it, and compare it to X-Bolt-Hmac-Sha256 in constant time. Then answer with a 200 and a JSON body.
const crypto = require('crypto');
const express = require('express');
function verifyBoltSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('base64');
const received = Buffer.from(signatureHeader, 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/bolt', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.BOLT_SIGNING_SECRET;
if (!secret) return res.status(500).json({ status: 'failed' }); // fail closed
if (!verifyBoltSignature(req.body, req.get('x-bolt-hmac-sha256'), secret)) {
return res.status(401).json({ status: 'failed' });
}
const event = JSON.parse(req.body.toString('utf8'));
// Route on event.type (payment, capture, credit, void, ...). Persist, then process async.
// Bolt needs HTTP 200 AND a valid JSON body, or it retries.
res.status(200).json({ status: 'succeeded' });
});
The same check in Python:
import base64
import hashlib
import hmac
import os
def verify_bolt_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
expected = base64.b64encode(digest)
return hmac.compare_digest(expected, signature_header.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_bolt_signature(raw_body, request.headers.get("x-bolt-hmac-sha256"),
# os.environ["BOLT_SIGNING_SECRET"])
# then return {"status": "succeeded"} with a 200
Bolt's own samples in TypeScript, JavaScript, Python and PHP all do the same thing. Two things to avoid: hex-encoding the digest (Bolt uses base64), and decoding or trimming the Signing Secret (it is used exactly as shown in the dashboard).
Make Bolt webhooks production-ready. Hookdeck Event Gateway verifies the
X-Bolt-Hmac-Sha256header, answers Bolt with a200and a JSON body, and durably queues every transaction event before your service sees it.
Bolt webhook limitations and pain points
A 200 is not enough
The Problem: Your handler returns 200, yet Bolt marks the delivery as failed and keeps retrying.
Why It Happens: Bolt counts a delivery as successful only when the response is HTTP 200 OK and the body is valid JSON. A 200 with an empty body, a plain-text OK, or a 204 No Content all fail and trigger exponential back-off.
Workarounds:
- Return a JSON body on every success path, for example
{"status": "succeeded"}, which is the response schema in Bolt's webhook reference. - Return
200rather than201,202or204. - Use the Webhook Events view in the dashboard to see the failure reason Bolt recorded.
How Hookdeck Can Help: Hookdeck answers every delivery with a 200 and a JSON body, so Bolt sees success as soon as the event is stored, whatever your own service returns.
Signature mismatches from encoding and body handling
The Problem: Every delivery fails verification even though the secret is right.
Why It Happens: The usual causes are comparing against a hex digest instead of base64, verifying a re-serialised body instead of the raw bytes, or using the sandbox secret against production traffic. Each produces a well-formed digest that never matches.
Workarounds:
- Read the raw body (
express.raw(),await request.body()) and parse JSON only after verification. - Encode the digest as base64.
- Keep sandbox and production secrets in separate variables.
How Hookdeck Can Help: Hookdeck's Bolt source type verifies X-Bolt-Hmac-Sha256 against the bytes it received, so the encoding and raw-body handling live in one place.
No event ID and no replay protection
The Problem: Retries and resends deliver the same event more than once, and nothing in the payload marks them as duplicates.
Why It Happens: Bolt payloads carry no event ID, and the signature covers only the body, so a replayed request verifies as well as the original.
Workarounds:
- Make each handler idempotent on
typeplusdata.idordata.reference. - Apply state changes only when they move a transaction forward, so a late
capturecannot overwrite a latercredit.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries before they reach your service, based on the fields you choose.
Retry timing is undocumented
The Problem: You can't predict how long Bolt keeps retrying or when it gives up.
Why It Happens: Bolt says it retries with exponential back-off and sends a failure email after 10 failed attempts, but it doesn't publish the intervals, the maximum number of attempts or the response timeout. The failure email is off by default.
Workarounds:
- Turn on webhook failure notifications in your dashboard email settings.
- Check Administration > Webhooks for failed deliveries after any outage, and resend them.
- Respond quickly and process asynchronously so you never rely on the unknown timeout.
How Hookdeck Can Help: Hookdeck stores each event when it arrives and retries delivery to your service on a schedule you configure, so you know exactly how failures are handled and can replay any event yourself.
Best practices
Verify against the raw body
The signature covers the exact bytes Bolt sent. Parse JSON only after verification. In a Next.js route handler, read await request.text(); in FastAPI, await request.body().
Route on the top-level type
type is the event; data.type is the kind of transaction and object is always transaction for transaction events (credit_card_deleted uses credit_card). Acknowledge any type you don't handle with the same 200 and JSON body, so new events never count as failures.
Don't wait for an auth webhook that won't come
Automatically approved authorizations return their result synchronously. Only manually reviewed ones produce an auth or rejected_* webhook. Take the synchronous result from the API response and use the webhook for the delayed cases.
Acknowledge fast and process asynchronously
Verify, persist, return 200 with JSON, then do the work. See why to process webhooks asynchronously.
Deduplicate deliveries
Build an idempotency key from type and data.id (or data.reference) and store it before processing. See our guide to webhook idempotency.
Test in sandbox first
Register a sandbox endpoint, complete a sandbox order that produces a subscribed event, and confirm the signature verifies and the dashboard records a success before you add the production endpoint.
Conclusion
Bolt signs each webhook with a base64 HMAC-SHA256 of the raw body in X-Bolt-Hmac-Sha256, keyed with your merchant Signing Secret. Verify against the raw bytes, route on the top-level type, deduplicate on the transaction ID, and always answer with a 200 and a JSON body, because anything else sends Bolt into retries.
Hookdeck Event Gateway sits in front of your receiver, checks every X-Bolt-Hmac-Sha256, answers Bolt in the format it expects, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Bolt webhooks reliably in minutes.