bolt.com is getting a new home → boltapp.com. Click here to transition smoothly to boltapp.com.

Skip to content

Merchant Callback conventions

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

View As MarkdownMCP

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

The request envelope

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

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 to choose events and open their references.

The response envelope

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

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:

FieldRule
dataRequired. Bolt reads the response body from data, so a response without it is rejected even when you return HTTP 200.
eventMust match the event you were sent. A mismatch is treated as a failed call.
statusEither success or failure. Set it accurately: some events, discounts.code.apply among them, reject a failure payload even when the HTTP status is 200.

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.

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:

DivisionResult
Custom cart, the defaultBolt 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 errorsBolt 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:

EventFailure fieldShape
order.createerrorsAn array, read as a deliberate decline. Stops the payment regardless of the division setting above
All other eventserrorA single object of code and message
order.create
{
  "event": "order.create",
  "status": "failure",
  "errors": [
    { "code": 2001, "data": [{ "reason": "Item out of stock" }] }
  ]
}
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 for the codes to use.

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:

Eventtotal_amountcurrency
order.shipping, order.tax41200"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.

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.

Address field names

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

EventAddressPhoneEmail
order.shipping, order.taxshipping_addressphoneemail
order.createbilling_address and each shipments[].shipping_addressphone_numberemail_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:

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

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.

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

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:

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);
}

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.

On this page