Event Catalogue

Every webhook event type Brainerce can deliver, when it fires, and what its payload contains.

The merchant-facing webhook system delivers events from the following catalogue. Subscribe via Settings → Webhooks; fetch the full list at runtime via GET /api/stores/{storeId}/webhook-subscriptions/event-catalog.

All payloads follow the standard envelope:

{
  "id": "evt_<32-hex>",
  "type": "<event-type>",
  "createdAt": "<ISO-8601>",
  "data": {
    /* see per-event tables below */
  }
}

The envelope id is also sent as the X-Brainerce-Event-Id header. It is the same on every retry and on an event that is resent later from the event log (a resend adds X-Brainerce-Redelivery: true), so deduplicate on it. An endpoint can also be given a body template that reshapes the request body for a third-party API; its {{path}} placeholders resolve against this envelope, for example {{data.order.id}}.

Payload enrichment. order.* and checkout.completed events ship with the full Order resource in the envelope alongside the original orderId. Example for order.created:

{
  "id": "evt_a3f9b1c2...",
  "type": "order.created",
  "createdAt": "2026-05-18T14:00:00.000Z",
  "data": {
    "orderId": "ord_abc123",
    "order": {
      "id": "ord_abc123",
      "orderNumber": "1042",
      "status": "PENDING",
      "financialStatus": "pending",
      "fulfillmentStatus": "unfulfilled",
      "totalAmount": 142.5,
      "subtotal": 130.0,
      "taxAmount": 12.5,
      "shippingAmount": 0,
      "currency": "USD",
      "items": [
        /* line items */
      ],
      "customer": { "email": "...", "firstName": "...", "lastName": "..." },
      "shippingAddress": { "...": "..." },
      "billingAddress": { "...": "..." },
      "createdAt": "2026-05-18T13:59:58.000Z",
      "updatedAt": "2026-05-18T13:59:58.000Z"
      /* + appliedDiscounts, deliveryType, paymentMethod, etc. */
    }
  }
}

The ID fields stay beside order, so a handler that only reads orderId keeps working.

The other families use the same pattern. The sibling key is named after the resource:

Event familySibling resource keyWhere it is missing
order.*, checkout.completedorderNever added when the lookup fails
customer.*customercustomer.deleted (the row is gone)
product.*productproduct.deleted (the row is gone)
payment.refundedpaymentpayment.succeeded and payment.failed carry no paymentId, so they have no payment sibling
inventory.*inventoryA product with no stock row of its own
donation.*donationNever added when the lookup fails
blog.post.*noneThe payload is the flat fields in the Blog table

Resource fields

Every field below is always present on the resource, with null where the value is unset. Dates are ISO 8601 strings. Money on orders, customers, products and payments is a JSON number in the major unit (142.5 is 142.50 in currency). Money on a donation is a decimal string ("12.50"), the only family that does this.

order: id, storeId, orderNumber, externalId, platform, status (DRAFT, PENDING, PROCESSING, ON_HOLD, PAID, SHIPPED, DELIVERED, COMPLETED, FULFILLED, CANCELLED, REFUNDED, PARTIALLY_REFUNDED), financialStatus (for example pending, paid, partially_refunded, refunded), fulfillmentStatus (unfulfilled, partial, fulfilled), totalAmount, subtotal, taxAmount, shippingAmount, discountAmount, couponDiscount, surchargeAmount (numbers; surchargeAmount is 0 when there is none), couponCode, currency, items (array of line items, your internal cost removed), customer (the buyer snapshot stored on the order), shippingAddress, billingAddress, appliedDiscounts, appliedSurcharges, customFieldValues, deliveryType (shipping unless the order is a pickup), paymentMethod, sourceName, notes, createdAt, updatedAt.

customer: id, storeId, email, phone, firstName, lastName, emailVerified, tags (string array), metadata, preferredLocale, acceptsMarketing, privacyPolicyAcceptedAt, totalSpent (number), ordersCount, lastOrderAt, createdAt, updatedAt.

product: id, storeId, sku, name, slug, description, descriptionFormat, metaDescription, basePrice, salePrice (numbers), gtin, mpn, attributes, images, status, type (SIMPLE, VARIABLE, KIT), isDownloadable, translations, taxBehavior, avgRating, reviewCount, createdAt, updatedAt. Variants, per-variant prices and stock are not included; read them from the API with the productId.

