Webhooks

This guide is for a developer connecting Meisei to their own systems. A webhook is a URL of yours that Meisei posts to when something happens, so you hear about a published review or a redeemed reward without polling for it. Every event is also kept for 30 days in an event log you can read on your own schedule. See Reading the event log.

Registering an endpoint

Settings, Webhooks, listing two endpoints, one active and one paused

The Create webhook dialog with a URL, a signing secret and two events ticked

Add one under Settings → Webhooks, or over the API:

curl -X POST "https://your-store.meisei.app/api/webhooks" \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/meisei",
    "events": ["review.created", "loyaltyReward.redeemed"],
    "hmacSecret": "a-long-random-string-you-generate"
  }'

The URL has to be a public https:// address. An address on a private network, an IP address, or a host of Meisei's own is refused when you save it. Meisei doesn't follow redirects either. A delivery that gets a 3xx answer counts as failed, so register the URL your endpoint answers on. Subscribe to the events you handle. On an update, the list you send replaces the whole subscription, so send the full list every time.

hmacSecret is a shared secret you choose. Meisei never returns it again, so store it alongside your own configuration. Leave it out and deliveries are unsigned, which means your endpoint has no way to tell a real delivery from anyone who guesses the URL.

Editing an endpoint under Settings → Webhooks leaves the secret alone unless you say otherwise. The secret field is blank when the dialog opens, and a blank field keeps whatever is stored. To stop signing, tick "Stop signing this endpoint and remove the secret." Over the API the same two choices are the difference between omitting hmacSecret and sending it as null.

What a delivery looks like

Every delivery is a POST with the same envelope:

{
  "id": "Xb3kQ9Lm",
  "event": "review.created",
  "timestamp": "2026-01-15T09:30:00Z",
  "idempotencyKey": "9f2c9a56-6f6a-4b16-9b0e-9f2e1d6b7c31",
  "data": {
    "id": "k3Fq8Zb2",
    "rating": 5,
    "published": false
  }
}

id is the event's own ID, the same one the event log lists it under. data is the record the event is about, in the same shape the API returns it, so every ID in it is the same short ID the API uses and can be sent straight back to a GET. Answer with any 2xx status. Anything else, or no answer within 15 seconds, is a failed attempt, and Meisei tries again (see Duplicates and retries).

What changed in an update

Every updated delivery also carries previousAttributes: each field of data that has changed since the last event about the same record, at the value that event carried. Fields that stayed the same are left out.

{
  "id": "Lm4Qp7Rt",
  "event": "productVariant.updated",
  "timestamp": "2026-01-15T09:30:00Z",
  "idempotencyKey": "1c0e5b7a-3d2f-4a8e-9b61-7f4d2c8e0a15",
  "data": { "id": "p8Zq2Kd1", "productId": "Xb3k9Wq2", "priceCents": 1500, "available": true },
  "previousAttributes": { "priceCents": 2000, "available": false }
}

That is enough to tell a price drop from a restock without keeping your own copy of the catalog. A field that is an object, such as metadata, is compared as a whole, so previousAttributes.metadata holds the entire old object. Customers, orders and products also carry tags, the names of their tags in alphabetical order, so a tag change arrives as previousAttributes.tags with the old list.

"Since the last event" matters in one case. A change Meisei makes without sending an event, such as a CSV import job, shows up in the next event about that record instead. So if you keep your own copy of a record, applying each delivery's data keeps it right.

The first updated event about a record with no earlier event has no previousAttributes, since there is nothing to compare it with. Other kinds of event never carry it. The event log returns the same field, as null where there is none.

Answer quickly, and do your own work afterwards. Meisei waits for your response, so an endpoint that takes ten seconds to process the payload takes ten seconds to acknowledge it.

Verifying the signature

When a secret is set, deliveries carry an X-Webhook-Signature header:

X-Webhook-Signature: sha256=3fd4a1c8b0e5...

It is the HMAC-SHA256 of the raw request body, keyed with your secret, hex encoded, prefixed with sha256=.

Verify it before you parse the body. Re-serializing the JSON changes the bytes, and the digest never matches again. This is the most common mistake in a webhook receiver.

Web Crypto
const raw = await req.text()
const key = await crypto.subtle.importKey(
  "raw",
  new TextEncoder().encode(secret),
  { name: "HMAC", hash: "SHA-256" },
  false,
  ["sign"]
)
const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(raw))
const expected = `sha256=${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`

if (req.headers.get("X-Webhook-Signature") !== expected) {
  return new Response("Invalid signature", { status: 401 })
}

const body = JSON.parse(raw)
Node
import { createHmac, timingSafeEqual } from "node:crypto"

// express.raw({ type: "application/json" }) keeps req.body as a Buffer
const expected = `sha256=${createHmac("sha256", secret).update(req.body).digest("hex")}`
const received = req.get("X-Webhook-Signature") ?? ""

const ok =
  received.length === expected.length &&
  timingSafeEqual(Buffer.from(received), Buffer.from(expected))
if (!ok) return res.status(401).send("Invalid signature")

const body = JSON.parse(req.body.toString("utf8"))

Compare in constant time, as above, rather than with ===.

Duplicates and retries

Every delivery carries an idempotencyKey. The same key is never delivered twice to the same webhook, but you can still end up processing the same work twice. That happens if your answer never reached us, or if your own handler crashed after acknowledging. Record the key and ignore one you have already handled.

