Gareth Wilson Gareth Wilson

Guide to Bolt Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationMerchant Dashboard (Administration > Developers > Webhooks) or POST /v1/webhooks
EndpointsUp to ten per division on multi-endpoint (V2) divisions; one URL on legacy divisions
Subscriptionevent_group: "all" or an explicit events array, per endpoint
Signature headerX-Bolt-Hmac-Sha256
SchemeHMAC-SHA256, base64 digest
Signed contentThe raw request body, nothing else
SecretThe merchant Signing Secret from Administration > API, used as a plain string
Replay windowNone. No timestamp is sent or signed
Success responseHTTP 200 with a valid JSON body
RetriesExponential back-off on any other response
Failure alertsAn optional email after 10 failed attempts
ResendFailed 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

EventFires whenTransaction status
pendingPre-authorization succeeded and the transaction is pending fraud reviewPending
failed_paymentThe payment failed pre-authorizationFailed Authorization
paymentWith automatic capture, the transaction passed authorization and fraud review and the funds were capturedCompleted
authWith manual capture, the transaction passed authorization and fraud review and is ready to captureAuthorized
rejected_irreversibleThe transaction failed fraud review, with no appealPermanently Rejected
rejected_reversibleThe transaction was flagged in fraud review and a re-review can be requestedRecently Rejected
captureA manual capture succeededCompleted
creditA refund or credit was processedRefunded
voidThe transaction was voidedCancelled
newsletter_subscriptionThe customer subscribed to the merchant's newsletter
risk_insightsRisk information from the fraud review is available
credit_card_deletedA 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_hook_notification: true, 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

  1. 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.
  2. Go to Administration, and under Developers select Webhooks. If the page is missing, your role lacks write access to webhook settings.
  3. 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_id} 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-Sha256 header, answers Bolt with a 200 and 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 200 rather than 201, 202 or 204.
  • 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 type plus data.id or data.reference.
  • Apply state changes only when they move a transaction forward, so a late capture cannot overwrite a later credit.

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.


Gareth Wilson

Gareth Wilson

Product Marketing

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