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) |
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.
Create an endpoint
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):
{
"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
}
The signingSecret is returned exactly once. Store it now in your secret manager. If you lose it, rotate the secret — the old secret stops working immediately (no overlap window in v1).
Required scope: developer:webhooks (added to new keys automatically) or developer:bootstrap (existing keys keep working).
List, update, delete
# 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:
# 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:
{
"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
{
"userId": "QqV9pK3nL...",
"developerKeyId": "kid_abc123",
"verifiedAt": "2026-05-12T19:42:11.823Z"
}
user.cancelled
{
"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):
{
"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.
| 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.
- Parse
t= and v1= from X-Marea-Signature
- Reject if
|now - t| > 300 (5-minute replay window)
- Compute
HMAC-SHA256(signingSecret, "<t>.<rawBody>") and hex-encode
- Compare against
v1 using a constant-time function
- 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.
Always verify against the raw request body bytes, not a JSON-parsed and re-stringified version. Whitespace differences will break the signature match.
Rotate the signing secret
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.