> ## 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 notify your integration about supported business events. Register a public HTTPS endpoint and the event types you handle; Maple signs deliveries and records their outcomes. Durable business-event delivery retries failures. Signed tests and manual replay attempts are one-shot actions and do not schedule their own automatic retries.

## Manage deliveries in the console

Invited organization members can open **Developers → app → Webhooks** without entering an API key. The app needs `webhooks:read` for diagnostics and `webhooks:write` for mutations. Outbound actions also require an active app.

- **Endpoints:** create/edit an endpoint, save its one-time signing secret, explicitly send a test, or disable/re-enable it. Prefer disable to preserve delivery history. Suspended apps remain inspectable and endpoints can still be disabled.
- **Deliveries:** filter ordinary and signed-test deliveries by endpoint, event type, outcome, and time range. Read the aggregate status, attempt count, latest response status, safe error, and timestamps. This is not a per-attempt timeline. Displayed URLs and endpoint status are current configuration, not historical destination snapshots.
- **Events:** inspect replayable business-event metadata. **Review event** previews an event-level retry; **Confirm retry** attempts eligible destinations, skipping successful pairs, disabled endpoints, and endpoints no longer subscribed. If targets change, review a fresh preview before confirming.

History defaults to 24 hours and supports up to 30 days and 100 rows per page; cursors expire after 15 minutes. This read window is not a claim that older records have been deleted. A confirmed console replay supports at most ten eligible endpoints and previews at most 100 targets; oversized fanout is refused before sending.

Console tests and replays have separate server-side budgets of three actions per person/app/environment and ten per app/environment per minute. Follow the returned cooldown; there is no automatic UI resubmission. If an outcome is unknown, inspect your receiver and refresh history before retrying.

A delivered test means your endpoint returned a `2xx`, not that its signature was verified. Test deliveries without a replayable business event offer **Send another test**. Neither a pending ledger row nor a manual action’s failure proves another attempt is scheduled.

The console exposes metadata, not full event payloads, receiver response bodies, secrets, or internal traces. The authenticated public event API below remains separate. The **Webhook format and signature verification** guide contains copyable synthetic examples only; opening it does not send a request or generate a signature with your secret.

### Test the full order workflow

For order integrations, open **Developers → app → Test orders** in the sandbox dashboard. Select a granted test location and confirm **Create test order**. The app needs `orders:write`, must be active, and must be the location’s active test-mode order receiver. Maple must prepare an in-stock menu item without required modifiers.

This creates a real pay-in-store pickup order through the normal workflow, including configured webhook and printer integrations. The action does not charge a card. Use an isolated sandbox location; it is not the same as **Send test** on a webhook endpoint. Creation is limited per location (three per minute by default) and unavailable if the budget store cannot be checked.

With `orders:read`, the outcome tracker shows validation, notification delivery, and the partner decision for the latest ten orders associated with this app, environment, and location. New orders appear after the workflow records validation or a webhook event. Refresh before resending after an uncertain result; an empty list does not prove creation failed. This tool does not replace verifying API requests using your own key.

Merchant **Location settings → Integrations → Connected apps** retains connection and access controls. Developer testing tools now live in the organization-scoped console.

## The event envelope

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

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

### `id`

string

Unique event id. **Dedupe on this** — delivery is at-least-once.

### `type`

string

The event type. Switch on it to route the event.

### `created`

number

Unix seconds when the event was created.

### `data`

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](https://docs.maple.inc/developer-api/webhook-events.md) for each payload, and fetch `GET /v1/orders/{orderId}` for authoritative current state.

## 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                                           |
| `booking.created`            | A booking was created or first observed from its provider                              |
| `booking.confirmed`          | A booking was confirmed                                                                |
| `booking.updated`            | A booking was changed                                                                  |
| `booking.cancelled`          | A booking was cancelled                                                                |
| `booking.declined`           | A booking was declined by the venue                                                    |
| `booking.seated`             | A party was seated                                                                     |
| `booking.completed`          | A booking was completed                                                                |
| `booking.no_show`            | A booking was marked no-show                                                           |
| `waitlist_entry.created`     | A party joined the waitlist                                                            |
| `waitlist_entry.notified`    | A waiting party was told their table is ready                                          |
| `waitlist_entry.seated`      | A waitlist party was seated into a walk-in booking                                     |
| `waitlist_entry.removed`     | A party left the waitlist or was removed by the venue                                  |
| `waitlist_entry.expired`     | A notified party did not respond in time                                               |
| `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](https://docs.maple.inc/developer-api/concepts/order-lifecycle.md#webhooks-dont-track-every-transition).

For what each order event carries and which to subscribe to, see [Webhook events](https://docs.maple.inc/developer-api/webhook-events.md#order-events). Booking events require `bookings:read` and are delivered for granted locations; see [Booking events](https://docs.maple.inc/developer-api/webhook-events.md#booking-events).

## Subscribing

```bash
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
{
  "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.

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

Durable business-event delivery uses up to **9 attempts** by default — the first immediately, then with increasing backoff. The early attempts fit inside the six-minute POS decision window, while the long tail supports reconciliation after a real outage:

| Attempt | Delay after previous |
| ------- | -------------------- |
| 1       | immediate            |
| 2       | 10 seconds           |
| 3       | 30 seconds           |
| 4       | 2 minutes            |
| 5       | 5 minutes            |
| 6       | 15 minutes           |
| 7       | 1 hour               |
| 8       | 3 hours              |
| 9       | 6 hours              |

A non-`2xx` response (or a timeout — the per-attempt limit is 15 seconds) fails that attempt. An event that fails **all 9** 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

Published business events are recorded for reconciliation and replay. Synthetic signed-test deliveries have delivery-ledger records but no replayable event-log row; send another explicit test instead.

- `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` — attempt delivery to currently matching enabled subscriptions, skipping pairs with recorded success. It reuses the event ID and does not force successful redelivery. Concurrent attempts or an uncertain network/persistence outcome can still produce duplicates; the receiver must deduplicate. Failed manual attempts are terminal and do not schedule automatic retries.

If your service was down long enough to miss an order, use the ledger replay first when you want the original event identity. For a direct catch-up, use `since` as your last successful checkpoint and drain forward: request `GET /v1/orders?since=<checkpoint>&limit=100`, then repeat with `starting_after=<last-order-id>` while `has_more` is true. With `since`, results are oldest first, so this reaches the entire backlog without skipping orders that share a timestamp. Fetch the authoritative order resource, then call `POST /v1/orders/{orderId}/resend` only for a live order that missed its webhook. Resend creates a fresh signed `order.notification` with a new event identity and never changes order state. Terminal orders (`REJECTED`, `CUSTOMER_CANCELLED`, or `STORE_CANCELLED`) cannot be resent or revived; reconcile them with `GET /v1/orders?since=...` and `GET /v1/orders/{orderId}`.

## Testing your handler

```bash
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
{ "object": "webhook_test_result", "event_id": "evt_...", "delivered": true, "response_status": 200 }
```

Use it to check reachability and a `2xx` response, and verify the signature in your own receiver. The response does not certify signature handling. This is one attempt only; a false `delivered` value or null response status must not be interpreted as an automatic retry pending.

## Checklist

> [!TIP]
>
> Verify the HMAC signature on every delivery against the raw body, and reject stale timestamps.

> [!TIP]
>
> Dedupe on the envelope `id`; make handlers idempotent.

> [!TIP]
>
> Return `2xx` fast; process asynchronously.

> [!TIP]
>
> Subscribe only to the event types you handle.

> [!TIP]
>
> Monitor for auto-disabled subscriptions and replay from the ledger after an outage.
