Open app

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:

HeaderDescription
webhook-idUnique id of the delivery. Use to dedupe — retries reuse the same id.
webhook-timestampUnix epoch seconds when the signature was computed. Reject deliveries skewed more than ±5 minutes from your clock.
webhook-signatureSpace-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.deleted 404s. For deletion events the resource is gone and GET /v1/products/{id} will return 404. Consumers needing pre-deletion data should maintain a local mirror by processing created / updated events — 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.

On this page