Webhooks
Subscribe to events emitted by Mercel and receive them at your endpoint, signed and delivered with retries.
Webhook endpoints are HTTPS URLs you register against a store. When something happens in that store — a product is created, an order moves to a new state, etc. — Mercel POSTs a JSON payload to every endpoint subscribed to that event type, signed per the Standard Webhooks spec.
For the full request/response shape of every endpoint mentioned below, see the API reference: webhook endpoints, webhook deliveries, and events.
Subscribing
Register an endpoint with POST /v1/webhooks/endpoints (or via Settings → Developer → Webhooks in the admin):
curl https://api.mercel.app/v1/webhooks/endpoints \
-H "Authorization: Bearer mercel_sk_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/mercel",
"subscribedEvents": ["product.created", "product.updated"]
}'The response includes a secret field — the raw signing secret. Save it now. Mercel retains a hash only; you cannot retrieve the raw secret again.
subscribedEvents must match entries in the published event catalog returned by GET /v1/webhooks/endpoints/event-types. Subscribe to specific types (product.created) or to a prefix (product.*).
Delivery format
Every request is a POST with Content-Type: application/json and a small set of webhook-* headers from the Standard Webhooks spec:
| Header | Description |
|---|---|
webhook-id | Unique id of the delivery. Use to dedupe — retries reuse the same id. |
webhook-timestamp | Unix epoch seconds when the signature was computed. Reject deliveries skewed more than ±5 minutes from your clock. |
webhook-signature | Space-separated list of v1,<base64-hmac> signatures. Multiple signatures appear during secret rotation. |
Mercel webhook bodies are thin — they carry an identifier reference (subject) for the resource the event is about, never an embedded snapshot of resource fields:
{
"id": "evt_01HJZQK7XJ8R9YGEWABCDEFGHJK",
"type": "product.created",
"created": "2026-05-22T15:42:11.012Z",
"subject": {
"id": "prd_01HJZQK7XJ8R9YGEWABCDEFGHJK",
"type": "product",
"url": "/v1/products/prd_01HJZQK7XJ8R9YGEWABCDEFGHJK"
}
}This shape is the same for product.created, product.updated, and product.deleted — every webhook event Mercel emits. To act on the event, follow subject.url and call the resource's retrieve endpoint (GET /v1/products/{id} etc.).
Hydrating events
After verifying a webhook signature, fetch the affected resource's current state via the path in subject.url:
import { Mercel } from "@mercel/sdk";
const mercel = new Mercel({ auth: process.env.MERCEL_API_KEY });
async function handle(event: WebhookEvent) {
if (event.subject.type === "product") {
const product = await mercel.products.getProduct({ path: { productId: event.subject.id } });
// ...do something with the live product state
}
}Two things to keep in mind:
- The resource you fetch reflects current state, not state at event-time. If multiple updates happen between the event firing and your handler running, you'll see the latest version. This is usually what you want.
product.deleted404s. For deletion events the resource is gone andGET /v1/products/{id}will return 404. Consumers needing pre-deletion data should maintain a local mirror by processingcreated/updatedevents — Mercel does not preserve pre-deletion snapshots server-side.
Retrieving events directly
The same envelope you receive on a webhook can also be retrieved through the API. This is useful for re-fetching, debugging, or building event-driven dashboards without setting up a public endpoint:
# Retrieve a single event by id
curl https://api.mercel.app/v1/events/evt_01HJZQK7XJ8R9YGEWABCDEFGHJK \
-H "Authorization: Bearer mercel_sk_…"
# List recent events in the bound store, optionally filtered by type or subject
curl "https://api.mercel.app/v1/events?type=product.created" \
-H "Authorization: Bearer mercel_sk_…"
curl "https://api.mercel.app/v1/events?subjectType=product&subjectId=prd_01HJZQK7XJ8R9YGEWABCDEFGHJK" \
-H "Authorization: Bearer mercel_sk_…"The response is byte-identical to the body that was POSTed to your webhook endpoint for the same event id — same id, type, created, subject. No additional fields are returned. See retrieve an event and list all events for the full parameter set.
Verifying signatures
The signature is HMAC-SHA256(secret, "<webhook-id>.<webhook-timestamp>.<body>"), base64-encoded, prefixed with the scheme version v1,. Verify it before trusting the payload:
import crypto from "node:crypto";
function verify(req: { headers: Headers; rawBody: string }, secret: string) {
const id = req.headers.get("webhook-id");
const timestamp = req.headers.get("webhook-timestamp");
const signatures = req.headers.get("webhook-signature");
if (!id || !timestamp || !signatures) throw new Error("Missing signature headers");
const skewSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (skewSeconds > 300) throw new Error("Timestamp skewed too far");
const keyBytes = Buffer.from(secret.replace(/^whsec_/, ""), "base64url");
const expected = crypto
.createHmac("sha256", keyBytes)
.update(`${id}.${timestamp}.${req.rawBody}`)
.digest("base64");
const ok = signatures
.split(" ")
.map((s) => s.split(",")[1])
.some((sig) => sig && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
if (!ok) throw new Error("Bad signature");
}Verify against the raw request body bytes — re-serializing parsed JSON may change byte ordering and break the signature. Any Standard-Webhooks-compatible library (e.g. Svix's verify()) works as a drop-in alternative.
Retries
If your endpoint returns a non-2xx response (or fails to respond within 10 seconds), Mercel retries on a Stripe-style schedule across 11 total attempts over ~3 days: 5 min, 30 min, 2 hr, 5 hr, 10 hr, then five 12-hour backoffs. The delivery row tracks every attempt; once the schedule is exhausted, the delivery is marked failed.
You can pause an endpoint at any time via PATCH /v1/webhooks/endpoints/{endpointId} with { "active": false } — this stops new deliveries without losing the endpoint or its history.
Auto-disable
To stop firing at a permanently broken receiver, Mercel auto-disables endpoints after 5 consecutive failed deliveries (each delivery already represents ~11 HTTP attempts over ~3 days, so the trigger means the endpoint has been broken for roughly two weeks of cumulative event-days). Disabled endpoints stay in your list and continue to receive new events into their delivery history as disabled rows, but no HTTP requests are made.
When an endpoint auto-disables, Mercel emits a webhook.endpoint.auto_disabled event and notifies every user in the store with store:developer:read. Re-enable the endpoint from Settings → Developer → Webhooks in the admin once the underlying issue is fixed.
Replaying a delivery
Every attempt is logged. Inspect a single delivery (request and response bodies, headers, timing) via GET /v1/webhooks/deliveries/{deliveryId}. To resend the original payload, call POST /v1/webhooks/deliveries/{deliveryId}/replay — the endpoint must still be active. Replays write a fresh delivery row; the original is left untouched.
Testing your endpoint
Send a synthetic webhook.ping event without waiting for a real one:
curl -X POST https://api.mercel.app/v1/webhooks/endpoints/{endpointId}/send-test-event \
-H "Authorization: Bearer mercel_sk_…"The response includes the deliveryId so you can tail the outcome via list webhook deliveries. The endpoint must be active.
webhook.ping carries the envelope's id, type, and created fields only — there is no subject because pings are synthetic and don't correspond to a real resource event. Real events always carry subject.
Rotating secrets
POST /v1/webhooks/endpoints/{endpointId}/rotate-secret issues a fresh signing secret. The previous secret remains valid for 24 hours so receivers can roll over without missing deliveries — during the overlap window, Mercel signs each delivery with both secrets and the webhook-signature header carries both values.