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

# Webhook endpoints

> Real-time event delivery via Stripe-style endpoints. Each endpoint has its own URL, raw signing secret, and event subscription list. HMAC-SHA256 signed payloads, 3 retries, 5-minute replay window.

# Webhook endpoints

Marea POSTs JSON events to **webhook endpoints** you register. An endpoint is a first-class object — it has its own URL, its own signing secret, its own set of subscribed events, and its own delivery log. You can register up to **16 endpoints per account** (e.g. one for production, one for staging, one for an internal audit log).

Stripe-style model: events fan out to every enabled endpoint whose `subscribedEvents` list contains the event type. Adding a new destination is one POST; you do not need a new developer key.

## Available events

| Event type             | When it fires                                                                               |
| ---------------------- | ------------------------------------------------------------------------------------------- |
| `order.created`        | A new order is placed on a storefront you bootstrapped                                      |
| `order.status_updated` | An order's `orderStatus` or `customStatus` changes                                          |
| `order.paid`           | An order is paid successfully                                                               |
| `user.verified`        | A user you bootstrapped verifies their email after sign-up                                  |
| `user.cancelled`       | A user you bootstrapped cancels OR is deleted by Marea (the `reason` field tells you which) |

<Note>
  **Where order events come from.** A single developer endpoint receives `order.*` events for every storefront your key bootstrapped — you do not register one endpoint per merchant. If you revoke the key that bootstrapped a given user, their order events stop flowing to your endpoint automatically.
</Note>

## Create an endpoint

```bash theme={null}
curl -X POST https://api.mareaalcalina.com/v1/webhook_endpoints \
  -H "Authorization: Bearer mk_dev_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/marea-webhook",
    "description": "production",
    "enabled": true,
    "subscribedEvents": ["user.verified", "user.cancelled", "order.paid"]
  }'
```

Response (`201 Created`):

```json theme={null}
{
  "endpointId": "mk_we_a1b2c3d4e5f60789",
  "scope": "developer",
  "url": "https://your-server.com/marea-webhook",
  "description": "production",
  "enabled": true,
  "subscribedEvents": ["user.verified", "user.cancelled", "order.paid"],
  "signingSecret": "8f3e0c9a7b4d2f1e6a5c8d9f0e3b7a4c8f5d2e1a6b9c0f3e8d4a7c2b5f1e9a0c",
  "signingSecretVersion": 1,
  "createdAt": "2026-05-12T19:00:00.000Z",
  "updatedAt": "2026-05-12T19:00:00.000Z",
  "lastDeliveryAt": null,
  "lastDeliveryStatus": null,
  "consecutiveFailures": 0
}
```