payment (only on payment.refunded): id, storeId, orderId, checkoutId, externalId, amount, refundedAmount (numbers), currency, status (PENDING, AUTHORIZED, CAPTURED, FAILED, REFUNDED, PARTIALLY_REFUNDED, CANCELLED), paymentMethod, paymentMethodDetails, errorCode, errorMessage, createdAt, updatedAt.

inventory (the product-level stock row): id, productId, trackingMode (TRACKED, UNLIMITED, DISABLED), total, reserved, available (integers), lastInventorySyncAt, updatedAt. A change to one variant's stock still reports the product-level row.

donation: id, storeId, status (PENDING, PAID, FAILED, CANCELLED), amount, feeCoverAmount, chargedAmount (decimal strings), currency, donorEmail, donorName, isAnonymous, tributeType (IN_HONOR, IN_MEMORY, or null), tributeName, message, customerId, recurringSubscriptionId, campaignId, fundId, dedicationLabel, receiptName, donorPhone, receiptTaxIdLast4, paidAt, createdAt, and payment (null, or { id, status, amount, refundedAmount, currency } with the amounts as decimal strings).

A few caveats:

  • The sibling is best-effort, so data.order can be missing on any order.* event. If loading the resource fails when the event is dispatched (a database timeout, or the row already gone), the event is still delivered, carrying only the ID fields: { "orderId": "..." } with no order key. The same applies to every family above. Never treat a missing sibling as an error and never drop the event over it. Fetch the order instead: GET /api/v1/orders/{orderId} with Authorization: Bearer brainerce_… and the orders:read scope (SDK: client.getOrder(orderId) in apiKey mode). That returns the REST order resource, which is not guaranteed to be field-for-field identical to the webhook snapshot, so map from it explicitly.
  • customer.deleted / product.deleted: the row no longer exists at dispatch time, so the sibling resource is omitted. The merchant still receives the event with the ID and can decide how to react (typically: remove it from their CRM / catalog index).
  • payment.failed fires before the Payment row exists (capture never landed), so the payload carries checkoutId + error only. No payment sibling. It is never sent for a checkout that already has an order or a captured payment: a late or repeated failure notice from the provider after a successful payment is ignored, so a payment.failed is not followed by an order for the same checkout.
  • order.refunded carries extra fields alongside orderId and the full order snapshot, and not all of them are always present — branch on what you get rather than destructuring blindly:
    • refundType ("full" | "partial") — always present.
    • amount — always present. The refunded amount only, in the order's currency — not the order total. For a manual "mark as refunded" status change this does equal the order's total at the time of refund (that path always sets refundType: "full"). For a refund taken in the payment provider's own dashboard (source: "provider"), amount is what that provider reports refunding on that one payment — on an order settled across more than one tender (e.g. part gift card, part card), a provider-reported refundType: "full" can still carry an amount smaller than the order total, because it only reflects that payment's leg.
    • refundId — present when a Brainerce refund record backs the event: a refund created through Brainerce (dashboard refund or POST /orders/:id/refunds), and a refund taken in the payment provider's own dashboard that Brainerce recorded in the order's refund list (it then carries the id of that record). It is null for a manual "mark as refunded" status change, and for a provider-dashboard refund that carried no refund id of its own, because no refund record backs those. Use it to tell two partial refunds on one order apart.
    • source: "provider" — present only when the refund was taken in the payment provider's own dashboard (Stripe, PayPal and some other providers: only those that report a refund back to us) and reached us on their webhook. Absent means the refund came from Brainerce. Treat it as a discriminator, not a guarantee of ordering: the event is delivered at least once, so key your own bookkeeping on orderId + refundType and make the handler idempotent.
  • Never included in any payload: costPrice on products (your margin) and the cost on order line items, passwordHash and login tokens on customers, raw providerData on payments, signed download URLs on orders, internal account ids (accountId), and the store id at the top level of data. A donation carries the donor's tax id only as receiptTaxIdLast4.
  • Delivery order is not guaranteed. Each event is a separate delivery with a short random delay, and a retry can land after a later event, so order.paid can arrive before order.created. Use createdAt on the envelope to order events, and key your handler on the resource id.
  • A test event carries only the ID fields plus "_test": true, never the resource sibling. Its envelope id is evt_test_ followed by 24 hex characters. Use a real event to see the full shape.

