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.
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. After the fifth failed attempt the event is not queued again by itself: it shows as Failed in the endpoint's event log and you resend it from there (see Resending events).
- Ten failed attempts (roughly two consecutive failing events) open a circuit breaker (the dashboard shows the endpoint as Stopped automatically). While it is open, events are not sent, but they are no longer lost: each one is recorded as Not sent in the event log (up to 500 per endpoint, kept for 7 days). 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. Once your endpoint is fixed you resend the Not sent events yourself. There is still no automatic replay.
Two optional settings change what is sent, without changing how it is signed: custom headers (for an API key or a token the destination expects) and a body template (to reshape the JSON into what a third-party API expects). Both are configured in the dashboard or through the API.
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.*, inventory.*, donation.* and payment.refunded ship the
full resource as a sibling key named after it (order, customer, product, inventory,
donation, payment), so the follow-up GET is usually unnecessary. The original ID
fields stay in place beside it. Not every event has one: payment.succeeded carries
only orderId, payment.failed only checkoutId and error, checkout.abandoned and
the blog.post.* events are flat, and a deleted customer or product has no sibling. The
exact fields per event, with types and money units, are in the Event Catalogue.
The sibling is best-effort: data.order can be missing. If loading the order fails
at dispatch time, the event is still delivered with the ID alone ({ "orderId": "..." },
no order key). Handle it by fetching the order, never by dropping the event:
GET /api/v1/orders/{orderId} with Authorization: Bearer brainerce_… and the
orders:read scope (SDK: client.getOrder(orderId)). The same holds for every family's
sibling.
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 |
X-Brainerce-Redelivery | true only on an event that was resent by hand or from the Not sent list. Absent on a first delivery |
On a resend the event keeps its original X-Brainerce-Event-Id, so a receiver that dedupes on it recognises the event. Any custom headers you set on the endpoint are sent in addition to these.
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.
What counts as success: any 2xx status. Everything else is a failure: 3xx (redirects are not followed, so point the endpoint at its final URL), 4xx, 5xx, a timeout, a connection or TLS error. There is no special handling of 410 Gone or any other code: it is retried like the rest, and Brainerce never deactivates or deletes an endpoint on its own; the only automatic stop is the circuit breaker below. Delivery order is not guaranteed (see Event Catalogue).
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 of this request. Each attempt, retries included, is signed afresh with a new timestamp - 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, and your own processing can fail halfway. Handle both with three rules:
- Store the event durably before you return
2xx. Insert it into an inbox table keyed by the eventid(orX-Brainerce-Event-Id), ignoring a duplicate insert. If the insert fails, return5xxso Brainerce retries. Once you return2xx, Brainerce does not send that event again on its own (only a person resending it from the event log can): from then on your inbox is the only copy. - Mark it done only after the work succeeds. A worker reads pending rows, does the work, and only then sets the row to done. If the work fails (your ERP is down, a timeout), the row stays pending and your worker retries it later. Brainerce's own retries stop after about 75 seconds, so retrying a slow downstream system is your worker's job.
- Make the side effect idempotent on the business key, not only on the event id.
order.createdandorder.paidare two different events for the same order, so event-id dedup does not stop an order being created twice in your ERP. Store the BrainerceorderIdon the ERP document as its external reference and create the document only when no document carries that reference yet, enforced by a unique constraint or a lock so two workers cannot both create it.
// 1. HTTP handler: verify, store, acknowledge
app.post('/webhooks/brainerce', express.raw({ type: 'application/json' }), async (req, res) => {
const event = verifyAndParse(req); // signature check; respond 401 on failure
try {
await db.query(
`INSERT INTO webhook_inbox (event_id, type, payload, status)
VALUES ($1, $2, $3, 'pending')
ON CONFLICT (event_id) DO NOTHING`,
[event.id, event.type, event]
);
} catch {
return res.status(500).end(); // not stored: let Brainerce retry
}
res.status(202).end();
});
// 2. Worker: process, then mark done. A throw leaves the row pending for the next run.
async function processInboxRow(row) {
const orderId = row.payload.data.orderId;
// data.order is best-effort; fetch it when it is missing
const order = row.payload.data.order ?? (await brainerce.getOrder(orderId));
// 3. Creates at most one ERP order per Brainerce orderId
await erp.createOrderIfAbsent({ externalRef: orderId, order });
await db.query(`UPDATE webhook_inbox SET status = 'done' WHERE event_id = $1`, [row.event_id]);
}A resent event carries the same event id as the original. If your inbox already holds it, the insert is ignored and nothing is processed twice, which is what you want for events you did receive. If it never arrived, it is new to your inbox and is processed normally. That protects your own inbox. It does not protect a third party's API that you call through a body template: that API never sees the event id, so put a business key in the body (for example the order id as ref) and let the destination refuse a duplicate on it.
Do not mark the event as seen before processing it (for example a Redis SET NX and then the work). If the work fails after the key is set, Brainerce's retry is skipped as a duplicate and the order never reaches your system.
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 Brainerce stops retrying. The event is not queued again by itself: it stays in the endpoint's event log as Failed, and you resend it from there when your endpoint is back (see Resending events). A resend of a failed event is tried once, not five times.
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. Two failures are permanent and are not retried: a URL the SSRF guard blocks (the hostname resolves to a private or loopback address), and a body template that cannot be rendered (the delivery is logged as Failed with "Payload template error" and does not count toward the breaker). Retries of an event in flight use the URL as it was when the event was queued, but the current custom headers and signing secret, so a retry queued before you rotate the secret is signed with the new one. 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. A single success anywhere in between resets the counter to zero. A test event is a normal delivery (five attempts) and counts too.
Once open:
- Events are not sent, but each one is recorded as Not sent. It is listed in the endpoint's event log with its full payload, up to 500 per endpoint, kept for 7 days. Past 500, further events are dropped and not recorded. A Not sent event is never delivered by itself: you resend it once the endpoint works (see Resending events).
- After one hour the circuit half-opens. The next event that matches the subscription goes out as a probe instead of being recorded as Not sent. A
2xxcloses the circuit and normal delivery resumes; anything else re-opens it for another hour. Either way, nothing from the gap is replayed automatically: the Not sent events wait in the log for you. - 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. - A failed single resend restarts the hour. Resending one event while the circuit is open is allowed, and if it succeeds it closes the circuit. If it fails, the circuit is marked open again from that moment, so the one-hour wait starts over.
- 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. The events from the gap are kept for 7 days, but nothing sends them until you do, and a Not sent event older than 7 days is gone. Poll GET /stores/{storeId}/webhook-subscriptions/{id} for circuitOpen: true and alert on it, and watch missedCount on the same row (it is also on each row of the list): the number of Not sent events waiting. Two bad events at 3am otherwise means an hour with nothing sent, then a backlog nobody resends.
The circuit is per subscription, not per event type or per URL. Two subscriptions pointing at the same URL each have their own breaker.
Resending events
Nothing resends an event for you. There is no automatic replay: not for a Failed event after its five attempts, and not for a Not sent event recorded while the circuit was open. You resend them yourself, from the endpoint's Event log in the dashboard or through the API.
The 7-day limit applies to Not sent events. The missedCount on the endpoint, the dashboard badge and replay-missed only look at Not sent events recorded in the last 7 days, and Not sent events are capped at 500 per endpoint. The event log itself is not pruned (rows stay until you delete the endpoint), and ?outcome=missed and counts.missed list every Not sent row regardless of age, so those numbers can be larger than missedCount. POST .../redeliver on a chosen row has no age limit.
The event log lists every delivery with a state: succeeded, failed, missed (shown as Not sent), resent (a Not sent event that has since been resent) or pending. GET /api/stores/{storeId}/webhook-subscriptions/{id}/deliveries?outcome=all|succeeded|failed|missed filters it and returns counts: { all, succeeded, failed, missed }. Each row carries state and renderedPayload (the body exactly as sent, after any template).
Resend everything that was Not sent
POST /api/stores/{storeId}/webhook-subscriptions/{id}/replay-missed- Sends the Not sent events oldest first, at most 25 per call, 500 ms apart. The response is
{ enqueued, eventIds, remaining }: call again untilremainingis0. - The circuit must be closed and the endpoint active. Fix the endpoint, then Deactivate and Activate it (or wait for a half-open probe to succeed) before you call it.
- Limited to 3 calls per hour per endpoint.
Resend chosen events from the log
POST /api/stores/{storeId}/webhook-subscriptions/{id}/redeliver
{ "deliveryIds": ["..."] }- Takes 1 to 25 delivery ids from the log. It works on Not sent and on Failed rows.
- One id bypasses an open circuit (a deliberate single resend to a stopped endpoint). Several ids respect it.
- Limited to 30 calls per hour per endpoint.
What a resent event looks like
- It keeps its original event id, in the envelope
idand inX-Brainerce-Event-Id, so a receiver that dedupes on the id recognises it. - It carries the header
X-Brainerce-Redelivery: true. - It is signed afresh, with a new timestamp, and uses the endpoint's current custom headers and body template, not the ones in force when the event first happened.
- It is tried once, with no retries. If it fails, it shows as Failed in the log and you can resend it again.
- Test events (
evt_test_*) cannot be resent.
Make resends safe: dedupe on a business key
A resend can reach a destination that already handled the original (your endpoint answered slowly, then failed). The event id protects your own inbox. It does not protect a third party: an API you call through a body template never sees the event id. Put the order id inside the template (for example as ref) and have the destination, or your own code, refuse a second request with the same ref.
Custom headers
Some destinations want an API key or a token in a header. Set headers on create or update (dashboard: the endpoint's Settings tab, Custom headers):
PATCH /api/stores/{storeId}/webhook-subscriptions/{id}
{ "headers": { "X-Api-Key": "key_live_abc123", "X-Old-Header": null } }- A map of header name to value, at most 10 headers. Names use letters, digits and hyphens. Values are printable ASCII, up to 2048 characters.
- They are sent with every delivery, including resends.
- Values are stored encrypted and never returned by any API or tool. Reads give you only
headerNames, the list of names. To change a value, send the name again with the new value. - An update merges: a name you send replaces its value,
nullremoves that name, and a name you leave out keeps its stored value. - You cannot set
Host,Content-Length,Content-Type,Transfer-Encoding,Connection,Upgrade,TE,Trailer,Expect,Cookie,Set-Cookie,Keep-Alive,Accept-Encoding, anyProxy-*header, or anyX-Brainerce-*header. The request is alwaysContent-Type: application/json, and theX-Brainerce-*headers stay ours. - Dashboard and API only. The admin MCP tools and the in-dashboard AI assistant cannot set or read header values, the same rule as the signing secret.
Body template
By default the body is the event envelope. A destination that expects a different JSON shape (a voucher or licence API, a CRM) can be given one with payloadTemplate on create or update (null clears it; dashboard: the Settings tab, Custom body).
A template is a JSON object. String values may contain {{path}} placeholders, resolved against the event envelope { id, type, createdAt, data }:
PATCH /api/stores/{storeId}/webhook-subscriptions/{id}
{
"payloadTemplate": {
"amount": "{{data.order.totalAmount}}",
"currency": "{{data.order.currency}}",
"ref": "{{data.order.id}}",
"productRef": "{{data.order.items.0.sku}}",
"note": "Order {{data.order.orderNumber}} from the web shop"
}
}For an order.paid event this sends:
{
"amount": 142.5,
"currency": "USD",
"ref": "ord_abc123",
"productRef": "MUG-BLUE",
"note": "Order 1042 from the web shop"
}The rules:
- A value that is exactly one placeholder keeps its type.
"{{data.order.totalAmount}}"becomes the number142.5, not the string"142.5"; a placeholder that points at an object or an array yields that object or array. A path that does not exist becomesnull. - A placeholder inside longer text is interpolated as text.
"Order {{data.order.orderNumber}} from the web shop"becomes a string; a missing path becomes an empty string. - Paths are dots. Numeric segments index an array:
data.order.items.0.skuis the first line item. - Up to 16 KB and 8 levels deep.
- No loops, conditions, helpers or expressions. A template is a lookup, not a program.
- The request is still a
POSTwith JSON, signed with the same HMAC over the actual body that is sent, so verify against the raw bytes as you do today. Check the exact paths against a real event:data.orderis best-effort and the fields differ per event family.
Put the order id in the body (ref above). It is what lets the destination refuse a duplicate when an event is resent.
Preview a template without sending anything
POST /api/stores/{storeId}/webhook-subscriptions/{id}/preview
{ "template": { ... }, "deliveryId": "..." }Both fields are optional. template is the template to try, so you can check an edit before saving it (null shows the plain envelope; omit it to use the one saved on the endpoint). deliveryId picks the stored event to render against (omit it for the most recent real event, test events are skipped). The response is the rendered body, the destination URL and the header names, plus usesTemplate and missing, the list of placeholder paths that found nothing in that event. Nothing is sent. If the endpoint has no stored real event yet, the call is a 400 ("No stored event to preview against yet"): wait for one real event, or pass the deliveryId of an existing row.
What a body template does not do
- POST with JSON only. No other method, no form encoding, no XML.
- One static URL. No placeholders in the URL, so you cannot route by order or product.
- No fan-out. An order with 3 units is one request, not three. A destination that needs one call per voucher, per unit or per line item needs your own relay in between.
- The rendered body is capped at 1 MB.
- Brainerce does not read the response and writes nothing back to the order. A licence key or voucher code the destination returns is not stored anywhere. See Sending the answer back.
- No fixed outbound IP. A destination that only accepts calls from whitelisted IP addresses (a distributor such as Stellr-net, for example) cannot be called from Brainerce directly. Use your own server with a registered IP as the relay.
The response body is not stored for these endpoints
For an endpoint that has custom headers or a body template, Brainerce keeps the status code and the response time of each delivery but not the response body, because a third-party response can contain codes or keys that should not sit in a log. Endpoints with neither keep the first 10 KB of the response as before.
Test events on endpoints with headers or a template
POST /api/stores/{storeId}/webhook-subscriptions/{id}/test with { "eventType": "order.created" } is a 400 for an endpoint with custom headers or a body template, unless you also send "confirmLiveCall": true. A third-party API cannot tell a test from a real event and may act on it (issue a voucher, charge an account). Use preview to see the body without sending, and send a live test only when you mean it.
Rotating the signing secret
If your secret leaks (or you just want to rotate on a schedule):
- Dashboard → Settings → Webhooks → the endpoint's More actions menu (or its Settings, Security section) → Rotate signing secret, then confirm
- 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.
Send test event (dashboard: the endpoint's Settings, Security; API: POST .../{id}/test with { "eventType": "order.created" }) queues a synthetic event with the ID fields of that type plus "_test": true inside data, and an envelope id that starts with evt_test_. It is signed and delivered like a real event (five attempts, counted by the circuit breaker). The call is refused with 400 unless the endpoint is active, its circuit is closed, and the event type is in its list; it is limited to 10 per hour per endpoint (then 429). A test event has no resource sibling (no order, customer and so on) and cannot be resent.
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, carrier-grade NAT, multicast), because the SSRF guard rejects them. The URL must be https:// and at most 2048 characters. It is checked twice: when you save it, a literal private or loopback address, localhost, a hostname ending in .local, .internal, .localhost, .lan, .intranet, .corp, .home or .private, and a URL with a username or password are refused; at delivery time the address the hostname actually resolves to is checked again. Any port is allowed.
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 | Every attempt is signed afresh: a retry carries a new X-Brainerce-Timestamp and a new X-Brainerce-Signature. Verify each request against its own headers and its own raw body. Do not cache a timestamp or signature from an earlier attempt. |
| 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. The events since it opened are listed as Not sent in the event log, kept for 7 days: resend them once the endpoint works. |
| 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 automatically. Open the event log, filter Not sent, and resend them. |
| A gap in your data, rows marked Not sent | Events recorded while the circuit was open. Resend them with POST .../replay-missed (25 per call, circuit closed) or from the event log. Past 7 days, or past 500 per endpoint, they were not kept: backfill those from the API. |
| A failed event never came back | After five failed attempts Brainerce does not queue it again. It is Failed in the event log: resend it from there (POST .../redeliver). |
| A resend was processed twice downstream | The event id is unchanged on a resend, so your own inbox ignores it, but a third-party API behind a body template never sees it. Put the order id in the template (for example as ref) and dedupe on it there. |
400 when sending a test event | The endpoint has custom headers or a body template, so a test is a real call. Use preview, or repeat with confirmLiveCall: true. |
| No response body in the delivery log | Deliberate on an endpoint with custom headers or a body template: the status code and time are kept, the body is not. |
| Duplicate side effects | The same event was processed twice, or two events (order.created and order.paid) each created the order. Dedup the inbox on id and create downstream records once per orderId, as in Idempotency. |
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.
Can Brainerce call the provider for you? If the licence or voucher API is reachable from the internet and authenticates with an API key, custom headers plus a body template let a webhook call it directly, so you do not need your own relay for the call. The answer still cannot be written back automatically: Brainerce does not read the response and writes nothing to the order. Getting the key onto the order and to the customer remains the pattern below. A provider that whitelists source IPs needs your own server regardless, because Brainerce has no fixed outbound IP.
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 (Orders, the table's
⋮ menu, Order Settings, Custom Fields), 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. The email is the only way it reaches them today: the
storefront does not return custom field values on the customer's order page, and
the field's isPublic flag is stored but not read by any endpoint. 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
Google cart transfer (UCP)
How Brainerce accepts a shopper's cart from Google's Universal Cart. What a sales channel exposes, the profile your storefront must serve at /.well-known/ucp, the keys, the request and response shapes, the limits, and which behaviours are still unconfirmed with Google.
Event Catalogue
Every webhook event type Brainerce can deliver, when it fires, and what its payload contains.