<Warning>
  **The `signingSecret` is returned exactly once.** Store it now in your secret manager. If you lose it, [rotate the secret](#rotate-the-signing-secret) — the old secret stops working immediately (no overlap window in v1).
</Warning>

Required scope: `developer:webhooks` (added to new keys automatically) or `developer:bootstrap` (existing keys keep working).

## List, update, delete

```bash theme={null}
# List
curl https://api.mareaalcalina.com/v1/webhook_endpoints \
  -H "Authorization: Bearer mk_dev_xxxxxxxxxxxxxxxx"

# Get one
curl https://api.mareaalcalina.com/v1/webhook_endpoints/mk_we_a1b2c3d4e5f60789 \
  -H "Authorization: Bearer mk_dev_xxxxxxxxxxxxxxxx"

# Update (PATCH-style — only the fields you send are modified)
curl -X POST https://api.mareaalcalina.com/v1/webhook_endpoints/mk_we_a1b2c3d4e5f60789 \
  -H "Authorization: Bearer mk_dev_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "subscribedEvents": ["order.paid"] }'

# Delete (permanent — see "Pause an endpoint" below if you just want to stop deliveries temporarily)
curl -X DELETE https://api.mareaalcalina.com/v1/webhook_endpoints/mk_we_a1b2c3d4e5f60789 \
  -H "Authorization: Bearer mk_dev_xxxxxxxxxxxxxxxx"
```

### Pause an endpoint

To stop deliveries without losing the URL, signing secret, or subscribed events, set `enabled: false`. Marea filters disabled endpoints out of fan-out before any HTTP attempt — zero retries, zero delivery rows. Flip it back when your receiver is ready:

```bash theme={null}
# Pause
curl -X POST https://api.mareaalcalina.com/v1/webhook_endpoints/mk_we_a1b2c3d4e5f60789 \
  -H "Authorization: Bearer mk_dev_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": false }'

# Resume
curl -X POST https://api.mareaalcalina.com/v1/webhook_endpoints/mk_we_a1b2c3d4e5f60789 \
  -H "Authorization: Bearer mk_dev_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": true }'
```

The same lever is one click away in the dashboard at `/developers/webhooks` (each row has a Pause / Resume button). Use it during deploys, receiver outages, or signing-secret swaps.

### URL validation

URLs are rejected at registration time when:

* the scheme is not `https:` (HTTP is never accepted)
* the length exceeds 2048 characters
* the hostname is `localhost`, `0.0.0.0`, `metadata.google.internal`, anywhere in `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, IPv6 ULA (`fc00::/7`), IPv6 link-local (`fe80::/10`), or any hostname ending in `.internal` / `.local`

The same validator runs in the dashboard, the REST endpoint, and the per-store integrations panel. Operate your receiver on a public-DNS HTTPS host you control.

## Event envelope

Every webhook is a `POST` with `Content-Type: application/json`. The body is the same envelope shape across all event types:

```json theme={null}
{
  "type": "user.verified",
  "eventId": "5c7a4e1b-9d3f-4a82-b1c5-7e6f0a3b8d2c",
  "timestamp": "2026-05-12T19:42:11.823Z",
  "apiVersion": "2026-05-12",
  "data": {
    "userId": "QqV9pK3nL...",
    "developerKeyId": "kid_abc123",
    "verifiedAt": "2026-05-12T19:42:11.823Z"
  }
}
```

`eventId` is a UUID v4 — use it as the idempotency key on your receiver. Marea may retry; processing the same `eventId` more than once must be a no-op on your side.

### Payload `data` shapes

#### `user.verified`

```json theme={null}
{
  "userId": "QqV9pK3nL...",
  "developerKeyId": "kid_abc123",
  "verifiedAt": "2026-05-12T19:42:11.823Z"
}
```

#### `user.cancelled`

```json theme={null}
{
  "userId": "QqV9pK3nL...",
  "developerKeyId": "kid_abc123",
  "cancelledAt": "2026-05-12T22:03:45.117Z",
  "reason": "user_clicked_cancel"
}
```

Cancellation reasons (5-value enum):

| `reason`              | Meaning                                                                                             |
| --------------------- | --------------------------------------------------------------------------------------------------- |
| `user_clicked_cancel` | User explicitly cancelled their account from the dashboard                                          |
| `squatting_defense`   | An unverified bootstrap account was cleaned up so a real user could sign up with the same email     |
| `30d_unverified`      | Bootstrapped account never verified their email; cleaned up after 30 days                           |
| `90d_no_tos`          | Verified user never accepted updated Terms of Service after 90 days; cleaned up                     |
| `key_revoked`         | Developer key that bootstrapped the user was revoked; the user was cleaned up as part of revocation |

#### `order.created` / `order.status_updated` / `order.paid`

`order.created` carries the full order payload (items, pricing, customer, delivery, payment method):

```json theme={null}
{
  "type": "order.created",
  "eventId": "8f7c6d5e-1234-5678-90ab-cdef12345678",
  "timestamp": "2026-05-13T20:35:22.123Z",
  "apiVersion": "2026-05-12",
  "data": {
    "orderId": "order_abc123",
    "publicOrderId": 1042,
    "userId": "user_uid_lupita",
    "menuId": "menu_taqueria_main",
    "orderStatus": "pending",
    "customStatus": { "id": "received", "label": "Recibido" },
    "totalPrice": 285.50,
    "subtotalPrice": 250.00,
    "deliveryPrice": 35.50,
    "currency": "MXN",
    "items": [
      { "title": "Taco al Pastor", "amount": 5, "price": 25.00 }
    ],
    "customer": { "name": "Juan Pérez", "phone": "+525512345678" },
    "paymentMethod": { "name": "Stripe" },
    "paymentStatus": "completed",
    "createdAt": "2026-05-13T20:35:18.456Z"
  }
}
```

`order.status_updated` is a slim payload (`previousState` → `newState` only) — receivers cache `order.created` and merge by `orderId`. `order.paid` is shaped like `order.created` plus `data.paymentMethod.id` and `data.paidAt`. Test fires from the dashboard add `__test: true` to the envelope.

## Headers Marea sends

| Header                    | Meaning                                                                                                                        |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `X-Marea-Signature`       | `t=<unix-ts>,v1=<hex-hmac>` — verify before trusting the body                                                                  |
| `X-Marea-Event-Type`      | Same as `data.type`                                                                                                            |
| `X-Marea-Event-Id`        | Same as envelope's `eventId`                                                                                                   |
| `X-Marea-Endpoint-Id`     | The endpoint that triggered this delivery (useful when one receiver multiplexes multiple endpoints)                            |
| `X-Marea-Signing-Version` | Endpoint's `signingSecretVersion`; bumps on every rotate. Useful for detecting in-flight rotation.                             |
| `X-Marea-Source`          | `developer` for `user.*` events on accounts you bootstrapped; `merchant` for `order.*` events from those accounts' storefronts |
| `User-Agent`              | `marea-webhook/1.0`                                                                                                            |

## Verify signatures

Every webhook is signed with HMAC-SHA256. The signed string is `"<timestamp>.<rawBody>"`; the result is hex-encoded in the `v1=` field.

1. Parse `t=` and `v1=` from `X-Marea-Signature`
2. Reject if `|now - t| > 300` (5-minute replay window)
3. Compute `HMAC-SHA256(signingSecret, "<t>.<rawBody>")` and hex-encode
4. Compare against `v1` using a constant-time function
5. On match: trust the payload. On mismatch: respond `401`.

The `signingSecret` is the raw 64-char hex string returned at create/rotate time — no further derivation needed.

Ready-to-paste verifiers in JavaScript and Python live in [Webhook receiver helpers](/api/webhooks/receiver-helpers).

<Warning>
  Always verify against the **raw request body bytes**, not a JSON-parsed and re-stringified version. Whitespace differences will break the signature match.
</Warning>

## Rotate the signing secret

```bash theme={null}
curl -X POST https://api.mareaalcalina.com/v1/webhook_endpoints/mk_we_a1b2c3d4e5f60789/rotate-secret \
  -H "Authorization: Bearer mk_dev_xxxxxxxxxxxxxxxx"
```

Response includes the new secret. `signingSecretVersion` bumps by 1; the old secret stops working immediately. Coordinate the rotation with your receiver — there is no overlap window in v1.

## Retry behavior

Marea expects a `2xx` response from your endpoint within **5 seconds**. Anything else (non-2xx, timeout, connection error) triggers the retry path:

| Attempt | Delay from first dispatch |
| ------- | ------------------------- |
| 1       | Immediate                 |
| 2       | +30 seconds               |
| 3       | +5 minutes                |

After attempt 3 fails, the delivery row is marked `max_attempts_reached`. For `user.*` events specifically, we also send a delivery-failure email to the developer who owns the endpoint so a misconfigured receiver doesn't silently lose lifecycle events.

You can inspect recent deliveries in the dashboard at `/developers/webhooks/{endpointId}` (last 50 per endpoint, 30-day retention).

There is no automatic replay endpoint. If you need bulletproof delivery, queue events on your end so a transient receiver outage doesn't lose them.

## Receiver implementation tips

* **Respond 200 immediately**, then process asynchronously. Marea's 5-second timeout is tight.
* **Idempotency**: use `eventId` from the envelope as your dedupe key. Retries are rare but possible.
* **Constant-time signature comparison**: use `crypto.timingSafeEqual` (Node) or `hmac.compare_digest` (Python). Never `===` on HMAC.
* **Multiplex one server across multiple endpoints**: the `X-Marea-Endpoint-Id` header tells you which endpoint a delivery came from.
* **Test with `webhook.site`** while wiring up: register a test endpoint pointed at your unique `webhook.site` URL, fire test events from the dashboard, then move the URL to your real receiver.

## Limitations and roadmap

* **Hard cap of 16 endpoints per account.** We'll raise it if real-world usage justifies.
* **No overlap window on secret rotation.** To roll a secret without dropping events, create a second endpoint with the same `subscribedEvents`, point your receiver at both for a brief cutover, then pause and delete the old one.
* **No event-replay endpoint.** Once the 3 retries fail, the event is gone — queue events on your side if you need bulletproof delivery.

For receiver-side helpers (signature verification code in JS + Python), see [Webhook receiver helpers](/api/webhooks/receiver-helpers).
