MobiariDocs

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"]}'
MethodPathPurpose
GET/v1/stores/{store_id}/webhooksList webhooks.
POST/v1/stores/{store_id}/webhooksCreate. 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}/deliveriesDelivery attempts, with response status and body.
GET/v1/webhook-events?webhook_id=…Events the webhook is subscribed to.
POST/v1/webhook-eventsSubscribe: {"webhook_id", "events": [...]}.
DELETE/v1/webhook-eventsUnsubscribe: {"webhook_id", "event"}.

Common events

EventFired when
orders.createdA new order is committed (payment intent issued).
orders.paidThe payment provider confirmed the payment.
orders.fulfilled, orders.shipped, orders.deliveredFulfillment progress.
orders.cancelledThe order was cancelled.
orders.refundedA full refund cleared the provider.
orders.updatedAny other change to an order.
products.created, products.updated, products.deletedCatalog changes, including media.
inventory.low_stock, inventory.back_in_stockStock crossed a threshold.
customers.created, customers.updated, customers.deletedCustomer records.
carts.abandoned, carts.recoveredCart 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:

HeaderValue
X-Webhook-Signaturesha256=<base64 HMAC>, see below
X-Webhook-EventThe event name, same as event in the body
X-Webhook-Event-IdUUID of the event. The same on every retry: use it to dedupe.
User-AgentMobiari-Webhook/1.0
Content-Typeapplication/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 processed

Testing 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

ActionPermission
List, getwebhooks.read
Createwebhooks.write
Editwebhooks.edit
Deletewebhooks.delete

On this page