Webhooks

For a developer wiring Cascade into their own systems. A webhook is a URL of yours that Cascade posts to when something happens, so you hear about a published review or a redeemed reward without polling for it.

Registering an endpoint

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

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

The URL has to accept HTTPS. Subscribe to the events you actually handle: the list you send replaces the subscription wholesale on an update, so send the full list every time.

hmacSecret is a shared secret you choose. Cascade 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 on open, 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 intents 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:

{
  "event": "review.created",
  "timestamp": "2026-01-15T09:30:00Z",
  "idempotencyKey": "9f2c9a56-6f6a-4b16-9b0e-9f2e1d6b7c31",
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "rating": 5,
    "published": false
  }
}

data is the record the event is about, in the same shape the API returns it. Answer with any 2xx status. Anything else is recorded as a failed delivery.

Answer quickly, and do your own work afterwards. Cascade waits for your response, so an endpoint that takes ten seconds to process the payload is an endpoint that 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 single most common mistake in a webhook receiver.

Bun
const raw = await req.text()
const expected = `sha256=${new Bun.CryptoHasher("sha256", secret).update(raw).digest("hex")}`

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 network timeouts are real: if you answered a request that never reached us, or your own handler crashed after acknowledging, you can end up processing the same work twice. Record the key and ignore one you have already handled.

There are no automatic retries. A delivery your endpoint rejects or times out on is recorded as failed and is not sent again. This is deliberate but easy to be caught by: treat webhooks as a fast path, not as a guaranteed one, and reconcile anything that matters by reading the API on a schedule.

Failed deliveries are listed with their error under the webhook in Settings → Webhooks, and over the API at GET /webhooks/{id}/events. Each row holds the payload that was sent, so you can see what your endpoint was asked to accept.

The events

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

Store data

EventFires when
customer.updated, customer.deletedA customer record changes.
order.updated, order.deletedAn order changes.
product.updated, product.deleted, productVariant.updatedA product or variant changes.
site.created, site.updated, site.deletedA site changes.

Tag changes have their own events: customerTag.*, orderTag.* and productTag.*, each with created, updated and deleted.

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.
productQuestion.createdA question is asked, or you record one yourself.
productQuestion.updatedA question is published, rejected or edited.
productQuestion.deletedA question is deleted, with its answers.
productQuestionAnswer.createdAn answer is written, by you or by a shopper.
productQuestionAnswer.updatedAn answer is edited, published or hidden.
productQuestionAnswer.deletedAn answer is deleted.
surveyFlow.created, surveyFlow.updated, surveyFlow.deletedA survey flow changes.
survey.deletedA sent survey is deleted.

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. Questions work the same way: one arrives unpublished and fires productQuestion.updated when you publish it.

Loyalty

EventFires when
loyaltyMembership.createdA customer joins a program.
loyaltyMembership.tierChangedA member moves between tiers.
loyaltyMembership.deletedA membership is removed.
loyaltyTransaction.createdPoints are earned, spent, adjusted or expired.
loyaltyReward.issuedA member claims a reward and gets a code.
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.
stampCard.created, stampCard.updated, stampCard.completedA stamp card is started, stamped or filled.
referral.updatedA referral is created or attributed.

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.

Everything else

emailTemplate.*, quickFilter.* and webhook.* fire on their own configuration changes, each with created, updated and deleted.

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.