Orders

EventWhen it firesdata
order.createdA new order exists, whatever its payment status: a storefront checkout, a paid checkout, an order created from the dashboard or POST /v1/ordersorderId, order
order.updatedThe order status changed, the shipping address was edited, or the order was marked as paid. Not sent for notes, tracking numbers, custom field values or line item editsorderId, order
order.paidThe order is paid: a provider captured the funds, or the merchant recorded an out-of-band payment (cash on delivery, bank transfer, card machine), or it was created already paidorderId, order
order.fulfilledThe order was fulfilled with the fulfil action (the dashboard's Add tracking window, or the fulfil tools of the MCP server and the AI assistant; there is no fulfil route on the public API), or a shipping label's carrier reported the parcel shipped. Not sent when the status is set to SHIPPED from the status dropdown or PATCH /v1/orders/{id} (that sends order.updated only), and not for a tracking correction on an order that already shippedorderId, order
order.cancelledThe order status became CANCELLED (dashboard, API, or the customer cancelling)orderId, order
order.refundedThe order was fully or partially refunded, including a refund taken in the payment provider's own dashboardorderId, order, refundType ("full" or "partial"), amount (number, refunded amount only), refundId (string or null), and source: "provider" on a provider-dashboard refund

The most common subscription is order.created + order.paid. Use order.paid to trigger fulfillment in your ERP; use order.created to log every order the moment it exists, whether or not it has been paid for yet.

order.created is not an attempt log. A checkout that fails payment never produces an order, so it never produces an order.created. If you want the attempts as well as the orders, subscribe to payment.failed (the provider rejected the transaction) and checkout.abandoned (only a logged-in customer's idle cart, and only for stores with cart recovery turned on, so it does not cover every shopper who left).

A refund taken in the provider's dashboard also moves the order

If someone refunds a charge directly in the provider's own dashboard (Stripe or PayPal, for example) rather than in Brainerce, and the provider reports refunds back, its refund webhook reaches us and we apply the same order-side transition a Brainerce refund does:

  • order.financialStatus becomes "refunded" or "partially_refunded".
  • order.status moves to REFUNDED / PARTIALLY_REFUNDED, but only from a status where that transition is legal. A CANCELLED or already-REFUNDED order keeps its status, and a PAID order that is only partially refunded keeps PAID — financialStatus is the field that always tells the truth about the money, so read that rather than status if you are reconciling refunds.
  • order.statusHistory gets the transition appended.
  • order.refunded fires, carrying source: "provider".

Two things this still does not do, deliberately: inventory is not restocked (a restock is a merchant choice with no equivalent input on a provider webhook), and payment.refunded does not reach you for every provider. Stripe, PayPal and some other providers report refunds back to Brainerce, so a provider-dashboard refund on one of those fires payment.refunded as well as order.refunded. The remaining providers (Sola included) do not report refunds at all, so neither event fires for a refund taken in their dashboard — Brainerce never learns about it. Do not branch on the provider name: treat a provider-dashboard refund as something that may never reach you. Subscribe to order.refunded and expect it for refunds taken in Brainerce on any provider.


Customers

EventWhen it firesdata
customer.createdA new customer account is created, either by a merchant or by a shopper signing up on your storefront. Guest checkout is the exception; see below.customerId, customer
customer.updatedA customer was updated through the customer update path: the dashboard, PATCH /v1/customers/{id}, the storefront profile edit. Not sent for changes the platform makes itself: order totals (totalSpent, ordersCount), marketing opt-in and unsubscribe, email verification, password changescustomerId, customer
customer.deletedThe customer account was deletedcustomerId (no customer)

What customer.created covers, and the one case it does not

Read this before you build a CRM sync on it.

customer.created fires when a customer account comes into existence, on all of these:

  • a merchant creating a customer from the dashboard or POST /v1/customers
  • a shopper signing up on your storefront with a password
  • a shopper completing an email-verified signup, where the event fires on verification, when the record is actually written, not when the code is emailed
  • a shopper signing up through Google, Facebook or GitHub OAuth (the only three providers there are), or a guest linking their first OAuth credential

Internally the shopper paths emit customer.registered, which is aliased onto customer.created for delivery. customer.registered is not subscribable on its own, and you will never receive both events for the same signup.

The exception is guest checkout. A shopper who checks out without registering has a customer record written for them, and that write emits no customer.created. It is not a signup: no credential is created and the shopper never asked for an account.

You still receive those shoppers, on the order events. order.created and checkout.completed both carry the enriched customer object, so a guest reaches your CRM attached to the order that created them. Like order itself, that object is best-effort: an event can arrive with orderId only, so fetch the order (which names its customer) when it is missing.

Expect customer.created for a customer you already know. Two ordinary sequences produce one: a guest who checked out months ago and now registers, and a merchant-created customer who later signs up on the storefront with the same email. The customerId is identical both times, and only the envelope id differs, so event-id deduplication will not collapse them. Handle the event as an upsert keyed on customerId, never a blind create. That is the one handler shape that is correct on every path above.

Note there is no GET /v1/customers list route to reconcile against, only GET /v1/customers/{id} and GET /v1/customers/by-email, so if you need a full customer export, take it from the dashboard.

Common use: pipe new customers into a CRM (HubSpot, Salesforce) or email tool (Mailchimp, Klaviyo).


Products

EventWhen it firesdata
product.createdA product is created: the dashboard, POST /v1/products, bulk create, duplicateproductId, product
product.updatedOne product was edited through the product update (dashboard product page, PATCH /v1/products/{id}). Not sent by bulk price, status or description edits, variant edits, or catalog importsproductId, product
product.deletedOne product was deleted. Not sent by a bulk deleteproductId (no product)

Common use: sync catalog changes into a marketplace listing (Amazon, eBay), or rebuild a static product index.


Inventory

EventWhen it firesdata
inventory.updatedStock changed for any reason (sale, restock, manual edit, import), for a product or one of its variantsproductId, variantId (only on a variant change), inventory
inventory.lowStock fell to or below the low-stock threshold, and is still above zero. At most one delivery per product per 24 hours. See belowproductId, available, threshold, inventory

Common use: trigger restock automations, alert ops in Slack when a popular SKU is running low.

inventory.low is deduplicated for 24 hours, per product

Stronger suppression than "once per crossing", and worth designing around: after one inventory.low fires for a product, no further inventory.low is delivered for that product for 24 hours, no matter how many times stock crosses the threshold in between. Sell down, restock, sell down again inside the same day and you receive exactly one event.

It is never sent when stock reaches zero. The check is skipped at available 0 or below, so a sale that sells the last unit fires no inventory.low; watch inventory.updated (its inventory.available) for out-of-stock. The event carries the available of the row that changed and fires for variant sales too, but it is per product: there is no variantId on it.

Consequences for your handler:

  • Do not treat the event as an authoritative "is low right now" signal. Read GET /v1/products/{id}/inventory when you act on it.
  • Do not build a low-stock dashboard purely from these events; they undercount by design.
  • inventory.updated has no such dedup, so use it if you need every movement.

How the threshold is resolved, in order:

  1. The product's own lowStockThreshold, when set.
  2. Otherwise the maximum lowStockThreshold across the store's sales channels. The maximum, not the minimum, so the most cautious channel wins and the alert fires earlier than a per-channel reading would suggest.
  3. Otherwise, when the store has no sales channels at all, the platform default of 5 (the LOW_STOCK_THRESHOLD setting of the platform).

The resolved number is echoed back as threshold in the payload; read it there rather than reimplementing this precedence.


Checkout

EventWhen it firesdata
checkout.completedAn order was created. Fires together with order.created from every place that creates an order (including the dashboard and POST /v1/orders), so subscribe to one or the other, not bothorderId, order
checkout.abandonedA logged-in customer's cart sat idle for 60 minutes and was marked abandoned. Only for stores that turned on cart recovery (the option "Also recover abandoned carts (logged-in customers)")cartId, abandonedAt (ISO 8601), customerId

Common use: trigger abandonment-recovery email campaigns; pipe completed checkouts into analytics.

checkout.abandoned does not cover a checkout that was started and left. It is sent from the cart sweep, which looks at carts that never reached checkout, belong to a logged-in customer (so customerId is always set, never null), have at least one item, were last touched between 60 minutes and 48 hours ago, and sit in a store with cart recovery on and a storefront channel with a domain. The sweep runs every 10 minutes. A guest cart, or a cart whose owner already opened the checkout, produces no event.

checkout.abandoned can fire more than once for the same cartId. A shopper who comes back to an abandoned cart gets it back as an active cart (the platform restores it for 30 days), and if they go idle again the cart is marked abandoned a second time. Read the event as "this cart is idle right now", and make your handler idempotent on cartId, for example by skipping a cart you have already emailed. The platform's own recovery emails are not repeated this way: they stop being scheduled for a checkout once the first one has been sent.


Payments

EventWhen it firesdata
payment.succeededA payment provider confirmed that funds were captured for a checkoutorderId only (no payment, no order)
payment.failedA payment provider rejected the transaction. No order exists yetcheckoutId, error (string). No payment
payment.refundedA refund reached a payment (Brainerce refund, or a provider-dashboard refund on a provider that reports refunds back, such as Stripe or PayPal)paymentId, refundAmount (number, major unit), isFullRefund (boolean), payment

Note that payment.succeeded and order.paid fire on similar events but at different layers:

  • payment.succeeded is the payment provider confirming. Use it when you care about the payment record itself (chargebacks, settlement reconciliation).
  • order.paid is the order state transitioning. Use it when you care about the order, for example to start fulfillment.

For most merchants, order.paid is the right choice. Subscribe to payment.* only when you're building payment-specific automation.

They are not interchangeable, and the gap matters. An order paid outside Brainerce, whether by cash on delivery, a bank transfer, a card machine, or an invoice settled by a business customer, fires order.paid but never fires payment.succeeded: no provider was involved, and no settlement will ever arrive to reconcile against. If you trigger fulfillment from payment.succeeded, every one of those orders will be silently skipped. Use order.paid.


Donations

⛔ A donation is not an order, so no order.* event ever fires for one. A donation has no line items, no fulfilment and no shipping, and it is reported separately from sales. If you subscribe to order.paid expecting to hear about gifts, you will hear nothing.

These two events are also how an organisation's own receipting system finds out a gift arrived. Brainerce issues tax receipts only through a connected receipt app (turned on by the merchant in the Donations page's Receipts tab). Without one, Brainerce issues no receipt and reports nothing to any tax authority, so a store with its own receipting system listens to donation.paid and donation.refunded instead. Receipt documents themselves fire no webhook.

EventWhen it firesdata
donation.paidThe provider confirmed the charge. Fires for a one-off gift from a donation page and for each cycle of a recurring donation (standing order).donationId, donation
donation.refundedA paid donation was refunded, in full or in partdonationId, donation

The donation object carries exactly these fields: id, storeId, status (PENDING, PAID, FAILED or CANCELLED; a paid one is PAID), amount, feeCoverAmount, chargedAmount (the three money values are decimal strings with two decimals, in the store currency), currency, donorEmail, donorName, isAnonymous, tributeType (IN_HONOR, IN_MEMORY or null), tributeName, message, customerId (or null), recurringSubscriptionId, campaignId, fundId, dedicationLabel, receiptName, donorPhone, receiptTaxIdLast4, paidAt, createdAt and payment (id, status, amount, refundedAmount, currency; null when the donation never had a payment). Nothing else is sent: the full receipt ID number, the failure reason and the donor's management link are never in a webhook.

Read amount, not chargedAmount, for the gift. amount is what the donor gave and what a receipt is written for. chargedAmount adds the processing fee the donor volunteered to cover, which is not part of the gift. On a fee-covered donation the two differ.

There is no refunded status on a donation. A refunded gift stays PAID, and the refunded figure is on donation.payment.refundedAmount — one fact in one place, rather than a copy on the donation that can drift from the money it describes.

donorName is sent even on an anonymous gift. isAnonymous is a promise about public surfaces, and this payload goes to your own systems; the flag rides along so you can honour it where you display donors. A recurring cycle carries recurringSubscriptionId; a one-off gift has it as null.

campaignId names the donation campaign the gift was for (a fundraising drive such as a building fund, not an email campaign), and is null for a general gift. Use it to file the gift against the right appeal in your own systems.

fundId names the fund (designation) the gift is for: where the money goes, such as scholarships or the building, as opposed to the campaign that raised it. null when the gift has none. A gift to a campaign carries the campaign's default fund unless the donor chose another. dedicationLabel is the store's own dedication wording as the donor picked it ("For the recovery of"), a snapshot that a later rename does not change; tributeName is the honoree.

Receipt details for your receipting system. receiptName is who the receipt is made out to when it is not the donor (a spouse, a company), donorPhone the donor's phone, and receiptTaxIdLast4 the last four characters of the ID or company number the donor gave for a receipt. The full number is stored encrypted and never leaves the platform in a webhook. All three are null when the donor gave none.

Blog

EventWhen it firesdata
blog.post.publishedA post goes live, by manual publish, scheduled publish, or SEO AutopilotpostId, slug, title, salesChannelIds (array, empty means every channel), publishedAt (ISO 8601 or null)
blog.post.updatedAn already-published post's content changespostId, slug, title, salesChannelIds, publishedAt (as above)

This is the pair a statically-generated storefront needs. Subscribe to both and revalidate on receipt: slug is enough to target the ISR path, and salesChannelIds tells you which storefronts the post belongs to, so a multi-channel setup can skip rebuilds it does not need.

These events are the reason SEO Autopilot content appears on a static storefront without a redeploy. Without a subscription, autopilot-published posts exist in Brainerce and are invisible on your site until the next build.

Two things these events do not tell you:

  • Unpublishing is silent. blog.post.updated fires only while the post is still PUBLISHED. Taking a post down emits nothing at all, so a cached storefront keeps serving a page the merchant has retired. If that matters, re-fetch the post on revalidation and 404 it when the API no longer returns it, rather than trusting an event to arrive.
  • A slug change arrives as the new slug only. Renaming a published post emits blog.post.updated carrying the new slug; the old path is never named, so revalidating the payload's slug leaves the previous URL stale in your cache. (Brainerce records the redirect on its own side, but your ISR cache does not learn about it.)

Subscribing programmatically

You can manage subscriptions via the API in addition to the dashboard:

# Create
curl -X POST https://api.brainerce.com/api/stores/{storeId}/webhook-subscriptions \
  -H "Authorization: Bearer brainerce_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/brainerce",
    "events": ["order.created", "order.paid"],
    "description": "Production order handler"
  }'

