> ## Documentation Index
> Fetch the complete documentation index at: https://docs.expys.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Subscribe to lifecycle events with signed, retried deliveries. Server-side: managed with the Org-API-Key, verified with a per-endpoint signing secret.

<Warning>
  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.
</Warning>

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.

```bash theme={null}
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"]
  }'
```

<ResponseField name="id" type="string" required>Endpoint id (use it to delete the endpoint).</ResponseField>
<ResponseField name="url" type="string" required>Your HTTPS delivery URL.</ResponseField>
<ResponseField name="events" type="string[]" required>The subscribed event names.</ResponseField>
<ResponseField name="environment" type="string" required>`SANDBOX` or `LIVE`, from the key you used.</ResponseField>

<ResponseField name="signingSecret" type="string" required>
  The HMAC signing secret, prefixed `whsec_`. **Shown once** - store it now.
</ResponseField>

<ResponseField name="createdAt" type="string" required>ISO-8601 creation time.</ResponseField>

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](/api-reference/introduction).

## 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.

| Event                          | Fires when                                                                |
| ------------------------------ | ------------------------------------------------------------------------- |
| `redemption.created`           | A redemption is submitted.                                                |
| `redemption.open`              | It moves to open.                                                         |
| `redemption.awaiting_vendor`   | It is waiting on the vendor.                                              |
| `redemption.awaiting_customer` | It is waiting on the customer.                                            |
| `redemption.purchased`         | It is purchased (sometimes called "confirmed"; `purchased` is canonical). |
| `redemption.completed`         | The experience completed.                                                 |
| `redemption.canceled`          | It was canceled (points are refunded).                                    |
| `wallet.credited`              | Points were credited to a member.                                         |
| `wallet.debited`               | Points were debited (for example, a redemption spend).                    |
| `member.created`               | A member profile was created.                                             |
| `member.tier_changed`          | A member's tier changed.                                                  |
| `member.removed`               | A member was removed.                                                     |
| `conversation.message_created` | A new concierge message was created.                                      |

<Note>
  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`.
</Note>

## Delivery format

Each delivery is a `POST` with a JSON body and these headers:

| Header              | Value                                                     |
| ------------------- | --------------------------------------------------------- |
| `X-Expys-Signature` | `sha256=<hex>` HMAC-SHA256 of the exact raw body.         |
| `X-Expys-Timestamp` | Delivery timestamp (use to reject stale replays).         |
| `X-Expys-Event`     | The event name (for example `redemption.created`).        |
| `X-Expys-Delivery`  | A 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`.

<CodeGroup>
  ```typescript TypeScript theme={null}
  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);
  }
  ```

  ```swift Swift theme={null}
  import Crypto
  import Foundation

  func verifyExpysSignature(rawBody: Data, signatureHeader: String, signingSecret: String) -> Bool {
    let key = SymmetricKey(data: Data(signingSecret.utf8))
    let mac = HMAC<SHA256>.authenticationCode(for: rawBody, using: key)
    let expected = "sha256=" + mac.map { String(format: "%02x", $0) }.joined()

    let a = Array(expected.utf8)
    let b = Array(signatureHeader.utf8)
    guard a.count == b.count else { return false }
    var diff: UInt8 = 0
    for i in a.indices { diff |= a[i] ^ b[i] }
    return diff == 0
  }
  ```

  ```kotlin Kotlin theme={null}
  import javax.crypto.Mac
  import javax.crypto.spec.SecretKeySpec

  fun verifyExpysSignature(rawBody: ByteArray, signatureHeader: String, signingSecret: String): Boolean {
    val mac = Mac.getInstance("HmacSHA256")
    mac.init(SecretKeySpec(signingSecret.toByteArray(), "HmacSHA256"))
    val hex = mac.doFinal(rawBody).joinToString("") { "%02x".format(it) }
    val expected = "sha256=$hex".toByteArray()
    val received = signatureHeader.toByteArray()
    if (expected.size != received.size) return false
    var diff = 0
    for (i in expected.indices) diff = diff or (expected[i].toInt() xor received[i].toInt())
    return diff == 0
  }
  ```
</CodeGroup>

<Warning>
  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.
</Warning>

## 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.

<Info>
  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.
</Info>

## 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.
