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

# Webhooks

> Subscribe to event types, verify HMAC-signed deliveries, dedupe at-least-once delivery, and replay from the event ledger.

Webhooks are how Maple pushes things to you — a new order, a status change, a finished menu sync. You register an HTTPS endpoint and the event types you care about; Maple signs each event and delivers it, retrying on failure and keeping a ledger you can replay from. You never poll.

## The event envelope

Every delivery has the same Stripe-style envelope. The `data` payload is event-specific:

```json theme={null}
{
  "object": "event",
  "id": "evt_...",
  "type": "order.notification",
  "created": 1765432100,
  "data": { "order_id": "ord_...", "location_id": "str_..." }
}
```

<ResponseField name="id" type="string">
  Unique event id. **Dedupe on this** — delivery is at-least-once.
</ResponseField>

<ResponseField name="type" type="string">
  The event type. Switch on it to route the event.
</ResponseField>

<ResponseField name="created" type="number">
  Unix seconds when the event was created.
</ResponseField>

<ResponseField name="data" type="object">
  Event-specific payload. `order.notification` carries the full order content; the lifecycle events (`order.created`, `order.paid`, `order.cancelled`) carry summary fields. See [Webhook events](/developer-api/webhook-events) for each payload, and fetch `GET /v1/orders/{orderId}` for authoritative current state.
</ResponseField>

## Event types

Subscribe only to what you act on. The live catalog is always at `GET /v1/webhook_event_types`:

| Type                         | When it fires                                                                          |
| ---------------------------- | -------------------------------------------------------------------------------------- |
| `order.validation_requested` | Maple asks you to validate an order before notifying you                               |
| `order.notification`         | An order is handed to you to fulfill — **the event you act on**                        |
| `order.created`              | An order was created — a lifecycle signal for analytics, thinner than the notification |
| `order.paid`                 | Payment settled (can be after the handoff)                                             |
| `order.cancelled`            | The order was cancelled — stop fulfilling it                                           |
| `store.provisioned`          | A location was provisioned to your app                                                 |
| `store.deprovisioned`        | A location was deprovisioned from your app                                             |
| `store.status.changed`       | A connected location's status changed                                                  |
| `menu.sync.completed`        | A menu sync completed for a location                                                   |
| `menu.sync.failed`           | A menu sync failed for a location                                                      |

`webhook.test` is also delivered on demand by the test endpoint, so you can verify a handler before any real traffic.

