Skip to main content
Webhook management is server-side. Register and delete endpoints with the Org-API-Key from your backend, and verify deliveries on a backend HTTPS endpoint. Never manage webhooks from an app.
Webhooks push lifecycle events from Expys to an HTTPS endpoint you control, so your systems react to redemptions, point changes, member changes, and concierge messages without polling.

Register an endpoint

Create an endpoint with the events you want. The response includes the signing secret once - store it immediately; it is never returned again.
curl -X POST https://api.expys.com/v1/webhooks \
  -H "Authorization: Bearer YOUR_ORG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.yourapp.com/webhooks/expys",
    "events": ["redemption.created", "wallet.debited"]
  }'
id
string
required
Endpoint id (use it to delete the endpoint).
url
string
required
Your HTTPS delivery URL.
events
string[]
required
The subscribed event names.
environment
string
required
SANDBOX or LIVE, from the key you used.
signingSecret
string
required
The HMAC signing secret, prefixed whsec_. Shown once - store it now.
createdAt
string
required
ISO-8601 creation time.
Manage endpoints with GET /v1/webhooks (list) and DELETE /v1/webhooks/{id}. An org may hold up to 10 active endpoints per environment. See the API reference.

Event catalog

The redemption.* names cover the full booking lifecycle - one event per status transition - so a CRM sees the whole course of an experience.
EventFires when
redemption.createdA redemption is submitted.
redemption.openIt moves to open.
redemption.awaiting_vendorIt is waiting on the vendor.
redemption.awaiting_customerIt is waiting on the customer.
redemption.purchasedIt is purchased (sometimes called “confirmed”; purchased is canonical).
redemption.completedThe experience completed.
redemption.canceledIt was canceled (points are refunded).
wallet.creditedPoints were credited to a member.
wallet.debitedPoints were debited (for example, a redemption spend).
member.createdA member profile was created.
member.tier_changedA member’s tier changed.
member.removedA member was removed.
conversation.message_createdA new concierge message was created.
Subscribing to an event name not in this catalog is rejected with WEBHOOK_EVENT_UNKNOWN. A non-HTTPS or disallowed URL is rejected with WEBHOOK_URL_NOT_ALLOWED.

Delivery format

Each delivery is a POST with a JSON body and these headers:
HeaderValue
X-Expys-Signaturesha256=<hex> HMAC-SHA256 of the exact raw body.
X-Expys-TimestampDelivery timestamp (use to reject stale replays).
X-Expys-EventThe event name (for example redemption.created).
X-Expys-DeliveryA unique delivery id (use it for idempotent consumption).

Verify the signature

Recompute HMAC-SHA256 over the exact raw request body (not a re-serialized object) with your signing secret, hex-encode it, prefix sha256=, and compare in constant time against X-Expys-Signature.
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyExpysSignature(
  rawBody: string,
  signatureHeader: string,
  signingSecret: string,
): boolean {
  const digest = createHmac("sha256", signingSecret).update(rawBody, "utf8").digest("hex");
  const expected = Buffer.from(`sha256=${digest}`);
  const received = Buffer.from(signatureHeader);
  return expected.length === received.length && timingSafeEqual(expected, received);
}
Read the raw body bytes before any JSON parsing middleware reshapes them. Verifying against a re-serialized object will fail because key order and whitespace differ from what was signed.

Retries and dead-lettering

A delivery is retried on any non-2xx response or timeout, with exponential backoff: first retry after 30s, doubling each time, capped at 1 hour. After 6 total attempts the delivery is dead-lettered and no longer retried.
Each delivery request times out after 10 seconds, so respond quickly. Do the real work asynchronously - acknowledge with a 2xx as soon as you have verified the signature and enqueued the event.

Consume idempotently

Deliveries are at-least-once: a retry can arrive after you already processed an event. Deduplicate on X-Expys-Delivery (or the event’s own id) and make handlers idempotent so a duplicate is a no-op.