Merchant Integration
Receive real-time POSTs from your Brainerce store when orders, customers, inventory, or payments change.
This page is for merchants integrating their own systems with Brainerce, for example piping order.created into Slack, syncing customer profiles to a CRM, or kicking off fulfillment in an ERP when a payment captures.
If you're building a marketplace app and want to receive webhooks inside the app context, see Marketplace Webhooks instead.
How it works
- You register a webhook subscription in Settings → Webhooks: an HTTPS endpoint URL and the list of events you want to receive.
- Brainerce shows you a signing secret (format:
whsec_<64-hex>). You see it once, so copy it into your environment immediately. - When a matching event happens on your store, Brainerce sends a signed HTTP POST to your URL.
- Your endpoint verifies the signature, processes the event, and returns
2xx. - Non-2xx responses are retried with exponential backoff: 5 attempts total, over about 75 seconds. Ten failed attempts (roughly two consecutive failing events) open a circuit breaker. An hour later the circuit half-opens and the next matching event goes out as a probe: a
2xxcloses the circuit and delivery resumes on its own, anything else re-opens it for another hour. Events that occurred while it was open are never queued and cannot be recovered.
What you receive
Every delivery is a POST with a JSON body following the Stripe-style event envelope:
{
"id": "evt_a3f9b1c2...",
"type": "order.created",
"createdAt": "2026-05-18T14:00:00.000Z",
"data": {
"orderId": "ord_abc123..."
}
}| Field | Meaning |
|---|---|
id | Stable opaque event id, evt_ + 32 hex characters. Not a UUID, so treat it as a string. Use it for idempotency; the same id may arrive twice |
type | One of the event types in the Event Catalogue |
createdAt | ISO-8601 timestamp of when the event fired on our side |
data | Event-specific payload. Always contains the resource ID(s), and for most families the full resource alongside them |
data carries the enriched resource, not just IDs. order.*, checkout.completed,
customer.*, product.*, payment.* and inventory.* all ship the full resource as a
sibling key named after it (order, customer, product, payment, inventory), so
the follow-up GET is usually unnecessary. The original ID fields stay in place beside
it. Enrichment was additive, and integrations written against the ID-only shape keep
working. The exact fields per family, and the cases where the sibling is omitted
(deleted resources, payment.failed), are in the Event Catalogue.
HTTP request shape
POST /your-endpoint HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
X-Brainerce-Signature: a3f9b1c2d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1
X-Brainerce-Timestamp: 1747574123456
X-Brainerce-Event: order.created
X-Brainerce-Event-Id: evt_a3f9b1c2d4e5f6a7b8c9d0e1f2a3b4c5
{"id":"evt_a3f9b1c2d4e5f6a7b8c9d0e1f2a3b4c5","type":"order.created","createdAt":"2026-05-18T14:00:00.000Z","data":{"orderId":"ord_abc123"}}| Header | Purpose |
|---|---|
X-Brainerce-Signature | HMAC-SHA256 hex digest of <timestamp>.<raw-body> using your subscription secret |
X-Brainerce-Timestamp | Unix milliseconds when we signed. Reject deliveries older than ~5 minutes (replay protection) |
X-Brainerce-Event | Convenience copy of the type field, letting you route requests without parsing JSON |
X-Brainerce-Event-Id | Convenience copy of the id field: the same idempotency value, header-accessible |
The request will always have Content-Type: application/json. Timeout is 10 seconds, so if your endpoint can't respond within that, return 202 Accepted immediately and process the event in the background.
Verifying signatures
This is mandatory before trusting any payload. See Verifying Signatures for ready-to-paste snippets in TypeScript, Python, PHP, Ruby, and Go.
The core verification algorithm:
- Read the
X-Brainerce-TimestampandX-Brainerce-Signatureheaders - Reject if timestamp is more than 5 minutes old (or in the future)
- Compute
HMAC-SHA256(your_subscription_secret, "${timestamp}.${raw_body}")as hex - Compare constant-time against the signature header
- Reject any mismatch
⚠️ Always compare with a constant-time comparison (
crypto.timingSafeEqualin Node,hmac.compare_digestin Python, etc.). String equality leaks timing information that lets attackers forge signatures byte-by-byte.
Idempotency
Webhooks can be delivered more than once. Use the id field (or X-Brainerce-Event-Id header) to deduplicate on your side:
async function handleWebhook(event) {
const seen = await redis.set(`webhook:${event.id}`, '1', 'EX', 86400, 'NX');
if (!seen) return; // already processed in the last 24h — skip
// ... process event
}Duplicates arrive within the same retry window (about 75 seconds), so a 24h dedup key is generous. Note the id is evt_ followed by 32 hex characters, an opaque string and not a UUID; do not parse it or validate it as one.
Retry behaviour
Five attempts total, the initial send plus four retries, over about 75 seconds.
| Attempt | Delay from previous |
|---|---|
| 1 | (initial) |
| 2 | 5 seconds |
| 3 | 10 seconds |
| 4 | 20 seconds |
| 5 | 40 seconds |
After the fifth attempt the delivery is abandoned. The event is not queued for later and there is no way to ask for it again.
Any non-2xx response is retried, not only 5xx. A handler that answers 400, 404 or 409 gets the full five attempts and those failures count toward the breaker exactly as a timeout does. The single permanent failure is a URL the SSRF guard blocks (a private or loopback address): that one is dropped immediately without retrying. If you want a delivery dropped rather than retried, return 2xx and discard it on your side.
Circuit breaker
failureCount increments on every failed attempt, not once per event, and the circuit opens at 10. With five attempts per event that is roughly two consecutive failing events, not ten, as an earlier version of this page said. A single success anywhere in between resets the counter to zero.
Once open:
- No further events are enqueued for that subscription. They are not held, not buffered, not delivered late. They are gone.
- After one hour the circuit half-opens. The next event that matches the subscription goes out as a probe instead of being dropped. A
2xxcloses the circuit and normal delivery resumes; anything else re-opens it for another hour. Either way, nothing from the gap is replayed. - Expect a small burst, not exactly one delivery. The probe is whichever event arrives first; any others that arrive in the few seconds before its result lands go out alongside it, because the circuit is not marked open again until that first attempt actually fails. On a busy store that is a handful of deliveries, not one.
- A failed probe gets one attempt, not five. Once the probe fails the circuit re-opens immediately, so that delivery's remaining retries are dropped on arrival rather than sent. The five-attempt table above describes deliveries on a closed circuit.
- The probe is deliberately fragile.
failureCountis not reset when the circuit half-opens; it is still at 10 or higher, so a single failed probe re-opens the circuit immediately. The "ten attempts, roughly two events" arithmetic above describes the first opening only; every re-opening after that takes one failure. - Manual recovery is still the faster path: fix your endpoint, then Deactivate and Activate the subscription in the dashboard. That resets
circuitOpenandfailureCounttogether, so you neither wait out the hour nor resume on a hair-trigger breaker.
Self-healing does not make monitoring optional, because the gap is never backfilled. Poll GET /stores/{storeId}/webhook-subscriptions/{id} for circuitOpen: true and alert on it. Two bad events at 3am otherwise means an hour of lost events before anything is even attempted again.
The circuit is per subscription, not per event type or per URL. Two subscriptions pointing at the same URL each have their own breaker.
Rotating the signing secret
If your secret leaks (or you just want to rotate on a schedule):
- Dashboard → Settings → Webhooks → your subscription → Rotate Secret
- The new secret is shown once. Save it.
- The old secret stops working immediately, with no overlap window.
- Update your endpoint to use the new secret before the next event fires.
For zero-downtime rotation, accept signatures from either the old or the new secret for a brief window (1 to 5 minutes), then drop the old one.
Testing your endpoint
The fastest way to verify your handler:
- Sign up at webhook.site (or run smee.io, ngrok, or
localtunnel). - Create a webhook subscription pointing at the temporary URL.
- Trigger an event on your store (e.g. place a test order).
- Watch the request land, and confirm headers, signature, and body shape.
- Switch the subscription URL to your real handler.
Local development tip: use ngrok or Cloudflare Tunnel to forward https://*.ngrok-free.app → localhost:3000. We can't deliver to private IPs (loopback, RFC1918, link-local), because the SSRF guard rejects them.
Common pitfalls
| Symptom | Likely cause |
|---|---|
| All deliveries return 401/403 | Signature verification is failing. Most common: comparing against the JSON-stringified body instead of the raw bytes as received. |
| First delivery works, retries fail | You're computing the signature with the retry timestamp instead of the original timestamp from the headers. |
| Sporadic verification failures | Body re-serialisation. JSON middlewares often re-encode keys; the raw bytes change. Sign against the buffer your HTTP framework received verbatim. |
| Circuit opened unexpectedly | Your endpoint timed out (>10s), or answered a non-2xx it thought was harmless. Ten failed attempts is only ~2 events. Return 2xx immediately and queue the work asynchronously. |
| Deliveries stopped and haven't come back | The circuit is open. It probes itself once an hour, but a still-broken endpoint re-opens it on the first failed probe. Fix the endpoint, then Deactivate and Activate rather than waiting. Everything since it opened is lost. |
| Deliveries resumed on their own, with a gap | An hourly half-open probe succeeded. Delivery resumes from that moment; nothing from the open window is replayed. Backfill it from the API. |
| A gap in your data with no failures logged | Events that occurred while the circuit was open were never sent at all, so there is no delivery record for them. Backfill from the API. |
| Duplicate side effects | You're processing the same id twice. Add the Redis dedup snippet above. |
Sending the answer back: finishing a purchase off-platform
A whole class of integration has the same shape. The customer pays, then something outside Brainerce has to issue what they bought — a licence key from a software distributor, a booking reference, a ticket number, a warranty registration — and only then can the customer be told what it is.
The answer usually comes back within seconds, sometimes minutes, occasionally never. So it cannot ride on the order confirmation.
Do not delay the order confirmation waiting for it. That email is the receipt for the payment. Hold it and a customer who paid gets silence, and if your provider errors it never sends at all: an outage at a third party becomes a missing receipt. Send it immediately and deliver the answer separately.
The pattern
- Subscribe to
order.paidand return 2xx immediately, then queue the work. - Call the provider. Use the Brainerce order id as your reference with them, never a fresh random one — a retried job then collides with the original instead of buying a second licence.
- Write the answer onto the order as a custom field.
- Move the order to
COMPLETED, which sends the order-completed email carrying that field.
// inside your queued handler
const licence = await provider.issue({ reference: order.id });
await client.setOrderCustomFieldValues(
order.id,
{ licence_key: licence.key },
{ idempotencyKey: `licence-${order.id}` }
);
await client.updateOrder(order.id, { status: 'COMPLETED' });The merchant creates the field once in the dashboard under Orders →
settings, and adds it to their order-completed template (it renders as
orderCustomFields). After that the value reaches the customer in the store's
own branding and language, and shows on their order page too if the field is
marked public. You never build an email, and the next integration reuses the
same template.
⛔ That template step is not optional and no default template does it for you. The variable reaches every order email, but nothing prints it until the merchant adds the block. A licence key written to a field on a store that never edited its template is stored correctly and seen by nobody. Confirm the template before you call the integration done.
Why the failure case takes care of itself
If the provider errors, you never mark the order complete. No email goes out claiming something was delivered, and the merchant's list of paid but not completed orders is exactly the list of purchases needing a human. There is no extra dashboard to build and no silent half-delivered state.
The one thing to check first
Marking an order COMPLETED also commits its inventory reservation and tells
the customer the whole order is finished. That is right for a purely
digital order. If the same order also ships a physical item, do not use this
signal — you would be telling someone their parcel is done because a licence
arrived. Write the custom field, and leave the status to fulfillment.
What's next
- Event Catalogue: every event type, when it fires, what data it carries
- Verifying Signatures: copy-paste snippets in 5 languages
- Marketplace Webhooks: a different flow for marketplace apps