# Merchant Callback conventions

> The envelope, failure behavior, amount encoding, and request verification shared by every Merchant Callback event.

Source: https://help.boltapp.com/api-reference/merchant-callback/conventions

Use these rules for every Merchant Callback. The individual event references define the fields for each request and response.

## The request envelope [#the-request-envelope]

Bolt sends every event as an envelope of `event` and `data`, and the payload for that event sits inside `data`:

```json title="Request"
{
  "event": "order.shipping",
  "data": {
    "shipping_address": { "postal_code": "94105", "country_code": "US" },
    "cart": { "total_amount": 41200, "currency": "USD" }
  }
}
```

This is the same whether you registered one Universal Merchant API URL or individual endpoints per event. There is no unwrapped variant.

Use the exact event name from the request. For example, the discount event is `discounts.code.apply`, not `discount.code.apply`. See [Merchant Callbacks](/api-reference/merchant-callback/overview) to choose events and open their references.

## The response envelope [#the-response-envelope]

Your response uses the same envelope. Echo the `event` you are answering, set a `status`, and put the body inside `data`:

```json title="Response"
{
  "event": "order.shipping",
  "status": "success",
  "data": {
    "shipping_options": [
      { "service": "Standard", "cost": 500, "reference": "std", "signature": "std-v1" }
    ]
  }
}
```

Three rules govern whether Bolt accepts it:

