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 family | Sibling resource key | Where it is missing |
|---|---|---|
order.*, checkout.completed | order | Never added when the lookup fails |
customer.* | customer | customer.deleted (the row is gone) |
product.* | product | product.deleted (the row is gone) |
payment.refunded | payment | payment.succeeded and payment.failed carry no paymentId, so they have no payment sibling |
inventory.* | inventory, plus variantInventory when the event names a variant | inventory: a product with no stock row of its own. variantInventory: an event with no variantId |
donation.* | donation | Never added when the lookup fails |
return.* | return | Never added when the lookup fails |
blog.post.* | none | The 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, unavailable, available (integers), lastInventorySyncAt, updatedAt. A change to one variant's stock still reports the product-level row, and for a product with variants that row is normally all zeros, because the stock is kept per variant.
variantInventory (the stock row of the variant the event is about, only when the event has a variantId): id, variantId, trackingMode, total, reserved, unavailable, available (integers), lastInventorySyncAt, updatedAt. For a variant change, read variantInventory, not inventory.
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).
return: id, storeId, orderId, orderNumber, currency (of the order), customerId (or null for a return the merchant opened on a guest order), number (the return number, such as R-1001), status (REQUESTED, APPROVED, DECLINED, RECEIVED, COMPLETED, CANCELLED), resolution (REFUND or EXCHANGE), source (CUSTOMER or MERCHANT), reasonCode, customerNote, declineMessage (the text written for the customer, null unless declined), restockingFee (a decimal string, or null), refundId (the refund that closed a REFUND return, otherwise null), exchangeOrderRef (free text the merchant typed for an exchange, otherwise null), requestedAt, approvedAt, receivedAt, completedAt, cancelledAt (null until it happens), and items. Each of items has id, lineId (the order line it returns), productId, variantId, name, sku, quantity (asked for), receivedQuantity (what actually arrived, 0 until received), reasonCode and restock (whether the units went back on sale). The team's private notes on a return, the merchant's own reason for declining, and the id of the team member who handled it are never sent.
A few caveats:
- The sibling is best-effort, so
data.ordercan be missing on anyorder.*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 noorderkey. 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}withAuthorization: Bearer brainerce_…and theorders:readscope (SDK:client.getOrder(orderId)inapiKeymode). 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.failedfires before the Payment row exists (capture never landed), so the payload carriescheckoutId + erroronly. Nopaymentsibling. 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 apayment.failedis not followed by an order for the same checkout.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.amount— always present. The refunded amount only, in the currency named bycurrencybelow — 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 setsrefundType: "full"). For a refund taken in the payment provider's own dashboard (source: "provider"),amountis 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-reportedrefundType: "full"can still carry anamountsmaller than the order total, because it only reflects that payment's leg.currency— the currencyamountis in, when known (absent on an event queued before it existed, and on a refund no record backs: thenamountis in the order's currency,data.order.currency). A refund goes back in the currency the payment was charged in, which differs fromdata.order.currencywhen a region charged its own currency: a EUR 23.78 payment on a USD store is recorded as a USD order, and a refund of EUR 5.00 arrives asamount: 5,currency: "EUR". Never bookamountagainst the order's currency without checking this field.refundId— present when a Brainerce refund record backs the event: a refund created through Brainerce (dashboard refund orPOST /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 isnullfor 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 onorderId+refundTypeand make the handler idempotent.
- Never included in any payload:
costPriceon products (your margin) and thecoston order line items,passwordHashand login tokens on customers, rawproviderDataon payments, signed download URLs on orders, internal account ids (accountId), and the store id at the top level ofdata. A donation carries the donor's tax id only asreceiptTaxIdLast4. - 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.paidcan arrive beforeorder.created. UsecreatedAton 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 envelopeidisevt_test_followed by 24 hex characters. Use a real event to see the full shape.
Orders
| Event | When it fires | data |
|---|---|---|
order.created | A new order exists, whatever its payment status: a storefront checkout, a paid checkout, an order created from the dashboard or POST /v1/orders | orderId, order |
order.updated | The order status changed, the shipping address was edited, the order was marked as paid, or its items were edited (a line added, removed or its quantity changed). Not sent for notes, tracking numbers or custom field values | orderId, order |
order.paid | The 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 paid | orderId, order |
order.fulfilled | The 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 shipped | orderId, order |
order.cancelled | The order status became CANCELLED (dashboard, API, or the customer cancelling) | orderId, order |
order.refunded | The order was fully or partially refunded, including a refund taken in the payment provider's own dashboard | orderId, 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.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".
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
| Event | When it fires | data |
|---|---|---|
customer.created | 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 |
customer.updated | A 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 changes | customerId, customer |
customer.deleted | The customer account was deleted | customerId (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
| Event | When it fires | data |
|---|---|---|
product.created | A product is created: the dashboard, POST /v1/products, bulk create, duplicate | productId, product |
product.updated | One 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 imports | productId, product |
product.deleted | One product was deleted. Not sent by a bulk delete | productId (no product) |
Common use: sync catalog changes into a marketplace listing (Amazon, eBay), or rebuild a static product index.
Inventory
| Event | When it fires | data |
|---|---|---|
inventory.updated | Stock changed for any reason (sale, restock, manual edit, import, stock transfer, purchase order receipt), for a product or one of its variants | productId, variantId (only on a variant change), inventory, variantInventory (only with variantId), incoming |
inventory.low | Stock fell to or below the low-stock threshold, and is still above zero. At most one delivery per product, per variant and per warehouse every 24 hours. See below | productId, variantId and variantInventory (only for a variant), available, threshold, warehouseId and warehouseName (only on a warehouse alert), inventory |
Common use: trigger restock automations, alert ops in Slack when a popular SKU is running low.
A sale sends inventory.updated when the order is created, for a card payment as well as for an order you create yourself. Reserving stock for a cart or a checkout in progress sends nothing, and neither does that reservation expiring: the number only moves for your integration when an order takes the stock. An order with several lines sends one event per product, or per variant when the lines are variants.
Variants. An event about a variant carries variantId, and variantInventory holds that variant's own counters. It is the same family of fields as inventory, so available = total - reserved - unavailable holds on it too. inventory.low for a variant carries variantId and, like inventory.updated, a variantInventory block. A stock change that touches several variants of one product sends one event per variant.
incoming is on every inventory.updated, as a plain number next to the blocks, and it is not part of inventory or variantInventory. It is the units still on their way to the product (or, for a variant event, to that variant): the unreceived part of purchase orders that are ORDERED or PARTIALLY_RECEIVED, plus the unreceived and unrejected part of stock transfers that are IN_TRANSIT or PARTIALLY_RECEIVED. It is 0 when nothing is on the way. A draft purchase order or transfer, and one that is cancelled, closed or received, adds nothing. It is the same number as the Incoming column of the inventory screen.
incoming is a snapshot of the moment the event is delivered, not a live value. Units on a purchase order are not on hand and not for sale, so placing or sending a purchase order does not fire inventory.updated: a product can have 40 units on order and you will not hear about it until stock moves for some other reason, or until the units are received (the receipt fires the event, and incoming then drops by what arrived). A stock transfer is different: shipping it takes the units off the store's on-hand stock, so shipping fires inventory.updated with a lower available and the shipped units in incoming. To follow documents on their way, read them through the API instead of waiting for this event. incoming is never counted in available, so available = total - reserved - unavailable is unchanged. If the read of incoming fails, the event is still delivered with its other blocks and incoming is left out (never sent as 0), so treat a missing incoming as unknown.
The inventory block on both events is the product's current stock row: id, productId, trackingMode, total, reserved, unavailable, available, lastInventorySyncAt and updatedAt. unavailable is units on hand that the merchant holds back from sale (damaged, quality control, safety stock, other). They are counted in total and never in available, so available = total - reserved - unavailable. Read available to decide what can be sold; do not compute it yourself. The block is the product-level row. For a product with variants it exists but stays at zero (the stock lives on the variants, see variantInventory), and it is absent for a product that has no row at all. Both blocks are read when the event is delivered, so they can be newer than the change that fired the event.
inventory.low is deduplicated for 24 hours, per product, variant and warehouse
Stronger suppression than "once per crossing", and worth designing around: after one
inventory.low fires for a product (or for one variant of it, or for one warehouse), no further inventory.low is delivered for that same product, variant and warehouse 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. A second variant of the same product running low the same day sends its own 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. For a variant it names the variant in variantId.
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, when the store has no sales channels at all, the platform default of
5(theLOW_STOCK_THRESHOLDsetting of the platform).
The resolved number is echoed back as threshold in the payload; read it there rather
than reimplementing this precedence. For a product with variants, a variant's own lowStockThreshold comes first.
A warehouse alert is a separate, opt-in event. A merchant can also set a low-stock number on a warehouse, or for one product at a warehouse. When the units available at that warehouse fall to it (still above zero), inventory.low is sent with warehouseId and warehouseName, and available is that warehouse's own number, not the store-wide total. It can fire when the store-wide total is healthy. threshold is the warehouse number. A store that never sets one receives only the store-wide event, exactly as before. Units not placed in any warehouse, and a warehouse that is switched off, never send a warehouse alert.
Checkout
| Event | When it fires | data |
|---|---|---|
checkout.completed | An 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 both | orderId, order |
checkout.abandoned | A 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
| Event | When it fires | data |
|---|---|---|
payment.succeeded | A payment provider confirmed that funds were captured for a checkout | orderId only (no payment, no order) |
payment.failed | A payment provider rejected the transaction. No order exists yet | checkoutId, error (string). No payment |
payment.refunded | A 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.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 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.
| Event | When it fires | data |
|---|---|---|
donation.paid | 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 |
donation.refunded | A paid donation was refunded, in full or in part | donationId, 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.
Returns
A return is a customer sending items of a shipped order back, or the merchant opening one for them. These events say where the return itself stands. They are not order events: a return does not change the order's status, and closing a refund return refunds the order through the normal refund path, so order.refunded fires as well and carries the amount (and the refundId that data.return.refundId also names). An exchange return moves no money and fires no order.refunded.
| Event | When it fires | data |
|---|---|---|
return.requested | A return was opened, by the customer or by the merchant | returnId, orderId, number, status, resolution, return |
return.approved | A return was approved, by the merchant or automatically by the store's return rules | as above |
return.declined | A return request was declined | as above |
return.received | The items arrived and what came back was recorded line by line. Nothing is refunded yet | as above |
return.completed | The return was closed: the refund was made (refund return), or the goods were put back on the shelf (exchange) | as above |
return.cancelled | The return was cancelled, by the customer (only while it waited for approval) or by the merchant (any time before it was closed) | as above |
statusandresolutionat the top ofdataare the state when the event fired. Thereturnobject is read when the event is delivered, so it can already show a later state if the return moved on in between. Order events by the envelope'screatedAt.- A return that starts approved fires two events. One the merchant opens, or one that the store's return rules approve automatically, fires
return.requestedand thenreturn.approved, in that order, almost at once.return.requestedis not proof that the return is waiting for a decision: readstatus. - A guest cannot open a return, but the merchant can open one for a guest order. That return fires these events with
customerIdnull. - The
returnobject is best-effort, like the other resource siblings: if it cannot be loaded at dispatch time the event is still delivered with the ids andstatusonly. - Emitted by the Returns module only. There is no
return.*event for a refund that is made without a return; that isorder.refundedalone.
Stock transfers
A stock transfer is a document that moves units from one warehouse to another when the goods take time to arrive (Inventory → Stock transfers in the dashboard). Each event below is one status change of the document. Editing a draft sends nothing.
| Event | When it fires | data |
|---|---|---|
transfer.created | A transfer was saved as a draft. Nothing has moved yet | transferId, number, fromWarehouseId, toWarehouseId, status (DRAFT), lines |
transfer.shipped | The transfer was shipped: the units left the origin warehouse and are in transit | as above, status IN_TRANSIT |
transfer.received | Units were received at the destination, in whole or in parts. One event per receipt | as above, status PARTIALLY_RECEIVED or RECEIVED, and the per-receipt fields on each line |
transfer.cancelled | The transfer was canceled, as a draft or in transit | as above, status CANCELLED, and returnedQuantity on each line |
Every lines entry has id, productId and variantId (one of them is null), quantity (units shipped on the line), and the cumulative receivedQuantity and rejectedQuantity. number is the transfer's number in the dashboard. There is no product or warehouse object: resolve the ids with the API.
transfer.received carries what THIS receipt moved, so a handler can be idempotent. Each line also has receivedNow (units accepted in this receipt, including held-back ones), rejectedNow (units rejected in this receipt) and heldBackNow (the part of receivedNow accepted as unavailable: on hand but not for sale). The cumulative counters are the lines' values at the moment of this receipt, so, if none of the events was missed, the sum of every receivedNow equals the latest receivedQuantity. A transfer received in three parts sends three events (PARTIALLY_RECEIVED, PARTIALLY_RECEIVED, RECEIVED). A receipt that only rejects units still sends transfer.received (and RECEIVED when nothing is left to account for), even though no stock moved.
transfer.cancelled says what went back. For a transfer canceled in transit, returnedQuantity on each line is the units returned to the origin warehouse (what was neither received nor rejected). Units already received stay at the destination. A draft that is canceled has returnedQuantity 0 on every line.
These are not a copy of inventory.updated, and they do not replace it. inventory.updated is store-wide and has no warehouse on it. A stock transfer moves units between warehouses, so these events are the only place that says which warehouse the units left and reached. Exactly when inventory.updated also fires, per stock row:
| Transition | inventory.updated |
|---|---|
| Draft created, draft edited, draft canceled | No, nothing moved |
| Shipped | Yes, one per line: the units leave the store's on-hand stock, so available goes down and incoming up |
| Received, accepted as available | Yes, available goes up and incoming down |
| Received, accepted as unavailable | Yes, total and unavailable go up, available does not move |
| Received, rejected only | No, no stock changed |
| Canceled in transit | Yes, the returned units are back in available |
Use inventory.updated for the store-wide number and transfer.* for the document and its warehouses. Subscribing to both is normal.
- The payload is built when the status changes and is not re-read at delivery, unlike the resource siblings of other events. It shows the document as it was at that moment, so deliveries of one transfer can arrive out of order: order them by the envelope's
createdAtand use the cumulative counters, not the order of arrival. - A transfer has no money, no cost and no notes in the payload. Your private note and reference on the document are never sent.
- There is no event when a draft is edited, and no event for the instant transfer between two warehouses (the Transfer Stock button on the Warehouses page): that move has no document and sends no
transfer.*. It sends noinventory.updatedeither, because store-wide stock does not change. - Emitted by the Inventory module only. A purchase order has its own lifecycle and sends none of these.
Blog
| Event | When it fires | data |
|---|---|---|
blog.post.published | A post goes live, by manual publish, scheduled publish, or SEO Autopilot | postId, slug, title, salesChannelIds (array, empty means every channel), publishedAt (ISO 8601 or null) |
blog.post.updated | An already-published post's content changes | postId, 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.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.
Every route lives under /api/stores/{storeId}/webhook-subscriptions:
| Route | Scope | Purpose |
|---|---|---|
GET /event-catalog | read | The subscribable event types with one-line descriptions ({ events: [{ type, description }] }). The same list as this page. |
GET /health | read | Store-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}/deliveries | read | The endpoint's event log. Filter with ?outcome=all|succeeded|failed|missed. |
POST /{id}/test | write | Send a test event (evt_test_*), 10 per hour per subscription. An endpoint with custom headers or a body template needs confirmLiveCall: true. |
POST /{id}/preview | read | Render the body, destination and header names for a stored real event without sending anything. |
POST /{id}/redeliver | write | Resend 1 to 25 chosen log rows ({ "deliveryIds": [...] }). 30 calls per hour. |
POST /{id}/replay-missed | write | Resend the events recorded while the circuit was open, oldest first, at most 25 per call. 3 calls per hour. |
DELETE /{id} | write | Delete the subscription. |
Custom headers, the body template and resending are explained in Webhooks: resending events, custom headers and body template.
Merchant Integration
Receive real-time POSTs from your Brainerce store when orders, customers, inventory, or payments change, resend events that were missed, and shape the request for a third-party API.
Verifying Signatures
Copy-paste HMAC-SHA256 verification snippets in TypeScript, Python, PHP, Ruby, and Go.