Bolt has moved to boltapp.com. Stores still using bolt.com hosts must update now: see the migration guide.

Skip to content

Universal callback conventions

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

View As MarkdownMCP

This page describes Universal Merchant API callbacks. Plugin integrations can use a different format; see Handle merchant callbacks.

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:

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

Follow the contract for the event you are handling:

FieldRule
dataRequired for success payloads in this flow. Error responses follow the event-specific schema.
eventEcho the event you were sent; validation differs by callback.
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.

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.

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:

EventFailure fieldShape
order.createerrorsAn 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.applyerrorA single object of code and message
order.create
{
  "event": "order.create",
  "status": "failure",
  "errors": [
    { "code": 2001005, "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."
  }
}

The cart.update response also uses an errors array. See cart.update.

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