| Field    | Rule                                                                                                                                                           |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data`   | Required. Bolt reads the response body from `data`, so a response without it is rejected even when you return HTTP 200.                                        |
| `event`  | Must match the event you were sent. A mismatch is treated as a failed call.                                                                                    |
| `status` | Either `success` or `failure`. Set it accurately: some events, `discounts.code.apply` among them, reject a `failure` payload even when the HTTP status is 200. |

<Warning>
  A flat response such as `{"status": "success", "display_id": "..."}` is rejected. HTTP 200 does not make it succeed, because Bolt looks for the body inside `data` and finds nothing there.
</Warning>

## When your endpoint fails [#when-your-endpoint-fails]

A rejected or failing response does not always stop the checkout. What happens depends on your division, and the custom cart default is the quiet one:

| Division                                                 | Result                                                                                                              |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| Custom cart, the default                                 | Bolt records the failure and lets checkout continue. The shopper is charged and no order is created on your server. |
| Divisions configured to fail on abnormal callback errors | Bolt fails the payment.                                                                                             |

For a custom cart this means a broken callback produces successful payments with no matching orders, and nothing in the shopper's experience indicates a problem. Alert on your own callback error rate rather than expecting checkout to surface it.

To fail on purpose, set `status` to `failure` and return a code. The field differs by event, so check the event's reference page:

| Event            | Failure field | Shape                                                                                              |
| ---------------- | ------------- | -------------------------------------------------------------------------------------------------- |
| `order.create`   | `errors`      | An array, read as a deliberate decline. Stops the payment regardless of the division setting above |
| All other events | `error`       | A single object of `code` and `message`                                                            |

```json title="order.create"
{
  "event": "order.create",
  "status": "failure",
  "errors": [
    { "code": 2001, "data": [{ "reason": "Item out of stock" }] }
  ]
}
```

```json title="Every other event"
{
  "event": "discounts.code.apply",
  "status": "failure",
  "error": {
    "code": 2319,
    "message": "Order total must be more than $100 to apply this discount."
  }
}
```

See [error codes](/api-reference/error-codes) for the codes to use.

## Amounts and currency [#amounts-and-currency]

The same field name can be a number or an object depending on which event you are handling. The rule is that events carrying a full cart snapshot use the expanded form, and events carrying a working cart use plain integers:

| Event                                  | `total_amount`                                                 | `currency`                                    |
| -------------------------------------- | -------------------------------------------------------------- | --------------------------------------------- |
| `order.shipping`, `order.tax`          | `41200`                                                        | `"USD"`                                       |
| `order.create`, `discounts.code.apply` | `{"amount": 41200, "currency": "USD", "currency_symbol": "$"}` | `{"currency": "USD", "currency_symbol": "$"}` |

Amounts are always in the currency's minor units, so `41200` is $412.00.

<Note>
  Type your inbound carts per event rather than sharing one cart model across all of them. Reusing a single strict model is the most common cause of a callback returning an error on one event while working on the others.
</Note>

## Address field names [#address-field-names]

Contact details on an address use different property names depending on which event delivered
the address:

| Event                         | Address                                                   | Phone          | Email           |
| ----------------------------- | --------------------------------------------------------- | -------------- | --------------- |
| `order.shipping`, `order.tax` | `shipping_address`                                        | `phone`        | `email`         |
| `order.create`                | `billing_address` and each `shipments[].shipping_address` | `phone_number` | `email_address` |

Everything else about the address is the same in both, including `street_address1`, `locality`,
`region`, `postal_code`, and `country_code`. `region` is the field the callback spec defines, and
it is usually the full state or province name, such as `California`; that is what Bolt checkout
sends. Do not reject a short form, though: the spec describes the field only as "a state,
province, or similar region type", and a code such as `CA` is valid in it.

Addresses can also carry a derived `region_code` such as `CA`, but that one is best effort. When
Bolt cannot map the region to a code, `order.create` omits the key entirely, while
`order.shipping` and `order.tax` still send the key with an empty value. **Do not test it with
`=== null`**: on the shipping and tax callbacks the unmapped case is normally an empty string, so
a null check silently misses it. Read `region`, and treat `region_code` as a key that may be
present and empty.

An `order.create` payload therefore carries the `_number` and `_address` spellings on every
address in it, while the shipping and tax callbacks carry the short ones:

```json title="order.create"
{
  "event": "order.create",
  "data": {
    "order": {
      "cart": {
        "billing_address": {
          "street_address1": "268 Bush Street",
          "locality": "San Francisco",
          "region": "California",
          "postal_code": "94104",
          "country_code": "US",
          "phone_number": "+14155550199",
          "email_address": "jane@example.com"
        }
      }
    }
  }
}
```

<Note>
  As with amounts, type your inbound addresses per event rather than sharing one address model.
  A parser written against `order.shipping` reads null contact details on `order.create`, which
  is easy to miss because every other field still populates.
</Note>

## Delivery option signatures [#delivery-option-signatures]

Shipping options, pickup options, and ship to store options all carry a `signature` field. Despite the name it is not a cryptographic value and Bolt does not verify it. It forms part of the cache key for the tax result attached to an option.

Give each option a value that is stable for as long as the option means the same thing, and change it whenever the option changes in a way that should invalidate its tax. Omitting it disables tax caching for that option rather than failing the request.

## Verifying the request came from Bolt [#verifying-the-request-came-from-bolt]

Every callback is signed. Compute the base64 encoded HMAC SHA-256 of the raw request body using your signing secret, then compare it to the `X-Bolt-Hmac-Sha256` header:

<CodeGroup>
  <CodeBlockTabs defaultValue="TypeScript">
    <CodeBlockTabsList>
      <CodeBlockTabsTrigger value="TypeScript">
        TypeScript
      </CodeBlockTabsTrigger>

      <CodeBlockTabsTrigger value="JavaScript">
        JavaScript
      </CodeBlockTabsTrigger>

      <CodeBlockTabsTrigger value="Python">
        Python
      </CodeBlockTabsTrigger>

      <CodeBlockTabsTrigger value="PHP">
        PHP
      </CodeBlockTabsTrigger>
    </CodeBlockTabsList>

    <CodeBlockTab value="TypeScript">
      ```typescript
      import { createHmac, timingSafeEqual } from "node:crypto";

      export function isFromBolt(
        rawBody: Buffer,
        headerValue: string,
        signingSecret: string,
      ): boolean {
        const expected = createHmac("sha256", signingSecret).update(rawBody).digest("base64");
        const a = Buffer.from(expected);
        const b = Buffer.from(headerValue);
        // timingSafeEqual throws unless both buffers are the same length.
        return a.length === b.length && timingSafeEqual(a, b);
      }
      ```
    </CodeBlockTab>

    <CodeBlockTab value="JavaScript">
      ```javascript
      const { createHmac, timingSafeEqual } = require("node:crypto");

      function isFromBolt(rawBody, headerValue, signingSecret) {
        const expected = createHmac("sha256", signingSecret).update(rawBody).digest("base64");
        const a = Buffer.from(expected);
        const b = Buffer.from(headerValue);
        // timingSafeEqual throws unless both buffers are the same length.
        return a.length === b.length && timingSafeEqual(a, b);
      }
      ```
    </CodeBlockTab>

    <CodeBlockTab value="Python">
      ```python
      import base64, hashlib, hmac

      def is_from_bolt(raw_body: bytes, header_value: str, signing_secret: str) -> bool:
          digest = hmac.new(signing_secret.encode(), raw_body, hashlib.sha256).digest()
          expected = base64.b64encode(digest).decode()
          return hmac.compare_digest(expected, header_value)
      ```
    </CodeBlockTab>

    <CodeBlockTab value="PHP">
      ```php
      <?php
      function isFromBolt(string $rawBody, string $headerValue, string $signingSecret): bool
      {
          $expected = base64_encode(hash_hmac('sha256', $rawBody, $signingSecret, true));
          return hash_equals($expected, $headerValue);
      }
      ```
    </CodeBlockTab>
  </CodeBlockTabs>
</CodeGroup>

Sign the raw body exactly as received. Parsing and re-serializing the JSON first changes the bytes and the signature will not match.

Two cases differ:

* **`GET` endpoints**, such as the OAuth login callback, sign the query string rather than a body.
* **While a signing secret is being rotated**, Bolt also sends `X-Bolt-Hmac-Sha256-Pending`, holding the same body signed with the incoming secret. Accept the request if either header matches, so that verification keeps working across the rotation.

<span id="next" />

## Related tasks [#related-tasks]

* [Choose and implement Merchant Callbacks](/api-reference/merchant-callback/overview)
* [Register Merchant Callback URLs](/platforms/how-to-integrate/set-up-hook-urls/)
