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 familySibling resource keyExample fields
order.*orderorderNumber, totalAmount, items[], customer, addresses, appliedDiscounts
customer.*customeremail, firstName, lastName, totalSpent, ordersCount, preferredLocale, acceptsMarketing
product.*productsku, name, slug, basePrice, salePrice, images, attributes, avgRating, reviewCount
payment.*paymentexternalId, amount, currency, status, paymentMethod, paymentMethodDetails, refundedAmount
inventory.*inventoryproductId, trackingMode, total, reserved, available, lastInventorySyncAt
donation.*donationamount, 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.failed fires before the Payment row exists (capture never landed), so the payload carries checkoutId + error only. No payment sibling.
  • 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.
    • refundIdonly when the refund was created through Brainerce (dashboard refund or POST /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 no refundId.
    • 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 on orderId + refundType and make the handler idempotent.
  • Sensitive fields excluded: costPrice on products (your margin), passwordHash on customers, raw providerData on 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

EventStatusWhen it firesdata shape
order.createdliveA new order is placed, regardless of payment status{ orderId }
order.updatedliveOrder metadata changes (status, shipping address, items){ orderId }
order.paidliveThe 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.fulfilledliveAll items in the order marked shipped/delivered{ orderId }
order.cancelledliveOrder cancelled (by merchant via dashboard or by customer via portal){ orderId }
order.refundedliveOrder 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.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 PAIDfinancialStatus 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".

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

EventStatusWhen it firesdata shape
customer.createdliveA 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.updatedliveCustomer profile or contact details change{ customerId }
customer.deletedliveCustomer 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

EventStatusWhen it firesdata shape
product.createdliveA new product is added to the catalog{ productId }
product.updatedliveProduct attributes, variants, or pricing change{ productId }
product.deletedliveProduct is removed from the catalog{ productId }

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


Inventory

EventStatusWhen it firesdata shape
inventory.updatedliveStock level changes for any reason (sale, restock, manual). Internally aliased from the legacy inventory.changed emit.{ productId, variantId? }
inventory.lowliveStock 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}/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 the platform default, 5.

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


Checkout

EventStatusWhen it firesdata shape
checkout.completedliveCustomer completes checkout. Fires alongside order.created, so subscribe to one or the other, not both{ orderId }
checkout.abandonedliveCart 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

EventStatusWhen it firesdata shape
payment.succeededlivePayment provider confirms funds captured{ orderId }
payment.failedlivePayment provider rejects the transaction{ checkoutId, error } (no orderId yet; the order is created on success)
payment.refundedliveRefund posted to the customer{ paymentId, refundAmount, isFullRefund }

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 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.

EventStatusWhen it firesdata shape
donation.paidliveThe 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.refundedliveA 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

EventStatusWhen it firesdata shape
blog.post.publishedliveA post goes live, by manual publish, scheduled publish, or SEO Autopilot{ postId, slug, title, salesChannelIds, publishedAt }
blog.post.updatedliveAn 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.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.