<Note>
  These are discrete events, not a status feed. Maple doesn't send a webhook for every order status change — the
  transitions you drive yourself (`accept`, `ready`, `complete`) aren't echoed back, and there's no per-transition
  event. For an order's current state, read `GET /v1/orders/{orderId}`. See the [Order
  lifecycle](/developer-api/concepts/order-lifecycle#webhooks-dont-track-every-transition).
</Note>

For what each order event carries and which to subscribe to, see [Webhook events](/developer-api/webhook-events#order-events).

## Subscribing

```bash theme={null}
curl -X POST $MAPLE_BASE/webhook_subscriptions \
  -H "Authorization: Bearer $MAPLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "notification_url": "https://your-app.example.com/maple/webhooks",
    "event_types": ["order.notification", "order.cancelled"]
  }'
```

```json Response theme={null}
{
  "object": "webhook_subscription.created",
  "subscription": { "object": "webhook_subscription", "id": "dws_...", "environment": "test", "status": "enabled", "...": "..." },
  "signing_secret": "mwhsec_..."
}
```

<Warning>
  The `signing_secret` (`mwhsec_…`) is returned **once, at creation, and never again.** Store it securely now. If you
  lose it, rotate by creating a new subscription.
</Warning>

The `notification_url` must be public HTTPS — private and internal addresses are rejected. Manage subscriptions with `GET`, `PATCH` (update the URL, event types, or `enabled`/`disabled` status), and `DELETE` on `/v1/webhook_subscriptions/{id}`. Updating a subscription leaves its signing secret unchanged.

### Multiple subscriptions

You can register more than one subscription. Every **enabled** subscription whose `event_types` include a fired event gets its own signed delivery, each with its own signing secret — so overlapping `event_types` across subscriptions are allowed. This lets you fan out by destination: for example, point order events at your fulfillment service and `menu.sync.*` events at a separate back-office endpoint. [Replay](#the-event-ledger) follows the same routing and skips any subscription that already received the event.

## Verifying the signature

Each delivery carries two headers:

* `maple-webhook-id` — the event id.
* `maple-webhook-signature` — `t=<unix_seconds>,v1=<hex_hmac>`.

The HMAC-SHA256 signature is computed over a signed string that binds the timestamp, the subscription, and the destination URL to the body — so a captured signature can't be replayed against a different subscription or URL:

```
{timestamp}.{subscription_id}.{notification_url}.{raw_body}
```

Recompute it with your signing secret and compare in constant time. Always use the **raw, unparsed request body** — re-serializing JSON will change the bytes and break the signature.

```ts theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyMapleWebhook(params: {
  signatureHeader: string; // maple-webhook-signature
  subscriptionId: string; // your subscription id (dws_…)
  notificationUrl: string; // the exact URL you registered
  rawBody: string; // the unparsed request body
  signingSecret: string; // mwhsec_…
  toleranceSeconds?: number; // default 300
}): boolean {
  const parts = Object.fromEntries(params.signatureHeader.split(',').map((p) => p.split('=')));
  const timestamp = Number(parts.t);
  const provided = parts.v1 ?? '';

  // Reject stale deliveries to prevent replay.
  const skew = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
  if (!Number.isFinite(timestamp) || skew > (params.toleranceSeconds ?? 300)) return false;

  const signed = `${timestamp}.${params.subscriptionId}.${params.notificationUrl}.${params.rawBody}`;
  const expected = createHmac('sha256', params.signingSecret).update(signed).digest('hex');
  return expected.length === provided.length && timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
}
```

## Delivery guarantees

* **At-least-once.** The same event can arrive more than once. Dedupe on the envelope `id` and make your handler idempotent.
* **Respond fast.** Return a `2xx` quickly, then do slower work asynchronously. Any non-`2xx` (or a timeout) counts as a failed delivery.
* **Order is not guaranteed.** Don't assume events arrive in the order they occurred; reconcile against the order resource when sequence matters.

### Retries and auto-disable

Each event is delivered with up to **6 attempts** — the first immediately, then with increasing backoff:

| Attempt | Delay after previous |
| ------- | -------------------- |
| 1       | immediate            |
| 2       | 1 minute             |
| 3       | 5 minutes            |
| 4       | 30 minutes           |
| 5       | 2 hours              |
| 6       | 6 hours              |

A non-`2xx` response (or a timeout — the per-attempt limit is 15 seconds) fails that attempt. An event that fails **all 6** attempts counts as one fully-failed delivery. After **5 consecutive fully-failed deliveries** — five separate events that each exhausted every attempt — the subscription is automatically **disabled**; a single successful delivery resets the counter to zero. Re-enable it with a `PATCH` setting `status` back to `enabled` once your endpoint is healthy, then [replay](#the-event-ledger) anything you missed.

## The event ledger

Every event is recorded, so a missed or failed delivery is recoverable.

* `GET /v1/webhook_events` — your recent events, most recent first (up to 50). Useful for reconciliation and debugging.
* `GET /v1/webhook_events/{eventId}` — a single event, including its full `data` payload.
* `POST /v1/webhook_events/{eventId}/replay` — re-deliver the event to currently-matching subscriptions. **Idempotent**: subscriptions that already received it are skipped, so replaying is safe.

## Testing your handler

```bash theme={null}
curl -X POST $MAPLE_BASE/webhook_subscriptions/{id}/test \
  -H "Authorization: Bearer $MAPLE_KEY"
```

This delivers a signed `webhook.test` event and returns whether it was delivered and the HTTP status your endpoint returned:

```json theme={null}
{ "object": "webhook_test_result", "event_id": "evt_...", "delivered": true, "response_status": 200 }
```

Use it to confirm — before any real order — that your endpoint is reachable, verifies the signature, and returns `2xx`.

## Checklist

<Check>Verify the HMAC signature on every delivery against the raw body, and reject stale timestamps.</Check>
<Check>Dedupe on the envelope `id`; make handlers idempotent.</Check>
<Check>Return `2xx` fast; process asynchronously.</Check>
<Check>Subscribe only to the event types you handle.</Check>
<Check>Monitor for auto-disabled subscriptions and replay from the ledger after an outage.</Check>