# List
curl https://api.brainerce.com/api/stores/{storeId}/webhook-subscriptions \
  -H "Authorization: Bearer brainerce_..."

# Update (e.g. add an event type)
curl -X PATCH https://api.brainerce.com/api/stores/{storeId}/webhook-subscriptions/{id} \
  -H "Authorization: Bearer brainerce_..." \
  -H "Content-Type: application/json" \
  -d '{ "events": ["order.created", "order.paid", "order.refunded"] }'

# Rotate the signing secret
curl -X POST https://api.brainerce.com/api/stores/{storeId}/webhook-subscriptions/{id}/rotate-secret \
  -H "Authorization: Bearer brainerce_..."

The API key needs the webhooks:write scope (mapped from MANAGE_WEBHOOKS store permission) for write operations, webhooks:read for reads.

Every route lives under /api/stores/{storeId}/webhook-subscriptions:

RouteScopePurpose
GET /event-catalogreadThe subscribable event types with one-line descriptions ({ events: [{ type, description }] }). The same list as this page.
GET /healthreadStore-wide snapshot: total, active and circuit-open counts, 24 h success rate and response time, 7-day trend, the 10 most recent failures.
GET /{id}/deliveriesreadThe endpoint's event log. Filter with ?outcome=all|succeeded|failed|missed.
POST /{id}/testwriteSend a test event (evt_test_*), 10 per hour per subscription. An endpoint with custom headers or a body template needs confirmLiveCall: true.
POST /{id}/previewreadRender the body, destination and header names for a stored real event without sending anything.
POST /{id}/redeliverwriteResend 1 to 25 chosen log rows ({ "deliveryIds": [...] }). 30 calls per hour.
POST /{id}/replay-missedwriteResend the events recorded while the circuit was open, oldest first, at most 25 per call. 3 calls per hour.
DELETE /{id}writeDelete the subscription.

Custom headers, the body template and resending are explained in Webhooks: resending events, custom headers and body template.