# 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_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):

```json
{
  "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:

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

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

```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](/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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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](/webhooks/guides/implement-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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Bolt webhooks reliably in minutes.