Webhooks
Receive a POST every time something interesting happens in your store.
A webhook is a URL you register that Mobiari POSTs to when an event fires. Use them to keep external systems (ERPs, AI agents, accounting, Slack) in sync without polling.
Setup
Create the webhook from the admin under Webhooks, or with the API. First the endpoint:
curl -X POST "https://api.mobiari.com/v1/stores/$STORE_ID/webhooks" \
-H "Authorization: Bearer $BP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/mobiari/webhook",
"secret": "a-long-random-string-that-stays-on-your-server"
}'The response is the webhook, including its id and secret. Leave
secret out and one is generated for you. You'll use it to
verify signatures.
Then subscribe it to events:
curl -X POST "https://api.mobiari.com/v1/webhook-events" \
-H "Authorization: Bearer $BP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"webhook_id": "'"$WEBHOOK_ID"'", "events": ["orders.created", "orders.paid"]}'| Method | Path | Purpose |
|---|---|---|
GET | /v1/stores/{store_id}/webhooks | List webhooks. |
POST | /v1/stores/{store_id}/webhooks | Create. Body: url, optional secret, is_active. |
PUT | /v1/stores/{store_id}/webhooks/{webhook_id} | Change url, secret or is_active. |
DELETE | /v1/stores/{store_id}/webhooks/{webhook_id} | Delete. |
GET | /v1/stores/{store_id}/webhooks/{webhook_id}/deliveries | Delivery attempts, with response status and body. |
GET | /v1/webhook-events?webhook_id=… | Events the webhook is subscribed to. |
POST | /v1/webhook-events | Subscribe: {"webhook_id", "events": [...]}. |
DELETE | /v1/webhook-events | Unsubscribe: {"webhook_id", "event"}. |
Common events
| Event | Fired when |
|---|---|
orders.created | A new order is committed (payment intent issued). |
orders.paid | The payment provider confirmed the payment. |
orders.fulfilled, orders.shipped, orders.delivered | Fulfillment progress. |
orders.cancelled | The order was cancelled. |
orders.refunded | A full refund cleared the provider. |
orders.updated | Any other change to an order. |
products.created, products.updated, products.deleted | Catalog changes, including media. |
inventory.low_stock, inventory.back_in_stock | Stock crossed a threshold. |
customers.created, customers.updated, customers.deleted | Customer records. |
carts.abandoned, carts.recovered | Cart recovery. |
The full list is the event catalog in the backend
(backend/crates/bus/src/events/catalog.rs). New events are added over
time; your handler should acknowledge events it doesn't handle.
Delivery shape
Every delivery is a POST with the same envelope:
{
"event": "orders.paid",
"store_id": "0199a4f2-6c1e-7d3a-9b1f-3e2c8a5d7f10",
"timestamp": 1790860251,
"data": {
"id": "…",
"status": "paid",
"...": "event-specific payload"
}
}timestamp is when the event happened, in Unix seconds. Money inside
data is in integer cents, like everywhere else in the API.
Headers on every delivery:
| Header | Value |
|---|---|
X-Webhook-Signature | sha256=<base64 HMAC>, see below |
X-Webhook-Event | The event name, same as event in the body |
X-Webhook-Event-Id | UUID of the event. The same on every retry: use it to dedupe. |
User-Agent | Mobiari-Webhook/1.0 |
Content-Type | application/json |
Respond with any 2xx within 30 seconds. Anything else is a failure and is retried.
Verifying signatures
Every delivery carries an X-Webhook-Signature header:
X-Webhook-Signature: sha256=<base64>The signature is HMAC-SHA256(secret, raw_request_body) encoded as
base64 (standard alphabet, with = padding). Compute the HMAC over
the raw bytes, not the parsed JSON — re-serializing would change
whitespace and break the comparison.
import crypto from 'node:crypto'
export function verify(req, secret) {
const header = req.headers['x-webhook-signature'] ?? ''
const sig = header.replace(/^sha256=/, '')
const expected = crypto
.createHmac('sha256', secret)
.update(req.rawBody) // raw bytes — see your framework's docs
.digest('base64')
// Constant-time compare; both buffers must be the same length first.
const a = Buffer.from(sig)
const b = Buffer.from(expected)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Always compare with a constant-time function. A naive === leaks
information about the secret to a timing attacker.
Retries
Failed deliveries (non-2xx, timeout, connection error) are retried from a durable queue with exponential backoff: roughly 10 seconds, then 20, 40 and 80, for up to 5 attempts. Retries survive our deploys. After the last attempt the delivery is marked failed and not retried automatically.
Every attempt is recorded with its response status and body. See them
in the admin under Webhooks → Deliveries, or with
GET /v1/stores/{store_id}/webhooks/{webhook_id}/deliveries.
Make your handlers fast
The whole retry schedule spans about two and a half minutes, so an endpoint that is down for longer loses deliveries. Acknowledge the delivery (return 2xx) as soon as you've validated the signature and persisted the payload, then process asynchronously.
Idempotency
The same event can reach you more than once: a retry after your handler
timed out, for example. Dedupe on X-Webhook-Event-Id, which is the same
on every attempt for an event. The body is identical across retries too.
key = f"mobiari:webhook:{request.headers['X-Webhook-Event-Id']}"
if not redis.set(key, 1, nx=True, ex=86400):
return ack() # already processedTesting locally
Spin up a tunnel (cloudflared, ngrok) pointing at your dev server,
register the public URL as a webhook against a development store, and
trigger an event from the admin. The Webhooks → Deliveries tab
shows everything that was attempted, including failures, with response
bodies — useful when your handler is returning the wrong status.
Permission cheatsheet
| Action | Permission |
|---|---|
| List, get | webhooks.read |
| Create | webhooks.write |
| Edit | webhooks.edit |
| Delete | webhooks.delete |