A delivery your endpoint rejects or times out on is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours: six attempts in all, the last about ten and a half hours after the first. Every attempt carries the same idempotencyKey and the same payload, sent to the webhook's current URL, so fixing a broken endpoint or its URL within that window loses nothing. A delivery that fails all six is marked failed and is not sent again on its own. A webhook whose URL Meisei no longer accepts (a private address saved before that was checked) fails at once, with no retries. Turning a webhook off stops its pending retries too. Anything that matters can still be reconciled from the event log, which holds every event whether or not it was delivered.

Every delivery is listed under the webhook in Settings → Webhooks, with its status (pending, retrying, succeeded or failed), how many attempts it has taken, and the HTTP status and error of the latest one. Over the API it is GET /webhooks/{id}/events, and status=failed lists the deliveries that ran out of retries. Each row holds the payload that was sent, so you can see what your endpoint was asked to accept, and the eventId it carried. Deliveries are kept for 30 days, as long as the event log keeps the events themselves.

To send one again after fixing your endpoint, click its Send again button in the deliveries list, or call POST /webhooks/{id}/events/{eventId}/redeliver. That makes one more attempt immediately, and Meisei does not retry it if it fails.

Recent deliveries for a webhook, most succeeded and two failed with the error each returned

The events

Names are resource.action. Subscribe with the exact string. GET /webhook-events returns the current list.

Store data

EventFires when
customer.created, customer.updated, customer.deletedA customer arrives or changes.
order.created, order.updated, order.deletedAn order arrives or changes.
product.created, product.updated, product.deletedA product arrives or changes.
productVariant.updatedA variant's metadata, price or stock changes.
site.created, site.updated, site.deletedA site changes.

Customers, orders and products arrive from your store's sync or from the import endpoints, and each one fires created the first time and updated after that. A sync that changes a variant's price, stock or metadata also fires productVariant.updated for that variant, with the productId it belongs to. A sync that leaves a variant as it was fires nothing for it. The first sync to see a variant only records it, so the event starts with the second. A customer Meisei meets first through a widget, a review or a loyalty event fires customer.created too. A CSV import job, or the history Meisei pulls in when you first connect a store, fires nothing, because it brings in past records rather than new changes.

Changing tags or metadata on many customers, orders or products at once, from a list in the dashboard or the bulk update endpoints, fires updated for each one that changed. Adding a tag a customer already has fires nothing for them.

A tag isn't a record of its own. It exists only while a customer, order or product carries it, so it has no events. Renaming or removing a tag is a bulk update of the records that carry it, and each of them fires updated with its old tags in previousAttributes.tags.

Reviews and surveys

EventFires when
review.createdA review arrives, published or not.
review.updatedA review is edited or its published state changes.
review.deletedA review is deleted.
review.publishedA review becomes visible on your store.
reviewQuestion.created, reviewQuestion.updated, reviewQuestion.deletedA question on the review form changes.
survey.deletedA review request is deleted.
reviewRequest.eligible, reviewRequest.canceledKlaviyo sends your review requests (see below).

A review that arrives unpublished and is published later fires review.created then review.updated. If you are syncing reviews to your own storefront, look at published on the payload rather than assuming created means visible, or subscribe to review.published, which fires once a review is visible.

reviewRequest.eligible and reviewRequest.canceled fire only when your review requests are sent by Klaviyo rather than by Meisei. reviewRequest.eligible fires when an order is due its request, and reviewRequest.canceled fires when that order is canceled.

Loyalty

EventFires when
loyaltyMembership.createdA customer joins a program.
loyaltyMembership.updatedA membership is edited, for example pinned to a tier.
loyaltyMembership.tierChangedA member moves between tiers.
loyaltyMembership.deletedA membership is removed.
loyaltyMembership.pointsEarnedA member earns points, with the total and balance.
loyaltyTransaction.createdPoints are earned, spent, adjusted or expired.
loyaltyReward.issuedA member gets a reward and its code.
loyaltyReward.claimedA member spends points on a reward.
loyaltyReward.redeemedA code is used.
loyaltyReward.revoked, loyaltyReward.expiredA reward stops being usable.
loyaltyReward.expiringSoon, loyaltyPoints.expiringSoonA reward or a balance is about to expire.

Configuration changes fire too: loyaltyProgram.*, loyaltyRule.* and loyaltyTier.*, each with created, updated and deleted.

The two expiringSoon events come from the overnight job rather than from anything a customer did, so they arrive on your organization's schedule. See Loyalty.

Wishlists

wishlist.created, wishlist.updated and wishlist.deleted.

There are no item-level events. Adding, removing or editing an item fires wishlist.updated for the list it belongs to.

Schedules

EventFires when
schedule.hourlyAt the top of every hour.
schedule.dailyOnce a day, in the first hour after midnight in your time zone.

Neither is about a record. data carries scheduledAt, timeZone, localDate (as YYYY-MM-DD) and localHour, so a receiver can do a daily job (a birthday sweep, a report) without running a clock of its own. Both are most useful with an automation.

Everything else

emailTemplate.*, webhook.* and metadataDefinition.* fire on their own configuration changes, each with created, updated and deleted. A metadataDefinition event is a custom field being defined, changed or removed.

Saved views, charts and dashboards are your team's own workspace rather than store data, so they fire nothing.

Testing an endpoint

The fastest way to see real deliveries is the example custom store, which registers a webhook against itself on boot and shows every inbound event in one list. See Custom integration.

While you are building, point the webhook at a tunnel to your machine and watch the deliveries land under Settings → Webhooks. A delivery that fails tells you the status and the body your endpoint returned, which is usually enough to see what went wrong.