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 */
}
}Payload enrichment. As of May 2026, 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 old ID-only shape still works, because order is an additive sibling, not a replacement.
All other event families now ship with the same enrichment pattern. The sibling key is named after the resource:
| Event family | Sibling resource key | Example fields |
|---|---|---|
order.* | order | orderNumber, totalAmount, items[], customer, addresses, appliedDiscounts |
customer.* | customer | email, firstName, lastName, totalSpent, ordersCount, preferredLocale, acceptsMarketing |
product.* | product | sku, name, slug, basePrice, salePrice, images, attributes, avgRating, reviewCount |
payment.* | payment | externalId, amount, currency, status, paymentMethod, paymentMethodDetails, refundedAmount |
inventory.* | inventory | productId, trackingMode, total, reserved, available, lastInventorySyncAt |
donation.* | donation | amount, feeCoverAmount, chargedAmount, currency, donorEmail, donorName, isAnonymous, tributeType, tributeName, recurringSubscriptionId, payment |
A few caveats:
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.failedfires before the Payment row exists (capture never landed), so the payload carriescheckoutId + erroronly. Nopaymentsibling.order.refundedcarries extra fields alongsideorderIdand the fullordersnapshot, and not all of them are always present — branch on what you get rather than destructuring blindly:refundType("full" | "partial") — always present.refundId— only when the refund was created through Brainerce (dashboard refund orPOST /orders/:id/refunds). A refund taken in the payment provider's own dashboard, and a manual "mark as refunded" status change, both fire the event with norefundId.source: "provider"— present only when the refund was taken in the payment provider's own dashboard (Stripe, PayPal or Grow — the providers 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 onorderId+refundTypeand make the handler idempotent.
- Sensitive fields excluded:
costPriceon products (your margin),passwordHashon customers, rawproviderDataon payments, signed download URLs on orders. Listed inline in the enricher source so the audit story is explicit.
Status legend. Events marked live fire today on production. Events marked coming soon are listed in the catalogue and accepted on subscription, but the emit-side wiring is still in progress. You'll start receiving them automatically once it ships, with no action required on your side. We document them now so subscriptions can be created ahead of the rollout.
Orders
| Event | Status | When it fires | data shape |
|---|---|---|---|
order.created | live | A new order is placed, regardless of payment status | { orderId } |
order.updated | live | Order metadata changes (status, shipping address, items) | { orderId } |
order.paid | live | The order is paid, meaning a provider captured the funds or the merchant recorded an out-of-band payment (cash on delivery, bank transfer, card machine) | { orderId } |
order.fulfilled | live | All items in the order marked shipped/delivered | { orderId } |
order.cancelled | live | Order cancelled (by merchant via dashboard or by customer via portal) | { orderId } |
order.refunded | live | Order fully or partially refunded — including a refund taken in the payment provider's own dashboard, which never touches Brainerce | { orderId, refundType } always, where refundType is "full" | "partial"; plus refundId on a Brainerce-created refund and source: "provider" on a provider-dashboard one |
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 (the shopper never came back).
A refund taken in the provider's dashboard now moves the order too
If someone refunds a charge directly in Stripe, PayPal or Grow rather than in Brainerce, the provider's refund webhook reaches us and we now apply the same order-side transition a Brainerce refund does:
order.financialStatusbecomes"refunded"or"partially_refunded".order.statusmoves toREFUNDED/PARTIALLY_REFUNDED, but only from a status where that transition is legal. ACANCELLEDor already-REFUNDEDorder keeps its status, and aPAIDorder that is only partially refunded keepsPAID—financialStatusis the field that always tells the truth about the money, so read that rather thanstatusif you are reconciling refunds.order.statusHistorygets the transition appended.order.refundedfires, carryingsource: "provider".
Previously only the Payment row changed, so an integration polling GET /v1/orders/:id after a provider-side refund saw an order that still looked paid. If your reconciliation compensated for that, remove the workaround.
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 Grow report refunds back to Brainerce, so a provider-dashboard refund on one of those fires payment.refunded as well as order.refunded. Cardcom, iCredit, MAX, Morning, Sola and Takbull do not report refunds at all, so neither event fires for a refund taken in their dashboard — Brainerce never learns about it. Subscribe to order.refunded and expect it for refunds taken in Brainerce on any provider.
Customers
| Event | Status | When it fires | data shape |
|---|---|---|---|
customer.created | live | A 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.updated | live | Customer profile or contact details change | { customerId } |
customer.deleted | live | Customer account is deleted | { customerId } |
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/Apple OAuth, 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.
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
| Event | Status | When it fires | data shape |
|---|---|---|---|
product.created | live | A new product is added to the catalog | { productId } |
product.updated | live | Product attributes, variants, or pricing change | { productId } |
product.deleted | live | Product is removed from the catalog | { productId } |
Common use: sync catalog changes into a marketplace listing (Amazon, eBay), or rebuild a static product index.
Inventory
| Event | Status | When it fires | data shape |
|---|---|---|---|
inventory.updated | live | Stock level changes for any reason (sale, restock, manual). Internally aliased from the legacy inventory.changed emit. | { productId, variantId? } |
inventory.low | live | Stock falls to or below the low-stock threshold. At most one delivery per product per 24 hours. See below. | { productId, available, threshold } |
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.
Consequences for your handler:
- Do not treat the event as an authoritative "is low right now" signal. Read
GET /v1/products/{id}/inventorywhen you act on it. - Do not build a low-stock dashboard purely from these events; they undercount by design.
inventory.updatedhas no such dedup, so use it if you need every movement.
How the threshold is resolved, in order:
- The product's own
lowStockThreshold, when set. - Otherwise the maximum
lowStockThresholdacross 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. - Otherwise the platform default,
5.
The resolved number is echoed back as threshold in the payload; read it there rather
than reimplementing this precedence.
Checkout
| Event | Status | When it fires | data shape |
|---|---|---|---|
checkout.completed | live | Customer completes checkout. Fires alongside order.created, so subscribe to one or the other, not both | { orderId } |
checkout.abandoned | live | Cart marked ABANDONED by the abandonment scheduler | { cartId, abandonedAt, customerId } (customerId may be null for guest carts) |
Common use: trigger abandonment-recovery email campaigns; pipe completed checkouts into analytics.
Payments
| Event | Status | When it fires | data shape |
|---|---|---|---|
payment.succeeded | live | Payment provider confirms funds captured | { orderId } |
payment.failed | live | Payment provider rejects the transaction | { checkoutId, error } (no orderId yet; the order is created on success) |
payment.refunded | live | Refund posted to the customer | { paymentId, refundAmount, isFullRefund } |
Note that payment.succeeded and order.paid fire on similar events but at different layers:
payment.succeededis the payment provider confirming. Use it when you care about the payment record itself (chargebacks, settlement reconciliation).order.paidis 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 the bridge out of a deliberate boundary: Brainerce does not issue tax receipts and reports nothing to any tax authority. donation.paid is how the receipting system an organisation already uses finds out a gift arrived.
| Event | Status | When it fires | data shape |
|---|---|---|---|
donation.paid | live | The 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.refunded | live | A paid donation was refunded, in full or in part | { donationId } |
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.
Blog
| Event | Status | When it fires | data shape |
|---|---|---|---|
blog.post.published | live | A post goes live, by manual publish, scheduled publish, or SEO Autopilot | { postId, slug, title, salesChannelIds, publishedAt } |
blog.post.updated | live | An already-published post's content changes | { postId, slug, title, salesChannelIds, publishedAt } |
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.updatedfires only while the post is stillPUBLISHED. 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.updatedcarrying the newslug; 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.