# Universal callback conventions

> The envelope, failure behavior, amount encoding, and request verification for Universal Merchant API events.

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

This page describes Universal Merchant API callbacks. Plugin integrations can use a different format; see [Handle merchant callbacks](/api-reference/merchant-callback/how-bolt-calls-you).

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

The Universal Merchant API sends events 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" }
  }
}
```

In this flow, the envelope applies to both the universal URL and event-specific URLs. Plugin callback contracts are separate.

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" }
    ]
  }
}
```

Follow the contract for the event you are handling:

| Field    | Rule                                                                                                                                                           |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data`   | Required for success payloads in this flow. Error responses follow the event-specific schema.                                                                  |
| `event`  | Echo the event you were sent; validation differs by callback.                                                                                                  |
| `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>
  In the Universal Merchant API flow, 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]

Abnormal `order.create` failures can allow payment processing to continue, depending on the division's error-handling settings. Timeout handling is configured separately. This behavior does not apply to every callback: a missing updated order-create payload fails a cart update.

Monitor callback failures and reconcile payments with orders. Do not assume a successful payment proves your server created its order.

To reject `order.create`, return HTTP 422 with `status: failure` and the documented error code. Do not return HTTP 200 for a deliberate decline. For other callbacks, follow the event-specific error status and schema:

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

```json title="order.create"
{
  "event": "order.create",
  "status": "failure",
  "errors": [
    { "code": 2001005, "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."
  }
}
```

The `cart.update` response also uses an `errors` array. See [cart.update](/api-reference/merchant-callback/cart-update).

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/)
