# AGENTS Source: https://docs.mareaalcalina.com/AGENTS # AGENTS.md — Marea Alcalina API > Following the [agents.md spec](https://agents.md/). This file is for AI agents (Claude, GPT, Cursor, Continue.dev, generic LLM-driven tools) consuming the Marea public API. For the full doc index in agent-friendly markdown form, fetch [llms.txt](https://docs.mareaalcalina.com/llms.txt). ## What this API is for Operations + retention API for small-business commerce. Bootstrap users, manage storefronts + products (food menus or retail / service catalogs), publish to public hosted URLs. End-customer order channels are the operator's choice — WhatsApp, web checkout, or in-person pickup — and configurable per storefront. The API does not lock the channel. ## Auth model Two-tier (see [Two-tier keys](https://docs.mareaalcalina.com/concepts/keys) for the full model): * `mk_dev_*` — **developer key**, held by the agent or app builder. Can bootstrap users (`users:bootstrap` scope). Capped at 50 bootstraps/day per key (`rpd: 50`). * `mk_user_*` — **per-user key**, returned by `POST /v1/users` and scoped to one user. Holds the catalog scopes (`catalog:read`, `catalog:write`, `storefront:publish`). Cannot bootstrap, cannot see other users' data. Both pass as `Authorization: Bearer ` (or `X-API-Key: ` as a fallback). The split lets an agent bootstrap users with its own dev key and receive a per-user key in the response — the agent never holds the user's password. ## Common patterns * **Bootstrap → verify → use.** `POST /v1/users` returns a *restricted* `mk_user_*` key + emails the user a 6-digit code. The user reads the code aloud (or the agent fetches it via Gmail-MCP), agent calls `POST /v1/users/:userId/verify`, the same key is upgraded to full per-user scope. See [Bootstrap quickstart](https://docs.mareaalcalina.com/quickstart/bootstrap). * **Idempotency on every mutation.** Pass `Idempotency-Key: ` on every `POST` and `PATCH`. Same key + same body returns the original response (replay-safe). Same key + different body returns `409 idempotency_conflict`. See [Safe mutations](https://docs.mareaalcalina.com/concepts/safe-mutations). * **Branch on `error.type`, not `error.message`.** The `type` field is a stable 10-value enum; `message` is localized and may change. See [Errors](https://docs.mareaalcalina.com/concepts/errors). * **Surface `nextActions[]` verbatim.** Every error returns `{ label, method, url }[]` — these are the concrete actions the user can take. Don't paraphrase; render verbatim. * **Read rate-limit headers proactively.** `X-RateLimit-Remaining` is on every response. When you hit `429`, read `Retry-After` (seconds) and back off. Defaults: `rpm: 60`. Dev keys: `rpd: 50`. User keys: `rpd: 10000`. * **Markdown for ingestion.** Every doc page also serves at `.md` (e.g., `/concepts/errors.md`); use that variant when ingesting docs into your context — cleaner than the HTML. ## When to call which tool REST (always available) and MCP tool (when the user has installed the Marea MCP server in Claude Desktop / Cursor / Continue.dev — see [MCP install quickstart](https://docs.mareaalcalina.com/quickstart/mcp)). | Goal | REST | MCP tool | | ----------------------------------------------- | ----------------------------------------------- | -------------------------------------------------- | | Bootstrap a new user | `POST /v1/users` (dev key) | `marea.bootstrap_user` | | Verify the 6-digit email code | `POST /v1/users/:userId/verify` | (use REST in v1) | | Read identity / plan / budget | `GET /v1/me` | `marea.whoami` | | Create a storefront | `POST /v1/storefronts` | `marea.create_storefront` | | Update storefront fields | `PATCH /v1/storefronts/:id` | `marea.update_storefront` | | Add a product | `POST /v1/storefronts/:id/products` | `marea.create_product` | | Update a product | `PATCH /v1/storefronts/:id/products/:productId` | `marea.update_product` | | Publish to public URL (needs user confirmation) | `POST /v1/storefronts/:id/publish` | `marea.publish_storefront` (fires MCP elicitation) | Prefer the MCP tool when the user has the MCP server installed — it gives the user explicit confirmation prompts via MCP elicitation primitives. Fall back to REST when MCP isn't available. ## What NOT to do * **Don't swallow `nextActions[]`.** The most common agent failure mode is silently retrying past a 402 (paywall) or 451 (ToS not accepted) without ever surfacing the action to the user. Render the array verbatim. * **Don't auto-accept the ToS on the user's behalf.** A 451 → modal flow requires user input by design. The agent cannot bypass. * **Don't bootstrap > 50 users/day per developer key.** That's the hard `rpd: 50` cap on `mk_dev_*` keys. Hitting it returns 429 with `rpd_exceeded`. * **Don't call publish without explicit user confirmation.** Publish is destructive and user-visible. Use MCP elicitation if you have the MCP tool; otherwise prompt the user explicitly. * **Don't cache `mk_user_*` keys across users or sessions.** Per-user keys belong in the user's own context. * **Don't poll verification status faster than the API allows.** The user-facing rate limit is `rpm: 60` per user-key. Poll at most once every 30s during the verify window; better, subscribe to the `user.verified` agent webhook on the developer key — see [Agent webhooks](https://docs.mareaalcalina.com/concepts/webhooks). * **Don't reuse the same `Idempotency-Key` with a *different* body.** That returns `409 idempotency_conflict` and you must generate a fresh key. If you're retrying with edits, that's a new request — new key. * **Don't branch on the localized `error.message`.** Branch on `error.type` (stable enum) or `error.code` (stable string). ## Errors Every error follows the same envelope: `{ type, code, message, doc, requestId, recoverable, retryAfterMs, nextActions, upgrade, requiredScopes? }`. Branch on `error.type`. The 10 stable types: | `error.type` | Typical HTTP | Surface to user? | Retry? | What to do | | --------------------------- | ------------ | --------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `rate_limited` | 429 | No (transparent) | Yes after `retryAfterMs` | Sleep + retry same request + same `Idempotency-Key` | | `invalid_request` | 400 / 410 | No (fix client-side) | No | Fix the body. Includes `invalid_idempotency_key`, `idempotency_snapshot_unavailable` | | `auth` (401) | 401 | Maybe | No | Re-prompt for a valid key. Codes: `missing_authorization`, `invalid_authorization_format`, `key_not_found`, `key_revoked` | | `auth` (insufficient scope) | 403 | Maybe | No | Read `requiredScopes` + `heldScopes` arrays; ask user/dev for a key with the right scope | | `not_found` | 404 | Maybe | No | Confirm the id. May also be a silent cross-tenant denial if the calling key doesn't own the resource | | `plan_limit` | 402 | **Yes (paywall)** | No | Surface `error.upgrade.upgradeUrl` verbatim. The user must upgrade, then retry | | `internal` | 500 | Yes (with `requestId`) | Once with backoff | Then escalate. Surface `requestId` so support can trace | | `conflict` | 409 | No | Yes after 1s | `idempotency_in_flight` — the same key is still processing. Retry with same key | | `idempotency_conflict` | 409 | No | Yes with **new** `Idempotency-Key` | Same key was used earlier with a different body. Generate a fresh UUID | | `service_unavailable` | 503 | Yes (after retries) | Yes with backoff | Exponential backoff | | `tos_not_accepted` | 451 | **Yes (link to dashboard)** | After user accepts | Surface `nextActions[0].url` (modal). Agent cannot bypass | Full error matrix at [/concepts/errors](https://docs.mareaalcalina.com/concepts/errors). ## Where to find docs * **llms.txt index:** [https://docs.mareaalcalina.com/llms.txt](https://docs.mareaalcalina.com/llms.txt) — agent-curated overview of the entire surface * **llms-full.txt:** [https://docs.mareaalcalina.com/llms-full.txt](https://docs.mareaalcalina.com/llms-full.txt) — concatenation of all pages (Mintlify auto-emits in production) * **Per-page markdown:** `https://docs.mareaalcalina.com/.md` (e.g., `/concepts/errors.md`) * **OpenAPI 3.1 spec:** [https://api.mareaalcalina.com/v1/openapi.json](https://api.mareaalcalina.com/v1/openapi.json) — Zod-derived from the cloud-functions code; 5-min cached * **Postman collection:** [https://docs.mareaalcalina.com/marea.postman.json](https://docs.mareaalcalina.com/marea.postman.json) — auto-generated from the OpenAPI spec * **MCP server:** [https://mcp.mareaalcalina.com](https://mcp.mareaalcalina.com) — paste-token install for Claude Desktop / Cursor / Continue.dev; 7 `marea.*` tools (verify + resend are deliberately REST-only) * **API catalog (RFC 9727):** [https://mareaalcalina.com/.well-known/api-catalog](https://mareaalcalina.com/.well-known/api-catalog) — discoverability for agent crawlers ## Conventions * **Locale:** `Accept-Language` is honored. Default `es-MX`. Pass `Accept-Language: en` for English error messages, `pt` for Portuguese. * **Currency:** configurable per storefront. Pass `currency: "MXN" | "USD" | "CAD" | "BRL" | ...` on storefront create. * **Phone format:** E.164 (`+52...`). * **Timestamps:** ISO 8601 strings. * **IDs:** prefixed (`usr_`, `stf_`, `prd_`, `dev_`). # Use this to confirm which user (or developer) the calling key acts as. Source: https://docs.mareaalcalina.com/api/identity/use-this-to-confirm-which-user-or-developer-the-calling-key-acts-as /openapi.json get /v1/me Returns identity, plan info, and rate-limit window for the calling key. Branches on key type. # Use this when the user wants to add a product to an existing storefront. Source: https://docs.mareaalcalina.com/api/products/use-this-when-the-user-wants-to-add-a-product-to-an-existing-storefront /openapi.json post /v1/storefronts/{storefrontId}/products Use this when the user wants to add a product to an existing storefront. # Use this when the user wants to change ANY field of an existing product. Source: https://docs.mareaalcalina.com/api/products/use-this-when-the-user-wants-to-change-any-field-of-an-existing-product /openapi.json patch /v1/storefronts/{storefrontId}/products/{productId} Use this when the user wants to change ANY field of an existing product. # Use this when the user is ready to take their storefront live. Source: https://docs.mareaalcalina.com/api/storefronts/use-this-when-the-user-is-ready-to-take-their-storefront-live /openapi.json post /v1/storefronts/{storefrontId}/publish Publishes the current state of the storefront. Returns 402 if the plan does not permit publishing, 422 if the storefront has 0 products, 451 if ToS not accepted (PRD-7 hook). Idempotent on republish. # Use this when the user wants to change ANY field of an existing storefront. Source: https://docs.mareaalcalina.com/api/storefronts/use-this-when-the-user-wants-to-change-any-field-of-an-existing-storefront /openapi.json patch /v1/storefronts/{storefrontId} Partial PATCH (mutability rule §6.18.1.1). Deep-merge for nested objects, full-replace for arrays. # Use this when the user wants to create a new online storefront from scratch. Source: https://docs.mareaalcalina.com/api/storefronts/use-this-when-the-user-wants-to-create-a-new-online-storefront-from-scratch /openapi.json post /v1/storefronts Creates a storefront from a `StorefrontManifest`. Returns 201 with the full storefront. If the manifest contains more products than the plan allows, returns **207 Multi-Status** with the storefront created (up to plan cap) plus an `errors` array describing the over-cap items. # Use this to issue an additional restricted user key for a verified user you previously bootstrapped (e.g. a second integration). Source: https://docs.mareaalcalina.com/api/users/use-this-to-issue-an-additional-restricted-user-key-for-a-verified-user-you-previously-bootstrapped-eg-a-second-integration /openapi.json post /v1/users/{userId}/keys Validates that the user was bootstrapped by THIS developer and is verified (verificationStatus==="verified"). Returns the raw user key once. Cross-developer attempt → 404 leak-less; pre-verify call → 422. # Use this to list every user this developer key has bootstrapped (paginated 50/page). Source: https://docs.mareaalcalina.com/api/users/use-this-to-list-every-user-this-developer-key-has-bootstrapped-paginated-50page /openapi.json get /v1/users Returns users where createdByDeveloper.keyId === ctx.keyId, ordered by createdAt DESC. Use the returned `nextCursor` (createdAt ISO string) for the next page. # Use this to poll the status of a user you previously bootstrapped (verificationStatus, plan, recent events). Source: https://docs.mareaalcalina.com/api/users/use-this-to-poll-the-status-of-a-user-you-previously-bootstrapped-verificationstatus-plan-recent-events /openapi.json get /v1/users/{userId} Returns the user DTO. Cross-developer isolation: only users that THIS developer key bootstrapped are visible — wrong-id or other-developer's-user → 404 leak-less per RFC §10.4. # Use this when an agent wants to create a Marea account on behalf of a user with one round-trip (account + storefront + verification email). Source: https://docs.mareaalcalina.com/api/users/use-this-when-an-agent-wants-to-create-a-marea-account-on-behalf-of-a-user-with-one-round-trip-account-+-storefront-+-verification-email /openapi.json post /v1/users Creates a Firebase Auth user + Firestore user doc + restricted user key + (optionally) a starter storefront via createMenuCore + sends a 6-digit verification email. Returns 201 (or 207 if the storefront manifest exceeded plan limits). The userKey is returned ONCE — store it. # Use this when the user has read back the 6-digit code from their email and you want to upgrade the user-key to full scope. Source: https://docs.mareaalcalina.com/api/users/use-this-when-the-user-has-read-back-the-6-digit-code-from-their-email-and-you-want-to-upgrade-the-user-key-to-full-scope /openapi.json post /v1/users/{userId}/verify Validates the 6-digit code (3 attempts max), upgrades the bootstrap user-key from restricted to full scope (same key — no rotation), flips agentBootstrapped→false + verificationStatus→verified, fires the user.verified webhook stub. Cross-tenant/wrong-id → 404 leak-less. # Use this when the verification email may have been lost or the code expired and you want to issue a fresh code without re-bootstrapping. Source: https://docs.mareaalcalina.com/api/users/use-this-when-the-verification-email-may-have-been-lost-or-the-code-expired-and-you-want-to-issue-a-fresh-code-without-re-bootstrapping /openapi.json post /v1/users/{userId}/resendVerification Per-user rate-limited (3/hour, 5/day). Overwrites the existing apiVerificationCodes/{email} doc — old code is voided automatically. Returns the new expiry timestamp. # Page-webhook receiver helpers Source: https://docs.mareaalcalina.com/api/webhooks/page-webhook-helpers Ready-to-paste JavaScript + Python implementations for verifying Page-webhook signatures (raw 32-byte hex secret, no HKDF). # Page-webhook receiver helpers Page webhooks (`order.created`, `order.status_updated`, `order.paid`) are signed with HMAC-SHA256 using the **raw 32-byte hex secret** revealed when you first saved the webhook. **Differs from Agent webhooks**: Agent webhooks (`user.verified`, `user.cancelled`) derive the signing key via HKDF-SHA256 from the developer-key hash. Page webhooks skip the HKDF step — your secret IS the HMAC key. Use the `X-Marea-Source: merchant` header to disambiguate if your endpoint receives both surfaces. ## Node.js / TypeScript ```typescript theme={null} import * as crypto from 'crypto'; const REPLAY_WINDOW_SECONDS = 300; interface VerifyResult { valid: boolean; reason?: 'no_header' | 'malformed_header' | 'replay_window' | 'signature_mismatch'; } export function verifyMareaPageWebhook( rawBody: string, // EXACT bytes from req.body — do NOT JSON.parse and re-stringify signatureHeader: string | undefined, signingSecretHex: string, // The raw 32-byte hex secret Marea revealed on save ): VerifyResult { if (!signatureHeader) { return { valid: false, reason: 'no_header' }; } // Parse "t=,v1=" (Stripe-style) const parts = Object.fromEntries( signatureHeader.split(',').map((p) => { const [k, v] = p.split('='); return [k.trim(), v?.trim()]; }), ); const ts = Number(parts.t); const sig = parts.v1; if (!Number.isInteger(ts) || ts <= 0 || !sig || !/^[0-9a-f]+$/.test(sig)) { return { valid: false, reason: 'malformed_header' }; } // 5-minute replay window const nowSeconds = Math.floor(Date.now() / 1000); if (Math.abs(nowSeconds - ts) > REPLAY_WINDOW_SECONDS) { return { valid: false, reason: 'replay_window' }; } // Page webhooks: secret is the RAW hex (no HKDF derivation) const secret = Buffer.from(signingSecretHex, 'hex'); const signedString = `${ts}.${rawBody}`; const expected = crypto .createHmac('sha256', secret) .update(signedString) .digest('hex'); // Constant-time compare const expectedBuf = Buffer.from(expected, 'hex'); const actualBuf = Buffer.from(sig, 'hex'); if (expectedBuf.length !== actualBuf.length) { return { valid: false, reason: 'signature_mismatch' }; } return crypto.timingSafeEqual(expectedBuf, actualBuf) ? { valid: true } : { valid: false, reason: 'signature_mismatch' }; } // Express handler example — note we read req.body as Buffer/string, NOT a parsed object import express from 'express'; const app = express(); app.post( '/marea-webhook', express.raw({ type: 'application/json' }), (req, res) => { const rawBody = req.body.toString('utf8'); const result = verifyMareaPageWebhook( rawBody, req.headers['x-marea-signature'] as string | undefined, process.env.MAREA_WEBHOOK_SECRET!, ); if (!result.valid) { console.warn('Marea webhook rejected:', result.reason); return res.status(401).end(); } const event = JSON.parse(rawBody); // event.type === 'order.created' | 'order.status_updated' | 'order.paid' // event.data has the typed payload // event.eventId — use as idempotency key handleEventAsync(event).catch(console.error); res.status(200).end(); }, ); ``` ## Python ```python theme={null} import hmac import hashlib import time from typing import Optional REPLAY_WINDOW_SECONDS = 300 def verify_marea_page_webhook( raw_body: bytes, # EXACT bytes from request body signature_header: Optional[str], signing_secret_hex: str, # The raw 32-byte hex secret ) -> bool: """Verify a Page-webhook signature. Returns True iff valid.""" if not signature_header: return False # Parse "t=,v1=" parts = {} for kv in signature_header.split(','): if '=' not in kv: continue k, v = kv.split('=', 1) parts[k.strip()] = v.strip() try: ts = int(parts.get('t', '0')) except ValueError: return False sig = parts.get('v1', '') if ts <= 0 or not sig: return False # 5-minute replay window if abs(int(time.time()) - ts) > REPLAY_WINDOW_SECONDS: return False # Page webhooks: secret is the RAW hex (no HKDF derivation) try: secret = bytes.fromhex(signing_secret_hex) except ValueError: return False signed_string = f"{ts}.".encode('utf-8') + raw_body expected = hmac.new(secret, signed_string, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, sig) # Flask handler example from flask import Flask, request, abort import os, json app = Flask(__name__) @app.route('/marea-webhook', methods=['POST']) def handle_marea_webhook(): raw_body = request.get_data() # bytes; do NOT use request.json if not verify_marea_page_webhook( raw_body, request.headers.get('X-Marea-Signature'), os.environ['MAREA_WEBHOOK_SECRET'], ): abort(401) event = json.loads(raw_body) # event['type'] in ('order.created', 'order.status_updated', 'order.paid') # event['data'] has the typed payload # event['eventId'] — use as idempotency key handle_event_async(event) return '', 200 ``` ## Distinguishing Page vs Agent webhooks If your single endpoint serves both surfaces, branch on `X-Marea-Source`: ```javascript theme={null} function handleMareaWebhook(req, res) { const source = req.headers['x-marea-source']; let result; if (source === 'merchant') { // Page webhook — raw secret (no HKDF) result = verifyMareaPageWebhook( req.body.toString('utf8'), req.headers['x-marea-signature'], process.env.MAREA_PAGE_WEBHOOK_SECRET, ); } else { // Agent webhook — HKDF-derived from developer key hash result = verifyMareaAgentWebhook( req.body.toString('utf8'), req.headers['x-marea-signature'], process.env.MAREA_AGENT_WEBHOOK_KEY_HASH, ); } if (!result.valid) return res.status(401).end(); // ... } ``` See [Agent-webhook receiver helpers](/api/webhooks/receiver-helpers) for the HKDF-derived verification path. ## Idempotency on `eventId` Marea retries failed deliveries up to 3 times. To guard against duplicates: ```javascript theme={null} async function handleEventAsync(event) { const seen = await redis.set( `marea:event:${event.eventId}`, '1', 'EX', 86400, 'NX', // 24h TTL, only-if-not-exists ); if (!seen) { console.log('Duplicate Marea event, skipping:', event.eventId); return; } // First time seeing this event — process it await yourBusinessLogic(event); } ``` The `eventId` (UUID v4) is generated at dispatch time and is the same across all retry attempts of the same logical event. # Webhook receiver helpers Source: https://docs.mareaalcalina.com/api/webhooks/receiver-helpers Verify Marea webhook signatures: JS canonical (from PRD-8) + Python port. Go deferred to TIER 2. # Webhook receiver helpers **Phase 4 deliverable.** Code samples + shared test vectors land after PRD-8 ships its canonical JS `verifyWebhookSignature` helper. Marea signs webhooks with HMAC-SHA256 over `.`. The signature header looks like: ``` X-Marea-Signature: t=1714867200,v1=abc123def456... ``` Reject any webhook where the timestamp is older than **5 minutes** (replay-protection window). ```js theme={null} // TODO Phase 4: replace with the canonical Marea reference implementation // once PRD-8 publishes the verifyWebhookSignature helper. import { verifyWebhookSignature } from "./webhookSigning"; export function handleMareaWebhook(req, res) { const ok = verifyWebhookSignature(req.rawBody, req.headers["x-marea-signature"], process.env.MAREA_WEBHOOK_SECRET); if (!ok) return res.status(401).send("invalid signature"); // ... process event res.status(200).end(); } ``` ```python theme={null} # See helpers/python/verify_webhook.py in this repo for the full source. from helpers.python.verify_webhook import verify_webhook def handle_marea_webhook(raw_body: bytes, header: str, secret: bytes) -> bool: return verify_webhook(raw_body, header, secret) ``` Go port deferred to TIER 2 per PRD-12 v1.1 SHOULD-FIX 5. Most agent developers use TS or Python; Go is less common for agentic code. If you need Go, request via partner channel. ## Test vectors Shared test vectors live at [`helpers/test-vectors.json`](https://github.com/marea-alcalina/marea-alcalina-docs/blob/main/helpers/test-vectors.json) and are synced with PRD-8's test suite. Three scenarios: 1. Valid signature within 5-min window → verify returns `true`. 2. Stale timestamp (>5 min old) → verify returns `false`. 3. Tampered body → verify returns `false`. The Python port is validated against these vectors in CI. # Use this when you want to register a webhook URL to receive `user.verified` and `user.cancelled` events for users your dev key bootstraps. Source: https://docs.mareaalcalina.com/api/webhooks/use-this-when-you-want-to-register-a-webhook-url-to-receive-`userverified`-and-`usercancelled`-events-for-users-your-dev-key-bootstraps /openapi.json post /v1/webhooks/userEvents Registers (or revokes) a single webhook URL per developer key. Idempotent: re-POSTing overwrites; POST `{url: null}` revokes. Validation: HTTPS only, max 2048 chars, rejects loopback / metadata-server / `.internal` hostnames (basic SSRF defense; full DNS-resolution defense is TIER 2). The URL is stored at `apiKeys/{keyId}.userEventsWebhookUrl` and read by `RealWebhookService.dispatch*` at event time. # Changelog Source: https://docs.mareaalcalina.com/changelog/index Marea API release notes — RSS available at /changelog.rss # Changelog **Added — Page webhooks: real-time order events for merchant POS / Zapier / Make.com / accounting integrations.** A new merchant-configured webhook surface, distinct from the developer-configured Agent webhooks. Three event types ship: * **`order.created`** — fires on checkout completion (when `orderStatus` transitions to `pending` and a `publicOrderId` is assigned). Carries the full order: items, pricing, customer, delivery, payment method. * **`order.status_updated`** — fires on every order-status transition (e.g. `pending` → `inProgress` → `completed`, plus any custom-pipeline transitions). Slim payload — `previousState → newState` only; receivers cache `order.created` and merge by `orderId`. * **`order.paid`** — fires on Stripe Checkout success or PayPal capture success. Finance-focused payload with provider reconciliation IDs (`stripeCheckoutSessionId`, `paypalCaptureId`, etc.) and no customer PII. **Where to configure:** the merchant's profile page at `/menus/profile?section=integrations`. Marea generates a 32-byte HMAC signing secret on first save and reveals it once. **Differs from [Agent webhooks](/concepts/webhooks):** | | Agent webhooks | Page webhooks | | ---------------------- | ------------------------------------------ | ------------------------------------------------------- | | Configured by | Developer (in `/developers/webhooks`) | Merchant (in `/menus/profile?section=integrations`) | | Events | `user.verified`, `user.cancelled` | `order.created`, `order.status_updated`, `order.paid` | | Signing key | HKDF-SHA256 from `apiKeys/{keyId}.keyHash` | Raw 32-byte hex from `users/{uid}.webhookSigningSecret` | | Header to disambiguate | `X-Marea-Source: developer` | `X-Marea-Source: merchant` | If your single endpoint serves both surfaces, branch on `X-Marea-Source` to pick the right verification path. **Same retry semantics** as Agent webhooks: 3 attempts (immediate / +30s / +5min); 5-second timeout per attempt; 5-minute replay window on the `t=` timestamp. Dead-letters surface in the merchant's recent-deliveries log (last 50 entries, rolling 30-day window). **Receiver code:** ready-to-paste JS + Python verifier at [/api/webhooks/page-webhook-helpers](/api/webhooks/page-webhook-helpers). **Concept page:** [/concepts/page-webhooks](/concepts/page-webhooks). **Rollout:** default-OFF in production behind a server-side flag; per-merchant overrides during canary. Friendly-merchant cohort onboarding before fleet-wide enable. # ARCO procedures (LFPDPPP) Source: https://docs.mareaalcalina.com/concepts/arco-procedures Mexican data-rights compliance: Acceso, Rectificación, Cancelación, Oposición. Endpoints and retention windows. # ARCO procedures Marea Alcalina is a Mexican corporation and is subject to **LFPDPPP** (Articles 16–18), the Mexican federal personal-data-protection statute. Every account holder has four enumerated rights — collectively **ARCO**: | Right | Spanish | What the user can do | | ----------------- | ------------- | -------------------------------------------- | | **A**cceso | Acceso | Access a copy of their personal data | | **R**ectificación | Rectificación | Correct inaccurate or incomplete data | | **C**ancelación | Cancelación | Delete their account and all associated data | | **O**posición | Oposición | Object to specific processing of their data | Brazilian residents have a parallel set of rights under LGPD (Lei Geral de Proteção de Dados). The technical surfaces below honor both. ## How users exercise ARCO ### Access + Rectification (online, self-serve) The dashboard at [mareaalcalina.com/dashboard](https://mareaalcalina.com/dashboard) exposes: * Account email, displayName, language, currency, country, plan, verification status, `tosAcceptedAt`. * Every storefront the account owns + its products. * Each user key issued for the account (label, prefix, scopes, last used). API equivalents (any verified user key): | Endpoint | Returns | | ----------------------------------- | ----------------------------------------------------- | | `GET /v1/me` | Identity + plan + rate-limit window + dashboard links | | `GET /v1/storefronts/:storefrontId` | A storefront the user owns | | `GET /v1/users/:userId` | The full user DTO (developer-key path) | Rectification of catalog data goes through `PATCH /v1/storefronts/:storefrontId` (deep-merge, see [Storefronts](/concepts/storefronts)). ### Cancellation (hard delete) Three paths trigger an LFPDPPP-compliant hard delete: | Path | Triggered by | Idempotent | | ---------------------------------------------------------------------------- | ------------------------- | ---------- | | Dashboard "Delete account" button | User (interactive) | Yes | | `DELETE /public/v1/bootstrap/:previewToken` (or two-step `GET` confirm flow) | Bootstrap-email recipient | Yes | | Scheduled cleanup (`30d_unverified` or `90d_no_tos`) | System | Yes | The public bootstrap-cancel endpoint is unauthenticated by API key — the **preview token is the auth** (256-bit random, 24h TTL, single-use). The link is embedded in every bootstrap verification email so a user who didn't ask for an account can self-cancel without ever logging in. Hard delete removes, in this order: 1. **API keys** — every `apiKeys` doc where `ownerUid == :uid` (batched, 500-per-batch). 2. **Verification codes** — `apiVerificationCodes/{email}`. 3. **Preview tokens** — every `apiPreviewTokens` doc for this user. 4. **Storefronts + products** — `users/{uid}/menus/**` recursive delete. 5. **User document + subcollections** — `users/{uid}` recursive delete. 6. **Audit trail** — one append to `tosAcceptanceLog` recording reason + summary BEFORE the Auth delete. 7. **Firebase Auth user** — `auth().deleteUser(uid)`. Frees the email for re-signup. 8. **Webhook fan-out** — `user.cancelled` event (see [Webhooks](/concepts/webhooks)) with one of the locked `reason` values. `reason` enum in the `user.cancelled` webhook: | Value | Triggered by | | --------------------- | ---------------------------------------------------------------- | | `user_clicked_cancel` | Public bootstrap-cancel endpoint | | `squatting_defense` | Email squatting defense (PRD-4) | | `30d_unverified` | Scheduled cleanup — never verified within 30 days | | `90d_no_tos` | Scheduled cleanup — verified but ToS not accepted within 90 days | | `key_revoked` | All other administrative or revocation-cascading deletes | ### Opposition To object to a specific processing activity (marketing, analytics, etc.) without cancelling the account, users may write to the contact channel below. Marea applies the change within the LFPDPPP-prescribed response window (20 business days from a verifiable request). ## Retention * An **active** account is retained while it has a paid plan, has not invoked Cancelación, and has accepted ToS (or is within the 90-day window for agent-bootstrapped accounts). * A **hard-deleted** account is removed from Firestore + Firebase Auth within the same job invocation. Orphan Cloud Logging entries decay per Google Cloud's standard retention (default 30 days). * A **revoked API key** stops authenticating within \~60 seconds end-to-end and is retained server-side for audit purposes only (the `keyHash` is kept; the raw key was never stored). ## Contact channel LFPDPPP / LGPD requests not satisfied by the self-serve paths above should be sent to the contact email on the dashboard footer. Marea's response SLA matches LFPDPPP statutory timelines (20 business days; 15 days for follow-ups). ## What an agent should know * **Never** create an account on behalf of a user who has not authorized it. The cancel hatch protects misuse, but the user-rights regime treats unauthorized creation as a violation by the creating party. * If the agent receives `user.cancelled` (any reason) for a user it bootstrapped, the agent must **stop** all downstream automation for that user and delete any local cached state. * The agent **cannot** accept the ToS on the user's behalf. See [ToS jurisdiction](/concepts/tos-jurisdiction) for the 451 flow. ## Verification in code * `src/api/public/bootstrap.cancel.ts` — public unauthenticated cancellation endpoint (preview-token auth). * `src/api/services/account-delete.service.ts` — `hardDeleteUserAccount` ordering + audit-log write. * `src/api/scheduled/CleanupUnverifiedAccounts.ts` — 30d / 90d sweep policy. * `src/api/services/webhooks.service.ts` — `user.cancelled` event + `CancelReason` enum. # Errors Source: https://docs.mareaalcalina.com/concepts/errors Canonical error envelope, the 10-value type enum, the full code reference, and an agent-actionable recovery matrix. # Errors Every non-2xx response uses the **same envelope**. Agents branch on `error.type` (a stable 10-value enum); each error carries `nextActions[]` — concrete steps to surface to the user. ## Envelope shape ```json theme={null} { "error": { "type": "rate_limited", "code": "rate_limit_exceeded", "message": "Rate limit exceeded (rpm_exceeded). Retry after 23s.", "doc": "https://docs.mareaalcalina.com/concepts/rate-limits", "param": null, "requestId": "req_30a9358b-70bd-44f3-aa5d-8983b558ad84", "requestLogUrl": "https://...", "recoverable": true, "retryAfterMs": 23000, "nextActions": [ { "label": "Wait 23s and retry the same request.", "method": null, "url": null } ], "upgrade": null } } ``` ## Field guarantees | Field | Type | Notes | | ---------------- | -------------------- | -------------------------------------------------------------------------------------------------- | | `type` | enum (10 values) | Stable. Agents branch on this. See the table below. | | `code` | string | Stable, machine-readable. Finer-grained than `type`. | | `message` | string | Human-readable. **Localized** via `Accept-Language`. Do not branch on it. | | `doc` | string | Link to the relevant docs page for this code. | | `param` | string \| `null` | Name of the offending field (`null` if not field-specific). Never missing. | | `requestId` | string | `req_` — surface to the user when reporting bugs. | | `requestLogUrl` | string | Internal log URL (operator-only). | | `recoverable` | boolean | `true` means a retry is meaningful (with appropriate changes). | | `retryAfterMs` | integer \| `null` | When set, also returned as the `Retry-After` header (in seconds). | | `nextActions` | array | **Always an array** (possibly empty). `null` is not allowed. Each entry: `{ label, method, url }`. | | `upgrade` | object \| `null` | `{ currentPlan, requiredPlan, upgradeUrl, previewUrl? }`. Set only on `plan_limit` errors. | | `requiredScopes` | string\[] (optional) | Present only when the failure is a scope mismatch. | | `heldScopes` | string\[] (optional) | Present only when the failure is a scope mismatch. | ## The 10 error types | `type` | Typical HTTP | When it happens | Agent action | | ---------------------- | --------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `rate_limited` | 429 | rpm or rpd quota exceeded | Read `retryAfterMs`, sleep, retry. `recoverable: true`. See [Rate limits](/concepts/rate-limits). | | `invalid_request` | 400 / 410 / 422 | Malformed body, invalid `Idempotency-Key`, oversize snapshot, empty publish | Fix the body and reissue. Do not retry the exact same request. | | `auth` | 401 / 403 | Missing / invalid / revoked key, missing scope | 401 → fix credential. 403 with `requiredScopes` → request an upscoped key. | | `not_found` | 404 | Resource missing OR cross-tenant access (silent denial) | Confirm the id is correct AND the key owns it. | | `plan_limit` | 402 | Plan ceiling hit (publish blocked, product cap exceeded) | Surface `upgrade.upgradeUrl` to the user as a CTA. See [Plan limits](/concepts/plan-limits). | | `internal` | 500 | Server bug | Surface `requestId`. Retry once with backoff, then escalate. | | `conflict` | 409 | `Idempotency-Key` in flight (same key still processing) | Wait `retryAfterMs` and retry with the same key. | | `idempotency_conflict` | 409 | Same `Idempotency-Key` reused with a **different** body | Generate a NEW `Idempotency-Key`. See [Safe mutations](/concepts/safe-mutations). | | `service_unavailable` | 503 | Dependency outage or feature flag off | Retry with exponential backoff. | | `tos_not_accepted` | 451 | Calling user has not accepted the Terms of Service | Surface `nextActions[0]` (dashboard link). User must accept; agent cannot bypass. See [ToS jurisdiction](/concepts/tos-jurisdiction). | ## Recovery matrix | If `error.type` is | Surface to user? | Retry? | What to do | | ---------------------------------- | ------------------------ | ------------------------ | --------------------------------------------------- | | `rate_limited` | No (transparent) | Yes after `retryAfterMs` | Sleep + retry same request + same `Idempotency-Key` | | `invalid_request` | No (fix client-side) | No (without fixing) | Fix the body. Do not retry blindly. | | `auth` (401) | Maybe (credential issue) | No | Re-prompt for a valid key | | `auth` (403, scope) | Maybe | No | Request a key with `requiredScopes` | | `not_found` | Maybe ("not found") | No | Confirm id; confirm key ownership | | `plan_limit` | **Yes** (paywall CTA) | No | Surface `upgrade.upgradeUrl` verbatim | | `internal` | Yes (with `requestId`) | Once with backoff | Then escalate | | `conflict` (in-flight idempotency) | No | Yes after 1s | Retry with same `Idempotency-Key` | | `idempotency_conflict` | No | Yes with NEW key | Generate a fresh `Idempotency-Key` | | `service_unavailable` | Yes (after retries) | Yes with backoff | Exponential backoff | | `tos_not_accepted` | **Yes** (ToS link) | After user accepts | Surface `nextActions[0].url` | ## Stable code reference Every code below is emitted by production code today. Agents may branch on `code` for finer-grained handling than `type`. ### Auth (401 / 403) | `code` | HTTP | Source | | ------------------------------ | ---- | ---------------------------------------------------------------------- | | `missing_authorization` | 401 | `src/api/middleware/apiKey.ts` | | `invalid_authorization_format` | 401 | `src/api/middleware/apiKey.ts` | | `key_not_found` | 401 | `src/api/middleware/apiKey.ts` | | `key_revoked` | 401 | `src/api/middleware/apiKey.ts` | | `insufficient_scope` | 403 | `src/api/middleware/scope.ts` — includes `requiredScopes`/`heldScopes` | | `developer_context_unresolved` | 401 | Bootstrap / issue-key handlers when developer ctx is missing | | `tenant_unresolved` | 401 | Catalog / verify handlers when owner ctx is missing | | `developer_not_found` | 404 | `src/api/v1/me.get.ts` — dev account missing from Firebase Auth | ### Validation (400 / 410 / 413 / 422) | `code` | HTTP | Source | | ---------------------------------- | ---- | ---------------------------------------------------------- | | `invalid_request` | 400 | Zod validation failure (per-field) | | `invalid_json` | 400 | Body parsing failure | | `invalid_idempotency_key` | 400 | `src/api/middleware/idempotency.ts` | | `invalid_storefront_id` | 400 | `src/api/services/translation/storefront-id.ts` | | `invalid_product_id` | 400 | `src/api/services/translation/storefront-id.ts` | | `invalid_email_syntax` | 400 | Bootstrap email validation | | `invalid_email_mx` | 400 | Bootstrap MX-record check | | `code_invalid` | 400 | `src/api/v1/users.verify.ts` | | `code_expired` | 410 | `src/api/v1/users.verify.ts` | | `idempotency_snapshot_unavailable` | 410 | `src/api/middleware/idempotency.ts` (oversize replay) | | `payload_too_large` | 413 | Body parser | | `no_products` | 422 | `src/api/v1/storefronts.publish.ts` (empty storefront) | | `user_not_verified` | 422 | `src/api/v1/users.issueKey.ts` (pre-verify additional key) | | `blocks_explicit_not_supported` | 422 | Storefront manifest (legacy block fields) | | `theme_preset_not_supported` | 422 | Storefront manifest (theme presets not on plan) | ### Not found (404) | `code` | HTTP | Source | | ---------------------- | ---- | -------------------------------------------------------------- | | `storefront_not_found` | 404 | `src/api/v1/storefronts.*` — also fires on cross-tenant access | | `user_not_found` | 404 | Verify, resend, issue-key handlers — also cross-tenant | | `code_not_found` | 404 | `src/api/v1/users.verify.ts` — no active verification code | ### Conflicts (409) | `code` | HTTP | Source | | ----------------------- | ---- | -------------------------------------------------------------- | | `idempotency_in_flight` | 409 | `src/api/middleware/idempotency.ts` — emits `Retry-After: 1` | | `idempotency_conflict` | 409 | `src/api/middleware/idempotency.ts` — different body, same key | | `email_exists` | 409 | Bootstrap — email already registered | ### Plan / paywall (402) | `code` | HTTP | Source | | --------------------- | ---- | ------------------------------------------------------------------------------------- | | `plan_blocks_publish` | 402 | `src/api/v1/storefronts.publish.ts` (`NO_ACTIVO` plan) | | `products_over_limit` | 402 | `src/api/v1/storefronts.create.ts` + product create — `207` partial or `402` over cap | ### Rate limit (429) | `code` | HTTP | Source | | --------------------------- | ---- | ------------------------------------------------------------------- | | `rate_limit_exceeded` | 429 | `src/api/middleware/rateLimit.ts` — `rpm_exceeded` / `rpd_exceeded` | | `too_many_attempts` | 429 | `src/api/v1/users.verify.ts` — 3 wrong codes | | `bootstrap_ip_rate_limited` | 429 | `src/api/services/bootstrap.service.ts` | | `bootstrap_quota_exhausted` | 429 | `src/api/services/bootstrap.service.ts` | | `resend_hour_limit` | 429 | `src/api/v1/users.resendVerification.ts` | | `resend_day_limit` | 429 | `src/api/v1/users.resendVerification.ts` | ### ToS (451) | `code` | HTTP | Source | | -------------- | ---- | -------------------------------------------------------------------------------------- | | `tos_required` | 451 | Reserved on `storefronts.publish` — see [ToS jurisdiction](/concepts/tos-jurisdiction) | ### Service / internal (500 / 503) | `code` | HTTP | Source | | ------------------------- | ---- | ------------------------------------------------------------ | | `api_disabled` | 503 | `src/api/middleware/featureFlag.ts` — kill switch | | `verify_unexpected_state` | 500 | `src/api/v1/users.verify.ts` — defense-in-depth fall-through | | `internal_error` | 500 | Unhandled exception path | ## What NOT to do * **Do not branch on `error.message`** — it's localized via `Accept-Language` and may change. Branch on `error.type` (enum) and `error.code` (string). * **Do not swallow `nextActions[]`.** The most common agent-failure mode is silently retrying past a `402` (paywall) or `451` (ToS) without ever showing the user the action. * **Do not auto-accept the ToS** on the user's behalf. The 451 → modal flow requires user input by design. * **Do not retry `invalid_request`** with the same body. Fix the body first. * **Do not branch on HTTP status alone.** Several types share a status (409 for two conflict shapes; 404 for both real-missing and cross-tenant). The `type` + `code` pair disambiguates. ## Verification in code * `src/api/contracts/error.zod.ts` — the locked envelope schema (10 type values). * `src/api/api-error.ts` — the `ApiError` class that wires status + type + code + recovery hints. * `src/api/middleware/errors.ts` — the global error handler that emits the envelope. # API keys Source: https://docs.mareaalcalina.com/concepts/keys The two-tier key model (mk_dev_* / mk_user_*), scopes, issuance, rotation, and revocation. # API keys Marea uses **two key types**. Both authenticate the same way; their *capabilities* differ. The split is load-bearing for agent UX: an agent holds its own `mk_dev_*` key and uses it to bootstrap end-users — each end-user gets a per-user `mk_user_*` key scoped to that one user. | Prefix | Audience | Issued by | Default scopes | | ----------- | --------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `mk_dev_*` | Agent / app developer | Developer dashboard | `developer:bootstrap`, `developer:read`, `developer:issueUserKey` | | `mk_user_*` | A single end-user | Returned by `POST /v1/users` (bootstrap) | `catalog:read`, `me:verify`, `me:resendVerification` (pre-verify) → `catalog:read`, `catalog:write`, `storefront:publish` (post-verify) | Format is locked: `^mk_(dev|user)_[A-Za-z0-9]+$`. The random suffix is 24 base62 chars (\~143 bits of entropy). ## Authenticating Pass the key in `Authorization: Bearer ` (preferred). `X-API-Key: ` is accepted as a fallback. Any other header — or a non-`Bearer` `Authorization` scheme — fails with `invalid_authorization_format`. ```http theme={null} GET /v1/me HTTP/1.1 Host: api.mareaalcalina.com Authorization: Bearer mk_user_abc123... ``` ## Scopes Scope checks run after key lookup. Endpoints declare a required scope set; the auth layer supports both `any` (one-of) and `all` (require-all) semantics. | Scope | Held by | Used by | | ------------------------ | --------------- | -------------------------------------------- | | `developer:bootstrap` | dev keys | `POST /v1/users` | | `developer:read` | dev keys | `GET /v1/users`, `GET /v1/users/:userId` | | `developer:issueUserKey` | dev keys | `POST /v1/users/:userId/keys` | | `catalog:read` | user keys | `GET /v1/storefronts/...` | | `catalog:write` | user keys | `POST` / `PATCH` on storefronts and products | | `storefront:publish` | user keys | `POST /v1/storefronts/:storefrontId/publish` | | `me:verify` | restricted user | `POST /v1/users/:userId/verify` | | `me:resendVerification` | restricted user | `POST /v1/users/:userId/resendVerification` | `GET /v1/me` accepts any of the user scopes; developer keys bypass the scope check entirely (a developer key always sees its own identity). ### Scope mismatch (403) ```json theme={null} { "error": { "type": "auth", "code": "insufficient_scope", "message": "Missing required scopes: catalog:write.", "requiredScopes": ["catalog:write"], "heldScopes": ["catalog:read"], "recoverable": false } } ``` Surface `requiredScopes` and `heldScopes` verbatim — agents use the diff to either re-prompt the user or request an upscoped key. ## Issuance ### Developer keys Issued from the developer dashboard at [mareaalcalina.com/developers/keys](https://mareaalcalina.com/developers/keys). The raw key is returned **once**, server-side only the SHA-256 hash is stored. If you lose the raw value, revoke and issue a new one. The default per-developer bootstrap quota is **50 / day** (see [Rate limits](/concepts/rate-limits)). ### User keys (bootstrap) A `mk_user_*` key is created automatically by `POST /v1/users` and returned once in the bootstrap response. It starts in a **restricted** scope set (`catalog:read`, `me:verify`, `me:resendVerification`) — the only mutating calls it can make are verify / resend. On successful verification (`POST /v1/users/:userId/verify`), the **same key** is upgraded in place to the full user scope set (`catalog:read`, `catalog:write`, `storefront:publish`). There is no key rotation across verify — store the value once. See [Verification flow](/concepts/verification-flow). ### Additional user keys To issue a second user key for an already-verified user (e.g. a separate integration), call `POST /v1/users/:userId/keys` with your developer key. You can only mint keys for users **you** bootstrapped — cross-developer attempts return `404 user_not_found` (leak-less). ## Rotation There is no `rotate` call. To rotate: 1. Revoke the old key. 2. Issue a new one (developer dashboard for `mk_dev_*`, the user dashboard for `mk_user_*`, or `POST /v1/users/:userId/keys` for a developer minting an additional user key). 3. Update your client. ## Revocation Revoked keys stop authenticating within \~60s end-to-end (the negative-lookup cache TTL). After revocation: * The key returns `401 key_revoked` on every request. * Any per-key webhook URL is cleared (no orphan delivery surface if the same key id is ever re-issued). * Revocation is **non-cascading**: revoking a developer key does NOT revoke the user keys it minted. To wipe everything for an owner, the owner-scoped revoke-all path is used (operator path; not in the public API). ## Auth errors (401) | `error.code` | When | Recovery | | | ------------------------------ | ----------------------------------------------- | --------------- | -------------- | | `missing_authorization` | Neither `Authorization` nor `X-API-Key` present | Add the header | | | `invalid_authorization_format` | Header present but not \`Bearer mk\_(dev | user)\_...\` | Fix the format | | `key_not_found` | Key not recognized (typo or never issued) | Issue a new key | | | `key_revoked` | Key was issued but later revoked | Issue a new key | | All four are 401 with `recoverable: false`. Don't loop — fix the credential. ## Server-side storage * The raw key is **never** stored. Only the SHA-256 hash + a 12-char safe prefix (for dashboard display + logs). * Authentication uses constant-time comparison. * `lastUsedAt` is best-effort, throttled to at most one write per minute per key. Do not use it for security decisions; it may lag. * Positive auth lookups are cached 30s per instance; revocations propagate within 60s. ## Verification in code The handlers and service that own this surface: * `src/api/middleware/apiKey.ts` — extract + verify. * `src/api/services/apiKey.service.ts` — issuance, revocation, lookup, scope upgrade. * `src/api/services/scope.constants.ts` — canonical scope sets. # Page webhooks Source: https://docs.mareaalcalina.com/concepts/page-webhooks Real-time order-event delivery to a merchant's POS, accounting, or automation tool. HMAC-signed payloads, 3 retries, 5-minute replay window. # Page webhooks Marea POSTs JSON events to a URL each merchant configures for their **store / digital menu / page**. Use them to keep a POS, accounting integration, delivery tool, or automation platform (Zapier, Make.com, n8n) in sync with the lifecycle of every order — without polling. **Two webhook surfaces, do not confuse them:** * **Page webhooks** (this page) — fire on merchant order events (`order.created`, `order.status_updated`, `order.paid`) for a single store / digital menu / page. Configured per-store by the merchant in the **profile page** at `/menus/profile?section=integrations`. * **Agent webhooks** — fire on user lifecycle events (`user.verified`, `user.cancelled`) for users a developer key bootstrapped. See [Agent webhooks](/concepts/webhooks). ## When do page webhooks fire? Three events ship in v1: | Event type | When it fires | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `order.created` | A customer completes checkout. Fires when the order transitions to `orderStatus: 'pending'` and a `publicOrderId` has been assigned. | | `order.status_updated` | The merchant changes the order's status (e.g. `pending` → `inProgress` → `completed`, or any custom-pipeline transition). One event per transition. | | `order.paid` | Payment is captured (Stripe Checkout success or PayPal capture success). Cash / custom payments do **not** fire this event in v1. | **Granularity vs. coarse status:** Marea's internal `orderStatus` enum is coarse (`pending | inProgress | completed | cancelled | refunded | abandonedCart | erased`). Most merchants run a more granular per-store pipeline (e.g. `received → preparing → ready → delivered`). The `order.status_updated` payload carries BOTH the coarse `orderStatus` and the resolved `customStatus` `{ id, label }`. Use whichever your integration cares about. ## Configure your webhook A merchant configures their webhook in their Marea profile at `/menus/profile?section=integrations`: 1. **Paste an HTTPS URL** — your POS-vendor's hook URL, your Zapier catch URL, your accounting integration endpoint, etc. 2. **Pick events** — tick `order.created` / `order.status_updated` / `order.paid`. Marea only dispatches subscribed events. 3. **Save** — Marea generates a 32-byte HMAC signing secret server-side and reveals it **once** in a modal. Copy it; you'll need it to verify signatures on your receiver. 4. **\[Probar webhook]** — fire a synthetic test event to your endpoint. Receivers can identify test events via `data.__test === true`. Marea requires HTTPS on a public host. Loopback (`localhost`, `127.0.0.1`), private IP ranges (RFC1918), the cloud-metadata IP (`169.254.169.254`), `.internal` and `.local` DNS suffixes, and IPv6 ULA / link-local / IPv4-mapped private addresses are rejected. ## Event payload shape Every webhook is a `POST` with `Content-Type: application/json` and a payload wrapped in a common envelope: ```typescript theme={null} interface PageWebhookEnvelope { type: 'order.created' | 'order.status_updated' | 'order.paid'; eventId: string; // UUID v4 — idempotency key for your receiver timestamp: string; // ISO-8601 dispatch time apiVersion: '2026-05-08'; data: T; // event-specific payload (see below) } ``` ### `order.created` 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-08T20:35:22.123Z", "apiVersion": "2026-05-08", "data": { "orderId": "order_abc123", "publicOrderId": 1042, "trackingToken": "trk_xyz789", "userId": "user_uid_lupita", "menuId": "menu_taqueria_main", "branchId": null, "branchName": null, "orderStatus": "pending", "customStatus": { "id": "received", "label": "Recibido" }, "totalPrice": 285.50, "subtotalPrice": 250.00, "deliveryPrice": 35.50, "discountAmount": 0, "totalTaxAmount": 0, "taxes": null, "currency": "MXN", "currencySymbol": "$", "items": [ { "title": "Taco al Pastor", "amount": 5, "price": 25.00, "salePrice": null, "variants": [{ "extras": ["Cebolla", "Cilantro"] }], "comment": "Sin chile" } ], "customer": { "name": "Juan Pérez", "phone": "+525512345678", "email": "juan@example.com" }, "delivery": { "type": "deliveryTrue", "address": "Av. Insurgentes Sur 1234, Col. Del Valle, CDMX", "addressUrl": "https://maps.google.com/?q=...", "structuredAddress": { "addressLineOne": "Av. Insurgentes Sur 1234", "city": "CDMX", "state": "CDMX", "postalCode": "03100", "countryCode": "MX" }, "numberOfTable": null }, "paymentMethod": { "name": "Stripe" }, "paymentStatus": "completed", "appliedPromotion": null, "generalComment": "Tocar al timbre 2 veces", "createdAt": "2026-05-08T20:35:18.456Z" } } ``` ### `order.status_updated` A slim payload — `previousState → newState` only. Receivers cache the `order.created` payload (which has full order context) and merge by `orderId`. PII is omitted from this event because status updates can fire many times per order. ```json theme={null} { "type": "order.status_updated", "eventId": "9a8b7c6d-2345-6789-01bc-def234567890", "timestamp": "2026-05-08T20:42:11.789Z", "apiVersion": "2026-05-08", "data": { "orderId": "order_abc123", "publicOrderId": 1042, "trackingToken": "trk_xyz789", "userId": "user_uid_lupita", "menuId": "menu_taqueria_main", "branchId": null, "previousOrderStatus": "pending", "orderStatus": "inProgress", "previousCustomStatus": { "id": "received", "label": "Recibido" }, "customStatus": { "id": "preparing", "label": "Preparando" }, "changedAt": "2026-05-08T20:42:09.234Z", "reason": null } } ``` ### `order.paid` A finance-focused payload — pricing + provider-specific reconciliation IDs. Customer PII is omitted. Use this event for accounting integrations that don't need order context. ```json theme={null} { "type": "order.paid", "eventId": "ab9c8d7e-3456-7890-12cd-ef3456789012", "timestamp": "2026-05-08T20:35:20.456Z", "apiVersion": "2026-05-08", "data": { "orderId": "order_abc123", "publicOrderId": 1042, "userId": "user_uid_lupita", "menuId": "menu_taqueria_main", "paymentProvider": "stripe", "paymentMethod": { "name": "Stripe" }, "paymentStatus": "completed", "totalPrice": 285.50, "subtotalPrice": 250.00, "deliveryPrice": 35.50, "discountAmount": 0, "totalTaxAmount": 0, "currency": "MXN", "stripeCheckoutSessionId": "cs_test_xxxxxxxxxx", "stripeInvoiceUrl": "https://invoice.stripe.com/i/...", "paypalOrderId": null, "paypalCaptureId": null, "paypalInvoiceUrl": null, "capturedAt": "2026-05-08T20:35:19.012Z" } } ``` ## Verify signatures Every webhook is signed with HMAC-SHA256. The signature is in the `X-Marea-Signature` request header: ``` X-Marea-Signature: t=1714867200,v1=2c8a1dd3... (64-char hex) ``` The signature is computed over the string `"."` using your **raw signing secret** (the 32-byte hex string Marea revealed when you first saved the webhook). ### Key difference from Agent webhooks Agent webhooks derive the signing key from the developer-key hash via HKDF-SHA256. **Page webhooks use the raw signing secret directly** — there is no HKDF step. Use the `X-Marea-Source` header to disambiguate: | Header | Source | Secret derivation | | --------------------------- | ------------- | ------------------------------------------------------- | | `X-Marea-Source: developer` | Agent webhook | HKDF-SHA256 from `apiKeys/{keyId}.keyHash` | | `X-Marea-Source: merchant` | Page webhook | Raw 32-byte hex from `users/{uid}.webhookSigningSecret` | If your single endpoint receives both surfaces, branch on `X-Marea-Source` to pick the right verification path. ### Verification flow 1. Parse the `t=...` and `v1=...` from the header 2. Reject if `|now - t| > 300` (5-minute replay window) 3. Compute `HMAC-SHA256(secret, ".")` and hex-encode 4. Use a constant-time comparison against `v1` 5. If match: trust the payload. If not: respond `401`. Ready-to-paste implementations live at [Page-webhook receiver helpers](/api/webhooks/page-webhook-helpers). Always verify against the **raw request body bytes**, not a JSON-parsed and re-stringified version. Whitespace differences will break the signature match. ## Headers reference | Header | Value | | -------------------- | ------------------------------------------------------------ | | `Content-Type` | `application/json` | | `X-Marea-Signature` | `t=,v1=` | | `X-Marea-Event-Type` | `order.created` / `order.status_updated` / `order.paid` | | `X-Marea-Event-Id` | UUID v4 (same value as `data.eventId`) — use for idempotency | | `X-Marea-Source` | `merchant` for Page webhooks | | `User-Agent` | `marea-webhook/1.0` | ## 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 event is dropped. The merchant sees the failure in the **recent deliveries log** in their integrations tab — outcome `max_attempts_reached` with the final HTTP status. There is **no automatic replay** in v1; if you need bulletproof delivery, queue events on your end (e.g. via Zapier / Make.com / n8n). ## Recent deliveries log The merchant's profile page surfaces the last 50 deliveries (rolling 30-day window) with: * **Outcome** — success / failed / max\_attempts\_reached / pending * **Response status** — color-coded (green `2xx`, yellow `4xx`, red `5xx` or max-attempts) * **Latency** — time to receive a response from your endpoint * **Attempt count** — `1/3`, `2/3`, `3/3` * **Error message** — for failed deliveries (truncated to 500 chars) Test fires (`data.__test === true`) are flagged in the log so they don't pollute success-rate metrics. ## Receiver implementation tips * **Respond 200 immediately**, then process asynchronously. Marea's 5-second timeout is tight; if your business logic takes longer, ack quickly and process in a queue. * **Idempotency**: design your handler to dedupe on `eventId` — Marea retries on transient failures, so duplicate deliveries are possible. * **Constant-time signature comparison**: don't use `===` or `==` on the HMAC — use `crypto.timingSafeEqual` (Node) or `hmac.compare_digest` (Python). * **Test with `webhook.site` first**: configure your webhook URL to point at a unique `webhook.site` URL while you wire up your real handler — you'll see the exact payloads Marea sends. * **Distinguish surfaces by `X-Marea-Source`** if your endpoint also receives Agent webhooks. ## Rotate the signing secret If you suspect your secret has leaked, click **Rotar secreto** in the integrations tab. Marea generates a new 32-byte hex secret, reveals it once, and immediately invalidates the old one. There is **no overlap window** in v1 — update your receiver's secret immediately. In-flight deliveries that were enqueued before the rotation are signed with the new secret at delivery time, so receivers configured with the new secret will accept them. ## Limitations and roadmap * **Three events only today** (`order.created`, `order.status_updated`, `order.paid`) — `menu.*` / `product.*` / `customer.*` / `subscription.*` events are TIER 2. * **No missed-event replay endpoint** — events that hit `max_attempts_reached` are gone. Use a buffered receiver if you need stronger guarantees. * **Single webhook URL per merchant** — fan-out to multiple endpoints via Zapier/Make.com. * **Cash / custom payments** don't fire `order.paid` in v1. Subscribe to `order.created` and look at `paymentMethod` if you need cash-order coverage. * **Refund event** (`order.refunded`) is TIER 2. * **24h secret-rotation overlap window** is TIER 2. For receiver-side helpers (signature verification code in JS + Python), see [Page-webhook receiver helpers](/api/webhooks/page-webhook-helpers). # Plan limits and the publish gate Source: https://docs.mareaalcalina.com/concepts/plan-limits Storefront caps, product caps, the pre-paywall publish block, and the 207 Multi-Status path when a manifest exceeds plan caps. # Plan limits Every Marea user is on a plan. The plan determines: 1. How many storefronts the user can have 2. How many products per storefront 3. Whether the user can publish at all (pre-paywall accounts cannot) 4. Per-transaction platform fee, multi-branch availability, and monthly-orders visibility (covered below) Your agent should read the user's plan from `GET /v1/me` (`plan.tier` + `plan.limits`) and surface `_links.upgradeUrl` if a higher tier is required. The numbers below are the canonical caps from `plan-limits.ts`; custom plans can override the storefront cap via `planQuantity`, and the `/v1/me` response always reflects the live cap. The API surfaces a **coarse-grained 4-value tier** (`free`, `basic`, `pro`, `business`) for SDK stability. Internally there are more numeric sub-plans (e.g. `business_200`, `business_500`, `agency_growth`) that collapse to `"business"` on the wire. The numeric `planQuantity` field carries the true storefront ceiling for custom plans. ## The `plan.limits` shape `/v1/me` returns this exact shape — fields are not pluralized the way you might expect: ```json theme={null} { "plan": { "tier": "free", "limits": { "storefronts": 1, "products": 30, "publishable": true } }, "planQuantity": null } ``` | Field | Meaning | | -------------------- | --------------------------------------------------------------------------------------------------------- | | `limits.storefronts` | Maximum storefronts the user can own. | | `limits.products` | Maximum products **per storefront** (each storefront gets its own bucket). | | `limits.publishable` | `true` if the plan permits making a storefront publicly available. Only `false` for pre-paywall accounts. | | `planQuantity` | If non-null, the per-account override for `limits.storefronts` (custom/agency plans). | ## Per-plan caps | Surfaced tier | Internal plans | `storefronts` | `products` per storefront | `publishable` | | -------------------- | ---------------------------------------------- | ------------- | ------------------------------ | ------------- | | `free` (pre-paywall) | `NO_ACTIVO` (9) | 1 | 2,000 (drafts only — see note) | **false** | | `free` | `FREE_NEW` (8) | 1 | 30 | true | | `free` | `FREE_OLD` (2, legacy) | 3 | 30 | true | | `basic` | `BASIC_MONTHLY` (4) / `BASIC_YEARLY` (5) | 3 | 60 | true | | `pro` | `PRO_MONTHLY` (1) / `PRO_YEARLY` (3) | 15 | 200 | true | | `business` | `BUSINESS_MONTHLY` (6) / `BUSINESS_YEARLY` (7) | 50 | 2,000 | true | | `business` | `BUSINESS_200` (11) | 200 | 2,000 | true | | `business` | `BUSINESS_500` (12) | 500 | 2,000 | true | | `business` | `BUSINESS_1000` (13) | 1,000 | 2,000 | true | | `business` | `AGENCY` (20, legacy) | 20 | 2,000 | true | | `business` | `AGENCY_MONTHLY` (21) / `AGENCY_YEARLY` (22) | 5,000 | 2,000 | true | **Pre-paywall accounts.** A pre-paywall user (`NO_ACTIVO`) collapses to `"free"` on the wire but `limits.publishable === false`. The product cap shows as 2,000 to permit full authoring preview before checkout, but `POST .../publish` returns **402** and orders are blocked upstream. Use `plan.limits.publishable` — never the tier name — to decide whether a publish call will succeed. ## Beyond storefronts + products `plan-limits.ts` also encodes several plan-derived behaviors. Most are not surfaced verbatim in `/v1/me.plan.limits` today (the public limits object is the locked 3-field shape above), but they govern what the user can do once they hit the dashboard. Surface these to the user when an upgrade CTA needs context: | Capability | Free (`NO_ACTIVO`/`FREE_*`) | Basic | Pro | Business | Agency | | --------------------------------------- | --------------------------- | ------- | ------- | --------- | --------- | | Platform fee (Stripe + PayPal) | 2.5% | 1.9% | 1.5% | 0.9% | 0% | | Multi-branch (locations per storefront) | 0 (off) | 0 (off) | 0 (off) | 3 | unlimited | | Monthly orders visible in dashboard | 30 (`NO_ACTIVO` = 0) | 200 | 1,000 | unlimited | unlimited | Monthly-order visibility gates dashboard surface — orders still arrive via WhatsApp regardless. The `0` for `NO_ACTIVO` means dashboard order intake is fully disabled until the user picks a plan. ## Scopes vs. plan Plan tier does **not** determine the scopes a user key holds. Scopes are set per-key at issuance (`catalog:read`, `catalog:write`, `storefront:publish`). The publish scope passes the auth layer for any plan, but the `storefronts/:id/publish` handler then re-checks `plan.limits.publishable` and returns 402 for pre-paywall accounts. In other words: the scope says "this key is permitted to attempt publishing", and the plan says "this account is permitted to actually publish". Both must be true. ## What happens at the limit ### 402 `plan_blocks_publish` — pre-paywall accounts only `POST /v1/storefronts/:id/publish` returns **402** when the user is in the pre-paywall state (`NO_ACTIVO`, plan 9). Once any paid plan or legacy free plan is active, this endpoint stops rejecting. ```json theme={null} { "error": { "type": "plan_limit", "code": "plan_blocks_publish", "message": "Your plan does not permit publishing. Upgrade to publish this storefront.", "recoverable": true, "upgrade": { "currentPlan": "free", "requiredPlan": "basic", "upgradeUrl": "https://mareaalcalina.com/upgrade?planSource=api" } } } ``` Surface `upgrade.upgradeUrl` to the user as a CTA. Do not retry without a plan change. `currentPlan: "free"` here is the coarse-grained tier surface — pre-paywall and `FREE_*` both collapse to `"free"` so upgrade copy stays consistent. The authoritative signal is `plan.limits.publishable` from `/v1/me`, not the tier string. ### 207 `products_over_limit` — bulk manifest exceeds product cap `POST /v1/storefronts` accepts a manifest with up to 100 products in a single call. If the user's plan caps lower than the manifest count, the storefront is still created **up to the cap** and the response status is **207 Multi-Status**: ```json theme={null} { "storefront": { "id": "stf_xxx", "...": "..." }, "errors": [ { "type": "plan_limit", "code": "products_over_limit", "message": "Plan allows 30 products; 12 were not created.", "param": "products", "doc": "https://docs.mareaalcalina.com/errors/products_over_limit", "recoverable": true, "recovery": { "skippedCount": 12, "skippedProducts": [ { "index": 30, "title": "Tacos al pastor" }, { "index": 31, "title": "Tacos de chorizo" } ], "upgrade": { "currentPlan": "free", "requiredPlan": "basic", "upgradeUrl": "https://mareaalcalina.com/upgrade?planSource=api", "previewUrl": "https://marea.pro/preview/" } } } ] } ``` The storefront is real and usable. `errors[].recovery.skippedProducts` tells your agent exactly which items were dropped, with the original manifest index. Surface the upgrade CTA — once the user upgrades, retry the dropped products via `POST /v1/storefronts/:storefrontId/products`. ### 402 `plan_max_products_reached` — individual product POST hit the cap Once the storefront exists, individual `POST /v1/storefronts/:storefrontId/products` calls that would push past the per-storefront product cap return a plain **402** with the same `upgrade` shape: ```json theme={null} { "error": { "type": "plan_limit", "code": "plan_max_products_reached", "message": "Plan limit of 30 products reached. Upgrade to add more.", "param": "products", "recoverable": true, "upgrade": { "currentPlan": "free", "requiredPlan": "basic", "upgradeUrl": "https://mareaalcalina.com/upgrade?planSource=api" } } } ``` Note the different `code` from the bulk path: single-product POSTs do **not** return 207 — there's nothing partial to report. ## Reading the user's current plan ```http theme={null} GET /v1/me HTTP/1.1 Authorization: Bearer mk_user_xxxxxxxxxxxxxxxx ``` ```json theme={null} { "id": "usr_xxx", "type": "user", "plan": { "tier": "free", "limits": { "storefronts": 1, "products": 30, "publishable": true } }, "planQuantity": null, "_links": { "upgradeUrl": "https://mareaalcalina.com/upgrade?planSource=api", "dashboardUrl": "https://mareaalcalina.com/dashboard" } } ``` Check `plan.limits.publishable` before calling publish, and compare current storefront / product counts against `plan.limits.{storefronts, products}` before bulk-creating to avoid the 207 round-trip. ## What this page does NOT cover * **Pricing.** Pricing, billing cycles, and promotional terms live at [mareaalcalina.com](https://mareaalcalina.com). The numbers above are the technical caps; what each plan costs is on the public pricing page. * **Plan sub-tiers on the wire.** Business and Agency have multiple internal sub-tiers (200 / 500 / 1,000 storefronts, monthly / yearly cadences, legacy variants). Agents see the public 4-value tier surface; the underlying numeric plan ID is not exposed. The true storefront ceiling for custom plans is in `planQuantity`. * **Cache TTLs.** `/v1/me` reads the user doc through a 30-second in-instance cache (BL-4). A plan change made through the dashboard becomes visible to the API within 30 seconds without any explicit invalidation. # Publishing a storefront Source: https://docs.mareaalcalina.com/concepts/publishing POST /v1/storefronts/:storefrontId/publish — the publish gates (402, 422, 451), the auto-version flow, and idempotent republish. # Publishing `POST /v1/storefronts/:storefrontId/publish` takes a draft storefront live. It is the only operation that exposes the storefront to the public internet — every other catalog mutation only changes the draft. Required scope: `storefront:publish` (held by user keys after verification; not held by developer keys, not held by restricted pre-verify user keys). ## Behavior The handler enforces the following gates in this order: | Order | Gate | On failure | | ----- | ---------------------- | ----------------------------------------------------------------------------------------------------------- | | 1 | Plan paywall | `402 plan_blocks_publish` if `plan == NO_ACTIVO` (pre-paywall state). FREE / BASIC / PRO / BUSINESS all OK. | | 2 | Ownership pre-check | `404 storefront_not_found` (leak-less) if the storefront isn't owned by the caller's user | | 3 | Empty-storefront guard | `422 no_products` if the storefront has zero products | | 4 | ToS gate | `451 tos_not_accepted` (reserved — see [ToS jurisdiction](/concepts/tos-jurisdiction)) | | 5 | Auto-version | If `versionId` is omitted, a fresh version snapshot is created atomically | | 6 | Publish transaction | Marks the version published; idempotent on republish of the same version (200 + same DTO) | The ownership check runs **before** the empty-products check so a wrong-user-key probe cannot enumerate storefront ids by status-code timing. ## Request ```http theme={null} POST /v1/storefronts/stf_abc/publish HTTP/1.1 Authorization: Bearer mk_user_... Idempotency-Key: 2f1a8c4b-2e3a-4b9d-9f1a-8c4b2e3a4b9d Content-Type: application/json {} ``` Body is optional. Pass `{ "versionId": "ver_xxx" }` to publish a specific named version; omit for auto-version (the common case). Include `Idempotency-Key` if you want the call to be safe to retry — see [Safe mutations](/concepts/safe-mutations). ## Successful response (200) ```json theme={null} { "storefront": { "id": "stf_abc", "name": "Tacos La Marea", "published": true, "publishedDate": "2026-05-10T01:23:45Z", "_links": { "previewUrl": "https://marea.pro/preview/", "publicUrl": "https://marea.pro/tacos-la-marea", "editUrl": "https://mareaalcalina.com/dashboard/menu/stf_abc" } } } ``` `_links.publicUrl` is now non-null and stable. Surface it to the user. ## Republishing Calling publish a second time on the same storefront: * If the version already published matches the request, the call is **idempotent**: the same 200 + DTO is returned. * If a new version is created (auto-version with new content), a fresh public snapshot is generated. This means an agent can call publish on every "save" without worrying about the user seeing a flicker — publish is cheap when nothing changed. ## Error responses ### 402 `plan_blocks_publish` — pre-paywall account ```json theme={null} { "error": { "type": "plan_limit", "code": "plan_blocks_publish", "message": "Your plan does not permit publishing. Upgrade to publish this storefront.", "recoverable": true, "upgrade": { "currentPlan": "free", "requiredPlan": "basic", "upgradeUrl": "https://mareaalcalina.com/upgrade?planSource=api" } } } ``` Today only the `NO_ACTIVO` (pre-paywall) state hits this. Free, Basic, Pro, and Business all publish successfully. Surface `upgrade.upgradeUrl` as a CTA. Do **not** retry until the user upgrades. ### 422 `no_products` — empty storefront ```json theme={null} { "error": { "type": "invalid_request", "code": "no_products", "message": "Cannot publish an empty storefront. Add at least one product first.", "recoverable": true, "nextActions": [ { "label": "Add at least one product before publishing.", "method": "POST", "url": "/v1/storefronts/stf_abc/products" } ] } } ``` Add at least one product, then retry publish with the same `Idempotency-Key`. ### 451 `tos_not_accepted` — user hasn't accepted ToS ```json theme={null} { "error": { "type": "tos_not_accepted", "code": "tos_required", "message": "User must accept the Terms of Service before publishing.", "recoverable": true, "nextActions": [ { "label": "Open the Marea dashboard and accept the Terms of Service.", "method": "GET", "url": "https://mareaalcalina.com/dashboard" } ] } } ``` The user must accept the ToS via the dashboard modal — the agent **cannot** bypass. Surface `nextActions[0].url` verbatim. Once accepted, retry publish with the same `Idempotency-Key`. See [ToS jurisdiction](/concepts/tos-jurisdiction). ### 404 — storefront not found OR cross-tenant access If your `mk_user_*` key doesn't own the storefront, you get `404 storefront_not_found` (silent denial — not 403). See [Storefronts](/concepts/storefronts). ## What NOT to do * **Don't auto-publish.** Publishing is destructive and user-visible — once it's live, the URL is shared and may be indexed. Always require explicit user confirmation. * **Don't retry 402 / 422 / 451** without surfacing `nextActions[]` to the user. Each is a `recoverable: true` error that needs user input, not a backoff. * **Don't auto-accept the ToS.** The 451 → modal flow is counsel-reviewed and intentional. ## Verification in code * `src/api/v1/storefronts.publish.ts` — the handler with the locked step order. * `src/utils/publish.utils.ts` — `runPublishTransaction`. * `src/models/plan-limits.ts` — `isPublishable()` (the 402 gate condition). # Rate limits and 429 handling Source: https://docs.mareaalcalina.com/concepts/rate-limits Per-key minute + day buckets. Stripe-compatible X-RateLimit-* headers. Read Retry-After and back off. # Rate limits Marea uses **per-key minute and day buckets** with Stripe-compatible headers. Counts increment **before** the handler runs, so 4xx and 5xx responses still count against your quota — that's deliberate, and it defends against spam-of-invalid-requests abuse. ## Headers on every response ```http theme={null} X-RateLimit-Limit: 60 ← rpm (requests-per-minute) for this key X-RateLimit-Remaining: 47 ← remaining in the current minute window X-RateLimit-Reset: 1714867260 ← epoch seconds when the minute rolls over ``` When you hit a limit, you also get: ```http theme={null} HTTP/1.1 429 Too Many Requests Retry-After: 23 ``` `Retry-After` is in seconds. For minute-bucket overflow it's seconds-until-the-next-minute (always ≤ 60). For day-bucket overflow it can be up to **86,400 seconds** (a full day). The headers report on the **minute bucket only** — there is no `X-RateLimit-Remaining-Day` header. Use `GET /v1/me`'s `rateLimit.remainingDay` for the day-bucket counter. ## Default budgets Read your live values from `GET /v1/me` (the `rateLimit` object). Values stored on the API key doc at issuance time: | Key prefix | Key type | `rpm` | `rpd` | Notes | | ----------- | --------------- | ----- | ------------ | ------------------------------------------------------------------- | | `mk_user_*` | User scope | 60 | 10,000 | Issued via `POST /v1/users/:userId/keys`. | | `mk_dev_*` | Developer scope | 60 | 50 (default) | The dev-key `rpd` is the **bootstrap-per-day ceiling** — see below. | **The developer key's `rpd` is intentionally tiny.** Developer keys exist to bootstrap accounts (`POST /v1/users/:userId/bootstrap`) and issue per-user keys, not to drive sustained traffic. The `rpd: 50` default is the bootstrap-per-day cap (`bootstrapRpd`, configurable at issuance time per the `IssueDeveloperKeyParams` contract). Once an account is bootstrapped, route all subsequent traffic through that account's `mk_user_*` key — that one gets the full 10,000/day budget. The middleware does not distinguish "this request is a bootstrap call" from "this request is something else" — it simply enforces whatever `rpd` is stored on the developer key. If you exhaust 50 requests on a dev key in a single day, you're locked out of that dev key for the rest of the UTC day, full stop. To check your remaining budget at any time, call `GET /v1/me`. The response includes: ```json theme={null} { "rateLimit": { "rpm": 60, "rpd": 50, "remainingMinute": 60, "remainingDay": 47 } } ``` **Header vs. body precision.** The exact `remainingMinute` value lives in the response headers (`X-RateLimit-Remaining`). The `/v1/me` body's `remainingMinute` is approximate — it's the value at response-assembly time, not at your next request. For backoff logic, trust the headers. ## 429 response shape ```json theme={null} { "error": { "type": "rate_limited", "code": "rate_limit_exceeded", "message": "Rate limit exceeded (rpm_exceeded). Retry after 23s.", "recoverable": true, "retryAfterMs": 23000, "nextActions": [ { "label": "Wait 23s and retry the same request.", "method": null, "url": null } ] } } ``` The `message` field tells you which bucket overflowed: * `message` contains `rpm_exceeded` → minute bucket. Wait `Retry-After` seconds (always ≤ 60), then retry. * `message` contains `rpd_exceeded` → day bucket. `Retry-After` is the seconds-remaining-in-the-day (up to 86,400). **Do not retry tight; wait the indicated time or escalate to the user.** The `retryAfterMs` field on the error body mirrors `Retry-After` in milliseconds. Both `recoverable: true` and `nextActions[0].label` indicate that the call is safe to repeat once the window opens. ## Recommended agent backoff Minute-bucket overflow: ``` attempt 1: hit 429, message contains "rpm_exceeded", Retry-After: 23 → sleep 23s, retry attempt 2: succeed (200) ``` Day-bucket overflow: ``` attempt 1: hit 429, message contains "rpd_exceeded", Retry-After: 41200 → surface to user: "Daily quota reached. Try tomorrow or upgrade." → DO NOT auto-retry ``` When using `Idempotency-Key`, **keep the same key across retries** — the server treats the eventual successful call as a replay-safe completion of the original request. See [Safe mutations](/concepts/safe-mutations). ## What counts against your quota Every authenticated request to a `/v1` endpoint — 200, 4xx, 5xx — counts. The increment happens **before** the handler runs, so a malformed body that the handler would have rejected with a 422 still consumes one minute-bucket slot and one day-bucket slot. Exceptions: * Unauthenticated requests (rejected at the apiKey middleware before rate-limit increments) do not count. * The health-check shortcut at `/healthz` does not pass through the rate-limit middleware. * If the rate-limit Firestore transaction itself fails (infrastructure error), the request is **failed open** — it proceeds without an increment and without a 429. A structured log entry is emitted so the 5xx-rate alert surfaces the underlying infra failure. ## Conventions Stripe-compatible headers and semantics — `X-RateLimit-*` plus `Retry-After`. The same backoff library you'd use for Stripe works here unchanged. Buckets are **per key, not per account**. If a single user has both `mk_user_keyA` and `mk_user_keyB`, each gets its own 60/10,000 budget. This is by design: a misbehaving integration on one key can't starve a well-behaved integration on another key for the same user. # Idempotency and safe mutations Source: https://docs.mareaalcalina.com/concepts/safe-mutations Stripe-style idempotency keys on every POST and PATCH. Replays return the original response; body mismatches return 409 idempotency_conflict. # Safe mutations Marea honors `Idempotency-Key` on every `POST` and `PATCH`. Replays of the **same key + same body** return the **same response** (status + body, including failures). This lets agents retry network errors without double-creating resources. ```http theme={null} POST /v1/users HTTP/1.1 Host: api.mareaalcalina.com Authorization: Bearer mk_dev_... Content-Type: application/json Idempotency-Key: 2f1a8c4b-2e3a-4b9d-9f1a-8c4b2e3a4b9d { "email": "owner@taqueria.example", "displayName": "La Taquería", "sourceAgent": "claude-code" } ``` ## Header contract | Property | Value | | ------------------ | ------------------------------------ | | Header name | `Idempotency-Key` | | Length | 1–255 characters | | Character class | ASCII printable only (`0x20`–`0x7E`) | | Recommended format | UUID v4 | | Applies to | `POST` and `PATCH` | | Ignored on | `GET`, `OPTIONS`, `DELETE` | Invalid headers (too short, too long, or containing non-printable bytes) return `400 invalid_idempotency_key`. The regex is `^[\x20-\x7e]{1,255}$`. ## Match key A stored idempotency record is keyed on the triple `(keyId, method+path, Idempotency-Key)`. The body is hashed (canonical JSON, sorted keys, SHA-256 hex) and compared on every replay. * **Cross-key replays don't work.** Different API keys with the same `Idempotency-Key` are independent requests. * **Cross-endpoint replays don't work.** `POST /v1/storefronts` and `POST /v1/storefronts/:id/products` with the same `Idempotency-Key` are independent. ## Outcomes | Match status | What you get | When | | ------------ | -------------------------------------------------------- | -------------------------------------------------- | | `owned` | Handler runs; response is stored for future replays | First time the key is seen | | `replay` | Original response replayed verbatim (same status + body) | Same key + same body; original completed | | `in_flight` | `409 conflict` + `Retry-After: 1` (`recoverable: true`) | Same key + still processing | | `conflict` | `409 idempotency_conflict` (`recoverable: false`) | Same key + **different** body | | `oversize` | `410 idempotency_snapshot_unavailable` (rare) | Original response exceeded the 100 KB snapshot cap | ## Missing the header Sending `POST` / `PATCH` **without** `Idempotency-Key` is not an error. The request proceeds and Marea returns a `Marea-Recommendation: include-idempotency-key` response header to nudge you. Skipping the header is fine for one-shot calls; include it for any retry-prone integration. ## The four 409 / 410 error shapes ### `in_flight` — same key still processing ```json theme={null} { "error": { "type": "conflict", "code": "idempotency_in_flight", "message": "Another request with this Idempotency-Key is still being processed. Retry shortly.", "recoverable": true, "retryAfterMs": 1000 } } ``` Wait 1s and retry with the **same key**. Two concurrent calls on the same key are serialized server-side — only one wins `owned`; the other waits or replays. ### `conflict` — same key, different body ```json theme={null} { "error": { "type": "idempotency_conflict", "code": "idempotency_conflict", "message": "Idempotency-Key was used earlier with a different request body.", "recoverable": false, "nextActions": [ { "label": "Generate a new Idempotency-Key for this request.", "method": null, "url": null } ] } } ``` Generate a **fresh** key. Don't reuse it. The most common cause is an agent retrying with edits ("same call, but change the email") — that's a new request and needs a new key. ### `invalid_idempotency_key` — format error ```json theme={null} { "error": { "type": "invalid_request", "code": "invalid_idempotency_key", "message": "Idempotency-Key must be 1-255 ASCII printable chars.", "param": "Idempotency-Key", "recoverable": false } } ``` Fix the header. UUID v4 is the safe default. ### `idempotency_snapshot_unavailable` — oversize response ```json theme={null} { "error": { "type": "invalid_request", "code": "idempotency_snapshot_unavailable", "message": "The original response was too large to store. Retry without the Idempotency-Key.", "recoverable": false, "nextActions": [ { "label": "Reissue the request without the Idempotency-Key.", "method": null, "url": null } ] } } ``` Response snapshots are capped at **100 KB** (Firestore doc-size headroom). On the rare oversize response, the original is marked oversize and future replays return 410 — drop the header and reissue. ## What is stored The first successful response is persisted as `(keyId, method+path, Idempotency-Key) → { status, body }`. Failed responses are also stored — a replay of a failed call returns the same failure. The stored snapshot is held to back replays; there is no client-controlled TTL. ## Convention Stripe-style. If you already use a Stripe retry library that drives `Idempotency-Key`, it works on Marea unchanged. ## Verification in code * `src/api/middleware/idempotency.ts` — header parsing + replay vs. owned vs. conflict branching. * `src/api/services/apiIdempotency.service.ts` — `acquireOrReplay`, `complete`, `fail`, snapshot cap (`SNAPSHOT_MAX_BYTES`). # Storefronts Source: https://docs.mareaalcalina.com/concepts/storefronts What a storefront is in Marea, how it is created and updated, the previewUrl / publicUrl split, and cross-tenant access rules. # Storefronts A **storefront** is the unit of catalog ownership in Marea. One user owns one or more storefronts; each has a name, a category list, products, branding, contact info, business hours, delivery settings, and a publishable hosted URL. Under the hood a storefront is the `users/{ownerUid}/menus/{menuId}` document; the API surface presents it with the typed id `stf_`. Internal ids never leak across the API — every handler strips the `stf_` prefix at the top before any Firestore access. | Concept | API surface | Notes | | ------------- | ------------------- | ------------------------------------------ | | Storefront id | `stf_` | Opaque, agent-stable | | Product id | `prd_` | Opaque, agent-stable | | Preview link | `_links.previewUrl` | 24h token, anyone with the link can open | | Public link | `_links.publicUrl` | `null` until publish; stable after publish | | Edit link | `_links.editUrl` | Dashboard deep link for the owner | ## Lifecycle ``` created (draft) ←─ POST /v1/storefronts │ ├─ previewUrl available immediately (24h-token preview) │ └─ POST /v1/storefronts/:storefrontId/publish ──▶ public live URL │ ├─ 402 if pre-paywall account ├─ 422 if 0 products └─ 451 if ToS not accepted ``` A storefront is created in **draft** state. Anyone with the `previewUrl` can open it; the URL token expires after 24 hours. To make it public, call `POST /v1/storefronts/:storefrontId/publish` (see [Publishing](/concepts/publishing)). ## Creating a storefront `POST /v1/storefronts` accepts a `StorefrontManifest`. Required scope: `catalog:write` (user keys only). ```http theme={null} POST /v1/storefronts HTTP/1.1 Authorization: Bearer mk_user_... Idempotency-Key: 2f1a8c4b-2e3a-4b9d-9f1a-8c4b2e3a4b9d Content-Type: application/json { "name": "Tacos La Marea", "businessType": "restaurante", "language": "es", "currency": "MXN", "categories": [ { "title": "Tacos", "description": "Estilo tradicional" }, { "title": "Bebidas", "description": null } ], "products": [ { "title": "Taco al pastor", "price": 25, "imageUrl": "https://...", "category": "Tacos" }, { "title": "Coca Cola", "price": 30, "imageUrl": null, "category": "Bebidas" } ], "schedule": [{ "day": "mon", "open": "08:00", "close": "22:00" }] } ``` * `products` is optional in the manifest. To batch-add more after creation, use the product endpoints. * If the manifest contains more products than the plan allows, the response is **`207 Multi-Status`** with the storefront created up to the plan cap plus an `errors` array describing the over-cap items. See [Plan limits](/concepts/plan-limits). * Idempotency: send `Idempotency-Key` to make retries safe. See [Safe mutations](/concepts/safe-mutations). Response (`StorefrontDto`): ```json theme={null} { "storefront": { "id": "stf_abc123", "name": "Tacos La Marea", "language": "es", "currency": "MXN", "published": false, "categories": [{ "title": "Tacos", "description": "Estilo tradicional" }], "products": [{ "id": "prd_xyz", "title": "Taco al pastor", "price": 25 }], "_links": { "previewUrl": "https://marea.pro/preview/", "publicUrl": null, "editUrl": "https://mareaalcalina.com/dashboard/menu/stf_abc123" } } } ``` `_links.publicUrl` is `null` until publish — never expose the preview URL as the "public" URL. ## Updating a storefront `PATCH /v1/storefronts/:storefrontId` is a **partial** update. Required scope: `catalog:write`. Three merge rules: | Field shape | Behavior | | --------------- | ------------------------------------------------------------------------ | | Nested object | Deep-merge. `{ "delivery": { "fee": 50 } }` only changes `delivery.fee`. | | Array | Full-replace. `{ "categories": [...] }` overwrites the whole array. | | Explicit `null` | Clears the field. `{ "delivery": null }` removes the delivery config. | Returns the updated `StorefrontDto` (`200 OK`). Republishing is a separate call — `PATCH` only mutates the draft. ## Products Products live on the storefront. Use `POST /v1/storefronts/:storefrontId/products` to create and `PATCH /v1/storefronts/:storefrontId/products/:productId` to update. Both require `catalog:write` and accept `Idempotency-Key`. The per-plan product ceiling is enforced on every create; over-cap creates return `402 plan_limit` with `upgrade.upgradeUrl` populated. ## Cross-tenant access is silently denied If your `mk_user_*` key tries to access a storefront owned by a different user, the API returns **`404 storefront_not_found`** — not `403`. The 404 also fires before the empty-storefront check on publish to avoid leaking existence by status-code timing. Common cause when you get a 404 on a stable `stf_` id you used moments ago: you're sending the wrong user key (e.g. dev environment vs. prod). Confirm with `GET /v1/me` which user the key acts as. ## What a storefront does NOT include * A storefront is **not** a channel. Where customers reach you (WhatsApp, web, in-person) is per-storefront configuration; the channel mix is not surfaced as a top-level field today. * A storefront is **not** an order ledger. Orders are a separate resource (see [Page webhooks](/concepts/page-webhooks) for order events). * Storefronts do not own subscriptions. Billing/plan lives on the owning user (see `/v1/me`). ## Limits See [Plan limits](/concepts/plan-limits) for per-plan storefront-count and product-count ceilings. The product cap is per-storefront, not per-account. ## Verification in code * `src/api/v1/storefronts.create.ts` * `src/api/v1/storefronts.update.ts` * `src/api/v1/storefronts.publish.ts` * `src/api/services/translation/storefront-id.ts` (`stf_` ↔ menuId translation) * `src/api/services/shape/storefront.shape.ts` (`toStorefrontDto`) # ToS, jurisdiction, and the 451 response Source: https://docs.mareaalcalina.com/concepts/tos-jurisdiction Terms of Service acceptance, Mexican-law jurisdiction, the 90-day no-ToS cleanup, and the 451 tos_not_accepted error shape. # Terms of Service and jurisdiction Marea Alcalina is incorporated under **Mexican law** (LFPDPPP — *Ley Federal de Protección de Datos Personales en Posesión de los Particulares*). All accounts agree to the ToS at sign-up; agent-bootstrapped accounts inherit a deferred-acceptance flow described below. This page documents: 1. How ToS acceptance is tracked. 2. The `451 tos_not_accepted` error envelope. 3. The 90-day no-ToS cleanup (involuntary deletion). 4. Jurisdiction. For the related Mexican data-rights flow (Acceso, Rectificación, Cancelación, Oposición), see [ARCO procedures](/concepts/arco-procedures). ## How acceptance is tracked Each user document has a `tosAcceptedAt` timestamp: | State | `tosAcceptedAt` value | Surface in `GET /v1/me` | | ------------------- | ------------------------- | --------------------------------------- | | Not yet accepted | `null` | `tosAcceptedAt: null` | | Accepted (any path) | ISO-8601 timestamp string | `tosAcceptedAt: "2026-04-15T10:00:00Z"` | Dashboard sign-ups accept ToS at the same step that creates the account. **Agent-bootstrapped accounts** (`POST /v1/users`) are created with `tosAcceptedAt: null` and `agentBootstrapped: true` — the user is expected to accept ToS later via the dashboard modal during their first interactive visit. ## The 90-day cleanup A scheduled job runs daily at 03:00 America/Mexico\_City and applies four sweeps for agent-bootstrapped accounts: | Sweep | Trigger | Action | | --------------------------- | -------------------------------------------------------- | -------------------------------------- | | Unverified 30d | `verificationStatus: pending` + `createdAt < now-30d` | Hard-delete (`reason: 30d_unverified`) | | Day 7 reminder | Verified, `tosAcceptedAt: null`, verified 7+ days ago | Email reminder #1 | | Day 14 reminder | Same, 14+ days, reminder count = 1 | Email reminder #2 | | Day 60 pre-deletion warning | Same, 60+ days, no warning sent yet (feature-flag gated) | Email warning #3 | | No-ToS 90d | Verified, `tosAcceptedAt: null`, verified 90+ days ago | Hard-delete (`reason: 90d_no_tos`) | The 90d cleanup is governed by Mexican counsel guidance — an agent that creates an account on behalf of a user does not have authority to bind that user to the ToS, so the user must accept (or implicitly reject by not visiting) within a bounded window. ## The 451 error envelope `451 tos_not_accepted` is the reserved error type that future ToS-gated mutations (publish, public-facing webhook delivery) will return when the calling user has not accepted the ToS. ```json theme={null} { "error": { "type": "tos_not_accepted", "code": "tos_required", "message": "User must accept the Terms of Service before this operation.", "doc": "https://docs.mareaalcalina.com/concepts/tos-jurisdiction", "param": null, "recoverable": true, "retryAfterMs": null, "nextActions": [ { "label": "Open the Marea dashboard and accept the Terms of Service.", "method": "GET", "url": "https://mareaalcalina.com/dashboard" } ], "upgrade": null } } ``` Agent contract for `451 tos_not_accepted`: * **Surface `nextActions[0].url` verbatim.** The user must accept the ToS through the dashboard modal — the agent **cannot** accept on the user's behalf. * Treat it as `recoverable: true`. After the user accepts, the next call (same `Idempotency-Key`) succeeds. * Do **not** retry without surfacing the action. This is the canonical agent-failure-mode for this error. The enum value `tos_not_accepted` is locked in the error envelope (`src/api/contracts/error.zod.ts`). The publish endpoint declares it in its OpenAPI error set today; gating activation for specific endpoints is rolling out behind the ToS modal launch. ## Jurisdiction | Property | Value | | ------------------ | ---------------------------------------------------------------------------------------- | | Governing law | Mexican federal law | | Privacy framework | LFPDPPP (Articles 16–18 → user rights; see [ARCO procedures](/concepts/arco-procedures)) | | Brazilian residual | LGPD disclosures apply to Brazilian residents | | Disputes | Courts of Mexico City | The current production ToS is available at the dashboard sign-up flow. Modal copy is counsel-reviewed; do not paraphrase it here — the dashboard URL is the source of truth. ## What the agent surfaces to the user | Situation | Surface | | -------------------------------------- | -------------------------------------------------------------------------------------------------------- | | Bootstrap completed | "Your account is created. Open the dashboard within 90 days to accept the Terms of Service." | | `451 tos_not_accepted` | Verbatim `nextActions[0].label` + click-through to `nextActions[0].url` | | Day-7 / Day-14 / Day-60 reminder email | The user receives these directly from Marea — agent doesn't surface anything | | 90d cleanup fired | Agent receives a `user.cancelled` webhook with `reason: 90d_no_tos` (see [Webhooks](/concepts/webhooks)) | ## Verification in code * `src/api/contracts/error.zod.ts` — locked `tos_not_accepted` enum value. * `src/api/scheduled/CleanupUnverifiedAccounts.ts` — 30d/60d/90d sweeps. * `src/api/constants/cleanup.constants.ts` — `TOS_DEADLINE_DAYS`, `TOS_REMINDER_DAYS`, `TOS_PREDELETION_WARNING_DAY`. * `src/api/services/account-delete.service.ts` — `hardDeleteUserAccount` (reason: `90d_no_tos`). # Verification flow Source: https://docs.mareaalcalina.com/concepts/verification-flow POST /v1/users returns a restricted key + emails a 6-digit code; POST /v1/users/:userId/verify upgrades the key to full scope. Plain code by design. # Verification flow `POST /v1/users` (bootstrap) creates the user, returns a **restricted** `mk_user_*` key, and emails the user a **6-digit code**. The agent then submits the code to `POST /v1/users/:userId/verify` — the **same key** is upgraded in place to full scope. No key rotation; store the value once. ``` Agent (dev key) User │ │ │ POST /v1/users │ ├─────────────────────────▶ │ │ { email, displayName, │ │ sourceAgent: "..." } │ │ │ │ 201 + restricted user key │ ◀─────────────────────────┤ │ │ + verificationExpiresAt │ │ │ │ email with 6-digit code (15 min TTL) │ │ │ │ reads code aloud OR │ │ agent reads it from Gmail MCP │ │ │ POST /v1/users/:userId/verify │ ├─────────────────────────▶ │ │ { code: "123456" } │ │ │ │ 200 { verificationStatus: │ ◀─────────────────────────┤ │ │ "verified" } │ │ │ │ same key — now has │ │ catalog:write + storefront:publish │ ``` ## Step 1 — `POST /v1/users` Required scope: `developer:bootstrap`. Returns `201 Created` (or `207 Multi-Status` if a starter manifest had over-cap products): ```http theme={null} POST /v1/users HTTP/1.1 Authorization: Bearer mk_dev_... Content-Type: application/json Idempotency-Key: 2f1a8c4b-2e3a-4b9d-9f1a-8c4b2e3a4b9d { "email": "owner@taqueria.example", "displayName": "La Taquería", "country": "MX", "language": "es", "currency": "MXN", "businessType": "restaurante", "sourceAgent": "claude-code", "initialStorefront": { "name": "Tacos La Marea", "products": [ ... ] } } ``` `sourceAgent` is required (1–64 chars, `[A-Za-z0-9 _.-]+` only). It is embedded in the verification email so the user can see which agent triggered the account. Response: ```json theme={null} { "userId": "usr_...", "storefrontId": "stf_...", "userKey": "mk_user_...", "verificationStatus": "pending", "verificationExpiresAt": "2026-05-10T20:15:00Z", "verificationDeliveryHint": "email-only", "previewToken": "...", "appliedDefaults": { "language": "es", "currency": "MXN", "country": "MX", "businessType": "restaurante" }, "idempotent": false } ``` **Store `userKey` immediately.** It is shown once. It starts with the restricted scope set: | Scope | Purpose | | ----------------------- | ------------------------------------------ | | `catalog:read` | Inspect the draft storefront | | `me:verify` | Submit the 6-digit code | | `me:resendVerification` | Request a fresh code if the email was lost | Until verify, the restricted key **cannot** create/update catalog data or publish. ## Step 2 — submit the code ```http theme={null} POST /v1/users/:userId/verify HTTP/1.1 Authorization: Bearer mk_user_... Content-Type: application/json { "code": "123456" } ``` Required scope: `me:verify`. The `:userId` path param **must** match the key's owner — cross-tenant or wrong-id attempts return `404 user_not_found` (leak-less, never 403). On success (`200`): ```json theme={null} { "userId": "usr_...", "verificationStatus": "verified" } ``` The same key's scope set is upgraded in place to `catalog:read`, `catalog:write`, `storefront:publish`. Cache propagation is bounded by the 30s positive auth cache; the planLimits cache for the user is invalidated immediately so `GET /v1/me` reflects `verified` on the next call. ### Code rules | Property | Value | | -------------------- | -------------------------------------------------------- | | Format | 6 decimal digits (`^\d{6}$`) | | TTL | 15 minutes from issuance | | Max attempts | 3 wrong attempts → `429 too_many_attempts` (must resend) | | Storage | Stored **in plain text** (intentional — see below) | | Source of randomness | `crypto.randomInt` (CSPRNG) | Plain-text storage is **intentional**. The user-reads-aloud flow requires the agent to be able to *say* the code on screen. A bcrypt-hashed code would only support submit-then-check, not the agentic read-aloud variant. Defense-in-depth lives elsewhere: 3-attempts lockout, 15-min TTL, per-user resend rate-limit, and the leak-less 404 on cross-tenant verify. ### Verify error shapes | Status | `error.code` | Meaning | Agent action | | ------ | ------------------- | ------------------------------------------------------------ | ------------------------------------------------ | | `400` | `code_invalid` | The submitted code doesn't match | Re-prompt the user; on 3rd wrong attempt, resend | | `404` | `code_not_found` | No active code for this user (never sent, or fully consumed) | Call `POST /v1/users/:userId/resendVerification` | | `404` | `user_not_found` | `:userId` doesn't match the key's owner (silent denial) | Use the correct user key | | `410` | `code_expired` | Code older than 15 min | Resend | | `429` | `too_many_attempts` | 3 wrong attempts in this verification window | Resend | ## Step 3 — resend (when needed) ```http theme={null} POST /v1/users/:userId/resendVerification HTTP/1.1 Authorization: Bearer mk_user_... ``` Required scope: `me:resendVerification`. Per-user rate-limited at **3 / hour** and **5 / day**. Overwrites the existing code doc — the old code is invalidated on resend. Returns: ```json theme={null} { "verificationStatus": "pending", "verificationExpiresAt": "2026-05-10T20:30:00Z" } ``` | Status | `error.code` | Meaning | | ------ | ------------------- | ---------------------------------- | | `429` | `resend_hour_limit` | 3 resends used in the current hour | | `429` | `resend_day_limit` | 5 resends used today | ## Variants of step 2 **A — User reads the code aloud.** Default. Agent says "I sent you a 6-digit code; tell me the number." User reads from email. Agent submits. **B — Silent verification via Gmail MCP.** If the user's mailbox is connected to the agent (e.g. Gmail MCP), the agent reads the verification email programmatically, extracts the 6-digit code, and submits — no manual step. The API contract is identical; only the source of the code differs. ## Account-cancellation hatch Every bootstrap email also embeds a single-use **cancel link** (24h TTL) pointing at `https://api.mareaalcalina.com/public/v1/bootstrap/:previewToken`. The user opens it, confirms once (GET → POST, two-step to defeat email-preview crawlers), and the account is hard-deleted. This is the LFPDPPP-required cancellation hatch for accounts created by an agent (see [ARCO procedures](/concepts/arco-procedures)). ## Verification in code * `src/api/v1/users.bootstrap.ts` * `src/api/v1/users.verify.ts` * `src/api/v1/users.resendVerification.ts` * `src/api/services/verification.service.ts` — 3-attempt lockout + scope upgrade. * `src/services/verification/sendVerificationCodeCore.ts` — code generation + email delivery (15-min API TTL). * `src/api/public/bootstrap.cancel.ts` — emergency cancel endpoint. # Versioning Source: https://docs.mareaalcalina.com/concepts/versioning How Marea versions its API: stable URL prefix, additive evolution, the Sunset header, and webhook payload pinning. # Versioning Marea's public API is on **v1**. This page documents what counts as a breaking change, how the API evolves additively, and where versions surface to clients (URL, headers, webhook payloads). ## URL versioning | Surface | Current value | Stability | | ----------------------------- | ----------------------------------------------- | ----------------------------------------------------------- | | URL prefix | `/v1/*` | Stable. Will remain mounted at least 12 months after v2 GA. | | OpenAPI spec URL | `https://api.mareaalcalina.com/v1/openapi.json` | Stable. Mintlify pulls this for the API reference tab. | | Public hosted-storefront base | `https://marea.pro/` | Stable. | A breaking change ships under a new prefix (`/v2/*`). v1 remains live during the deprecation window. There is no `Marea-Api-Version` header today — version is in the URL. ## What counts as additive (non-breaking) The following changes can land on `/v1/*` without notice: * Adding new endpoints. * Adding new fields to a response body. * Adding new optional fields to a request body. * Adding new values to an open-ended enum (only if the field is documented as open-ended). * Adding new `error.code` values within an existing `error.type`. * Adding new headers (request or response). * Tightening internal implementation (cache TTLs, retry backoff, etc.) when the external behavior is unchanged. Clients should be **forward-compatible** with all of the above. In particular: * Treat unknown fields in responses as ignorable. * Branch on `error.type` first (closed 10-value enum); fall back to `error.code` when finer-grained. * Do not parse `error.message` — it is localized via `Accept-Language` and may change. ## What counts as breaking The following changes never ship to `/v1/*`; they require a new prefix: * Removing or renaming a field, header, or endpoint. * Changing a field's type (e.g. string → number). * Tightening a response (e.g. an optional field becoming required, a nullable field becoming non-null). * Changing the meaning of an enum value (or removing one). * Changing the 10-value `error.type` enum. * Changing the `Idempotency-Key` contract (header name, length, character class, snapshot cap). ## Deprecation signaling When a v1 endpoint or field is scheduled for sunset, two signals surface: 1. The **`Sunset` HTTP response header** carries an RFC 8594 date for the planned removal: ```http theme={null} Sunset: Wed, 30 Nov 2027 00:00:00 GMT ``` `Sunset` is in the CORS expose-headers list — browser clients can read it. 2. The **changelog** entry under the [Releases tab](/changelog) documents the rationale and the migration path. Clients should monitor `Sunset` headers in production and surface them to operators. There is no `Deprecation` header in v1 today; rely on `Sunset` + the changelog. ## Webhook payload versioning Webhook payloads pin to a date-based `apiVersion` so subscribers can lock to a specific schema even as new versions roll out: | Webhook surface | Current `apiVersion` | Where it appears | | ---------------------------- | -------------------------------- | ----------------------------------------- | | Page webhooks (orders) | `2026-05-08` | `apiVersion` field on every event payload | | Agent webhooks (user events) | (no payload version field today) | n/a — schema additions are additive | Page-webhook subscribers can inspect `apiVersion` and write payload-handling code that branches on it. See [Page webhooks](/concepts/page-webhooks) for the locked schema. ## Surfaces an agent should know | You depend on | How to verify | | ----------------------------- | ------------------------------------------------------------------------------------------------------ | | The `error.type` enum | 10 locked values. See [Errors](/concepts/errors). Branch on this, not `code` or `message`. | | The scope set | Defined in `src/api/services/scope.constants.ts`. New scopes are additive; existing scopes are stable. | | The `Idempotency-Key` shape | 1–255 ASCII printable. Locked. See [Safe mutations](/concepts/safe-mutations). | | The `stf_` / `prd_` id format | Locked. Internal ids never appear on the wire. | ## Verification in code * `src/api/server.ts` — `/v1/*` mount point. * `src/api/middleware/cors.ts` — `Sunset` in the expose-headers list. * `src/api/types/webhook.types.ts` — `PAGE_WEBHOOK_API_VERSION` constant. * `src/api/contracts/error.zod.ts` — the locked 10-value `type` enum. # Agent webhooks Source: https://docs.mareaalcalina.com/concepts/webhooks Real-time event delivery to your agent for user lifecycle changes on developer keys. HMAC-signed payloads, 3 retries, 5-minute replay window. # Agent webhooks Marea POSTs JSON events to a URL you configure for your **developer key**. Use them to keep your agent (or downstream CRM / integration platform) in sync with the lifecycle of users you bootstrapped — without polling. **Two webhook surfaces, do not confuse them:** * **Agent webhooks** (this page) — fire on user lifecycle events (`user.verified`, `user.cancelled`) for users your developer key bootstrapped. Configured in the **developer dashboard** at `/developers/webhooks`. * **Page webhooks** — fire on merchant order events (`order.created`, `order.status_updated`, `order.paid`) for a single store / digital menu / page. Configured per-store by the merchant. See [Page webhooks](/concepts/page-webhooks). ## When do agent webhooks fire? Two events fire today: | Event type | When it fires | | ---------------- | --------------------------------------------------------------------------------------------- | | `user.verified` | A bootstrapped user successfully verifies their email after sign-up | | `user.cancelled` | A user cancels their account OR is hard-deleted by Marea (the `reason` field tells you which) | **Order events ship under Page webhooks, not here.** `order.created`, `order.status_updated`, and `order.paid` are merchant-side events tied to a specific store / page; they are configured per-store by the merchant, not by the developer key. See [Page webhooks](/concepts/page-webhooks). ## Configure your webhook URL In the developer dashboard at `/developers/webhooks`, set a single **Default URL** that receives events from all your developer keys: ``` https://your-server.com/marea-webhook ``` Marea requires HTTPS on a public host. Loopback (`localhost`, `127.0.0.1`), private IP ranges (RFC1918), the cloud-metadata IP (`169.254.169.254`), `.internal` and `.local` DNS suffixes, and IPv6 ULA / link-local / IPv4-mapped private addresses are rejected. ## Event payload shape Every webhook is a `POST` with `Content-Type: application/json` and a payload matching the type: ### `user.verified` ```json theme={null} { "type": "user.verified", "userId": "QqV9pK3nL...", "developerKeyId": "kid_abc123", "verifiedAt": "2026-05-08T19:42:11.823Z" } ``` ### `user.cancelled` ```json theme={null} { "type": "user.cancelled", "userId": "QqV9pK3nL...", "developerKeyId": "kid_abc123", "cancelledAt": "2026-05-08T22:03:45.117Z", "reason": "user_clicked_cancel" } ``` ### Cancellation reason enum (5 values) | `reason` | Meaning | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `user_clicked_cancel` | User explicitly cancelled their account from the dashboard | | `squatting_defense` | An unverified bootstrap account was cleaned up by Marea so a real user could sign up with the same email (PRD-4 squatting defense) | | `30d_unverified` | Bootstrapped account never verified their email; cleaned up after 30 days of inactivity | | `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 | Note: when a `user.cancelled` event fires, the user's data has already been deleted from Marea by the time the event reaches you. There is no follow-up `user.deleted` event. ## Verify signatures Every webhook is signed with HMAC-SHA256. The signature is in the `X-Marea-Signature` request header: ``` X-Marea-Signature: t=1714867200,v1=2c8a1dd3... (64-char hex) ``` The signature is computed over the string `"."` using a secret derived from your developer key. You **must** verify the signature on every incoming webhook before trusting the payload. ### Verification flow 1. Parse the `t=...` and `v1=...` from the header 2. Reject if `|now - t| > 300` (5-minute replay window) 3. Compute `HMAC-SHA256(secret, ".")` and hex-encode 4. Use a constant-time comparison against `v1` 5. If match: trust the payload. If not: respond `401`. Ready-to-paste implementations in JavaScript and Python live in [Webhook receiver helpers](/api/webhooks/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. ## 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 event is dropped. Marea also: 1. Marks `users/{uid}.lastUserEvents[]` with `deliveryFailed: true` (visible on the bootstrapped-users dashboard) 2. Sends a delivery-failure email to the developer 3. Fires an internal Telegram alert (`webhook_dispatch_failed` P1 in our observability) There is no automatic replay. If you need bulletproof delivery, queue events on your end (e.g. via a managed integration platform like Zapier or Make.com) so a transient receiver outage doesn't lose events. ## Receiver implementation tips * **Respond 200 immediately**, then process asynchronously. Marea's 5-second timeout is tight; if your business logic takes longer, ack quickly and process in a queue. * **Idempotency**: Marea may retry, so design your handler to be idempotent on `(type, userId, developerKeyId, verifiedAt|cancelledAt)`. Duplicate deliveries are rare but possible. * **Constant-time signature comparison**: don't use `===` or `==` on the HMAC — use `crypto.timingSafeEqual` (Node) or `hmac.compare_digest` (Python). * **Test with `webhook.site` first**: configure your webhook URL to point at a unique `webhook.site` URL while you wire up your real handler — you'll see the exact payloads Marea sends. ## What about per-key URLs? The dashboard configures a **single Default URL** per developer. All your keys' events POST to that URL. The `developerKeyId` field in every payload tells you which key triggered the event, so you can route environments server-side: ```js theme={null} function handleMareaWebhook(req, res) { if (!verifySignature(req)) return res.status(401).end(); const env = KEY_TO_ENV[req.body.developerKeyId]; // 'prod' | 'staging' | 'dev' routeToHandler(env, req.body); res.status(200).end(); } ``` For developers who genuinely need a separate URL per key (rare), Marea's API offers a per-key override: `POST /v1/webhooks/userEvents` with `{ keyId, url }` using the developer key in the `Authorization: Bearer mk_dev_...` header. The dispatcher checks per-key URLs first, then falls back to the developer-level Default URL. ## Limitations and roadmap * **Two events only today** — order events (`order.*`) ship as **[Page webhooks](/concepts/page-webhooks)**, a separate merchant-configured surface * **No replay endpoint** — once 3 retries fail, the event is gone. Use a buffered receiver if you need stronger guarantees * **Single Default URL** per developer in the dashboard. Per-key overrides via API only * **No signing-secret rotation UI** — to rotate, revoke the developer key and issue a new one For receiver-side helpers (signature verification code in JS + Python), see [Webhook receiver helpers](/api/webhooks/receiver-helpers). # Cloud functions AGENTS Source: https://docs.mareaalcalina.com/coordination/cloud-functions-AGENTS # DRAFT — `marea-alcalina-cloud-functions/AGENTS.md` > **Coordinated PR target** (BL-DOC-5): Agent A owns `marea-alcalina-cloud-functions/`. PRD-12 (this repo) is responsible for drafting the engineer-side AGENTS.md content; Agent A reviews + commits it to the cloud-functions repo. Do NOT directly write into the cloud-functions repo from here. > > Once Agent A merges this draft to `marea-alcalina-cloud-functions/AGENTS.md`, delete this file from `marea-alcalina-docs/coordination/`. *** # AGENTS.md — Marea Cloud Functions ## What this repo is Firebase Cloud Functions backing Marea Alcalina (catalog + orders + WhatsApp + email + payments). Public REST API at `api.mareaalcalina.com/v1/*` (PRD-1+). ## Architecture * Express + middleware chain (PRD-1 §10.x — 12 middleware in fixed order). * Firestore for data; Cloud Tasks for webhook delivery. * Two-tier API keys: `mk_dev_*` (developer; can bootstrap) + `mk_user_*` (per-user; cannot bootstrap) — PRD-1 + PRD-6. ## Repo conventions * All v1 endpoints in `src/api/v1/`. * Refactored cores in `src/api/services/` (PRD-2 + PRD-3). * Middleware in `src/api/middleware/`. * Zod schemas drive request validation AND OpenAPI generation (PRD-1 §10.3 via `@asteasolutions/zod-to-openapi`). * NEVER scan full Firestore collections — Firestore reads cost money. Always use targeted queries (see `feedback_firestore_reads.md` in user memory). * NEVER invent MRR / pricing / plan facts — pull from Stripe API or `plan-limits.shared.ts` only (see `feedback_no_invent_match_stripe.md`). ## Where to find specs * PRDs: `Strategic-2026-Plan/prds/PRD-{03..13}-public-api-*.md` * Playbook: `Strategic-2026-Plan/prds/00-PUBLIC-API-V1-IMPLEMENTATION-PLAYBOOK.md` * RFC: `Strategic-2026-Plan/rfcs/RFC-public-api-v1.md` * Strategy: `Strategic-2026-Plan/05-PUBLIC-API-V1-EXECUTION-STRATEGY.md` ## Key invariants (BL locks) * Idempotency cross-instance race protection (PRD-1). * 6-digit verification code is PLAIN, not bcrypt-hashed (PRD-3 BL-BOOT-3 — intentional: enables read-aloud variant). * `hardDeleteUserAccount` is atomic (PRD-3 BL-BOOT-8). * Publish endpoint middleware order: `planLimits → requireTosAccepted → idempotency` (PRD-7 BL-TOS-3). * `bootstrapRpd: 50` per `mk_dev_*` key per day (PRD-1). * 5-minute replay window on webhook signatures (PRD-8). ## Testing & deploy * `npm run build` after non-trivial changes (per `feedback_always_build_to_verify.md` — tsc alone misses Angular template / i18n / Tailwind issues; for cloud-functions it catches missing imports + Zod schema drift). * Validate CI configs before push (per `feedback_validate_before_push.md`). * Production deploy is gated on Stage-0 ship checklist in playbook §9. # Marea Alcalina API Source: https://docs.mareaalcalina.com/index Bootstrap a digital menu or product catalog and a public hosted storefront for a single operator — in under 10 calls. Channel-agnostic ordering. Storefronts ship in Spanish (default), English, or Portuguese. The order channel is the operator's choice — WhatsApp, web checkout, or in-person pickup — and configurable per storefront. **These docs are agent-first.** Every page is also available as raw markdown at `.md`, and every API reference page ships with a copy-paste-for-LLM block. Every error returns a `nextActions[]` array (`{label, method, url}`) — surface it to the user verbatim; **do NOT swallow 402 / 451 / 422 silently.** If you're an agent reading these docs to integrate, start with [llms.txt](/llms.txt) and [AGENTS.md](/AGENTS.md). ## Where to start Two-call flow: `POST /v1/users` returns a restricted user-key + emails a 6-digit code; verify to upgrade the key. User-key CRUD on the catalog. Idempotency-Key on every mutation. Public hosted URL. Handles 402 (paywall), 422 (0 products), and 451 (ToS not accepted). Paste-token install for Claude Desktop / Cursor / Continue.dev. 7 `marea.*` tools. ## Auth in 30 seconds ```http theme={null} GET /v1/me HTTP/1.1 Host: api.mareaalcalina.com Authorization: Bearer mk_dev_xxxxxxxxxxxxxxxx ``` Two-tier keys: * **`mk_dev_*`** — developer key. Held by the agent. Bootstraps users. * **`mk_user_*`** — per-user key. Returned by `POST /v1/users`. Manages owned data only; cannot bootstrap. The split is load-bearing for agent UX: an agent never needs to hold a user's password. See [Two-tier keys](/concepts/keys) for the full model. ## Five things every integration must do 1. **Send `Idempotency-Key` on every POST and PATCH.** Same key + same body returns the original response (replay-safe). See [Safe mutations](/concepts/safe-mutations). 2. **Branch on `error.type`, not `error.message`.** The `type` field is a stable 10-value enum; messages are localized. See [Errors](/concepts/errors). 3. **Surface `nextActions[]` verbatim.** It's a `{label, method, url}[]` array of concrete remediation steps. Don't paraphrase. 4. **Read `X-RateLimit-Remaining` and respect `Retry-After`.** See [Rate limits](/concepts/rate-limits) for current numbers. 5. **Handle the publish gates.** `POST .../publish` returns 402 (pre-paywall), 422 (0 products), or 451 (ToS not accepted) — surface each `nextActions[]` explicitly; don't auto-accept ToS or auto-publish without user confirmation. See [Publishing](/concepts/publishing). ## Webhook surfaces Marea ships two distinct webhook surfaces. Pick the one that matches your role: `user.verified`, `user.cancelled` for users your **developer key** bootstrapped. Configured in `/developers/webhooks`. HKDF-derived signing key. `order.created`, `order.status_updated`, `order.paid` for a single **store / digital menu**. Configured per-merchant in `/menus/profile?section=integrations`. Raw 32-byte hex signing secret. If a single endpoint receives both, branch on the `X-Marea-Source` header (`developer` vs `merchant`) to pick the right verifier. ## Agent-friendly resources * [**llms.txt**](/llms.txt) — job-to-be-done index of the entire docs surface * [**AGENTS.md**](/AGENTS.md) — common patterns + what NOT to do (cheatsheet) * [**OpenAPI 3.1 spec**](https://api.mareaalcalina.com/v1/openapi.json) — Zod-derived; 5-min cached * [**Postman collection**](/marea.postman.json) — auto-generated from the OpenAPI spec * [**MCP server**](https://mcp.mareaalcalina.com) — paste-token install for Claude Desktop / Cursor / Continue.dev * **Per-page markdown:** every page is also at `.md` (e.g., [/concepts/errors.md](/concepts/errors)) # Bootstrap a user account Source: https://docs.mareaalcalina.com/quickstart/bootstrap Two-call flow — POST /v1/users creates the user + storefront + restricted mk_user_* key + emails a 6-digit code; POST /v1/users/:userId/verify upgrades the same key to full scope. # Bootstrap a user account The bootstrap flow lets an agent create a Marea account on behalf of a small-business operator without ever holding the user's password. It's two calls separated by a 6-digit code that goes to the user's email. ## The architecture (read first) The two-tier key model is what makes this safe (full model: [Two-tier keys](/concepts/keys)): ``` agent (holds mk_dev_*) ──POST /v1/users──▶ Marea │ creates user, storefront, mk_user_* (RESTRICTED) │ emails 6-digit code to user ◀──── 201 { userKey: mk_user_xxx, ... } agent prompts user for code user reads code aloud OR agent fetches from Gmail-MCP ──POST /verify──▶ │ upgrades mk_user_* to FULL scope (same key, no rotation) ◀──── 200 { verificationStatus: "verified" } agent (now holds mk_user_*) ──any catalog op──▶ Marea ``` **Why the split:** the agent's `mk_dev_*` key can bootstrap users (scope `developer:bootstrap`) but cannot touch individual user data. The `mk_user_*` key returned by bootstrap is restricted to `me:verify` + `me:resendVerification` until the user submits the code — then the same key is upgraded in place to the full per-user scope set. The agent never holds the user's password and the user-key never has bootstrap power. ## Step 1 — Create the user Required scope on the calling key: `developer:bootstrap`. Hard cap: 50 bootstraps/day per dev key (`rpd: 50`). ```bash curl theme={null} curl -X POST https://api.mareaalcalina.com/v1/users \ -H "Authorization: Bearer mk_dev_xxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -H "Accept-Language: es-MX" \ -H "Idempotency-Key: 2f1a8c4b-2e3a-4b9d-9f1a-8c4b2e3a4b9d" \ -d '{ "email": "owner@taqueria.example", "displayName": "Marea Taqueria", "country": "MX", "language": "es", "currency": "MXN", "businessType": "restaurant", "sourceAgent": "claude-desktop" }' ``` ```javascript JavaScript theme={null} const res = await fetch("https://api.mareaalcalina.com/v1/users", { method: "POST", headers: { "Authorization": `Bearer ${process.env.MAREA_DEV_KEY}`, "Content-Type": "application/json", "Accept-Language": "es-MX", "Idempotency-Key": crypto.randomUUID(), }, body: JSON.stringify({ email: "owner@taqueria.example", displayName: "Marea Taqueria", country: "MX", language: "es", currency: "MXN", businessType: "restaurant", sourceAgent: "claude-desktop", }), }); const body = await res.json(); // body.userKey is shown ONCE — store it now. ``` ```python Python theme={null} import os, uuid, requests res = requests.post( "https://api.mareaalcalina.com/v1/users", headers={ "Authorization": f"Bearer {os.environ['MAREA_DEV_KEY']}", "Content-Type": "application/json", "Accept-Language": "es-MX", "Idempotency-Key": str(uuid.uuid4()), }, json={ "email": "owner@taqueria.example", "displayName": "Marea Taqueria", "country": "MX", "language": "es", "currency": "MXN", "businessType": "restaurant", "sourceAgent": "claude-desktop", }, ) body = res.json() # body["userKey"] is shown ONCE — store it now. ``` ### Request body fields | Field | Type | Required | Notes | | ------------------- | ----------------------------------- | -------- | --------------------------------------------------------------------------------------- | | `email` | string (RFC 5322) | yes | Where the 6-digit code is sent. | | `displayName` | string (1–200) | yes | Shown in the dashboard + on the storefront if no `name` override. | | `sourceAgent` | string (1–64, `^[A-Za-z0-9 _.-]+$`) | yes | Your agent identifier — e.g. `claude-desktop`, `cursor`, `my-saas`. Logged on the user. | | `country` | ISO 3166-1 alpha-2 | no | Defaults from `Accept-Language` then `MX`. | | `language` | `es` \| `en` \| `pt` | no | Defaults from `Accept-Language` then from country. | | `currency` | ISO 4217 | no | Defaults from country (e.g. `MX` → `MXN`). | | `businessType` | string | no | Defaults to `general`. Used to tune the starter storefront. | | `initialStorefront` | `StorefrontManifest` | no | If present, a storefront is created in the same call. | ### Response — 201 Created ```json theme={null} { "userId": "usr_4f1a2b3c4d5e6f7a8b9c0d1e", "storefrontId": "stf_9a8b7c6d5e4f3a2b1c0d9e8f", "userKey": "mk_user_01HZX9K8QW7VPYR3M2N1B4FJSA", "verificationStatus": "pending", "verificationExpiresAt": "2026-05-10T18:23:00.000Z", "verificationDeliveryHint": "email-only", "previewToken": "pv_8c4b2e3a4b9d9f1a8c4b2e3a", "appliedDefaults": { "language": "es", "currency": "MXN", "country": "MX", "businessType": "restaurant" }, "idempotent": false } ``` * The `userKey` is **shown once.** Store it immediately in the user's session — there is no way to read it back. * The key is **restricted** at this point: it can only call `POST /v1/users/:userId/verify` and `POST /v1/users/:userId/resendVerification` until the code is submitted. * `verificationExpiresAt` is the deadline for the 6-digit code. After expiry, call resend. * `previewToken` is an unguessable token: surface `https://marea.pro/preview/{previewToken}` to the user so they can see their starter storefront before verifying. * `appliedDefaults` reports the values Marea inferred when you omitted `country` / `language` / `currency` / `businessType`. ### 207 Multi-Status — partial-success with `initialStorefront` If you included `initialStorefront` with more products than the user's free-tier cap allows, the user + storefront are created and the over-cap products are dropped. Status is **207** and the response body adds an `errors[]` array describing each skipped product: ```json theme={null} { "userId": "usr_...", "storefrontId": "stf_...", "userKey": "mk_user_...", "verificationStatus": "pending", "verificationExpiresAt": "2026-05-10T18:23:00.000Z", "verificationDeliveryHint": "email-only", "previewToken": "pv_...", "appliedDefaults": { "language": "es", "currency": "MXN", "country": "MX", "businessType": "restaurant" }, "errors": [ { "type": "plan_limit", "code": "products_over_limit", "message": "Skipped 'Taco al pastor': free tier allows 30 products." } ] } ``` Surface the `errors[]` array to the user verbatim and offer the upgrade link from `GET /v1/me`. The accepted-and-stored products are already live; the over-cap ones must be added later via `POST /v1/storefronts/:storefrontId/products` after upgrade. ## Step 2 — Verify the 6-digit code The user receives an email with a 6-digit code (e.g. `123456`). Two patterns: * **User reads aloud / pastes** — the agent prompts: *"Check your inbox at `owner@taqueria.example` and tell me the 6-digit code."* * **Silent variant via Gmail MCP** — if the user has the Gmail MCP server installed, the agent can read the inbox directly. See [Verification flow](/concepts/verification-flow). Required scope on the calling key: `me:verify` (the restricted scope automatically granted on the just-issued `mk_user_*`). The `:userId` must match the key's owner — wrong id returns a leak-less `404 not_found`, not a `403`. ```bash curl theme={null} curl -X POST https://api.mareaalcalina.com/v1/users/usr_4f1a2b3c4d5e6f7a8b9c0d1e/verify \ -H "Authorization: Bearer mk_user_01HZX9K8QW7VPYR3M2N1B4FJSA" \ -H "Content-Type: application/json" \ -d '{ "code": "123456" }' ``` ```javascript JavaScript theme={null} const res = await fetch( `https://api.mareaalcalina.com/v1/users/${userId}/verify`, { method: "POST", headers: { "Authorization": `Bearer ${userKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ code: "123456" }), }, ); const body = await res.json(); // body.verificationStatus === "verified" — key is now full-scope. ``` ```python Python theme={null} res = requests.post( f"https://api.mareaalcalina.com/v1/users/{user_id}/verify", headers={ "Authorization": f"Bearer {user_key}", "Content-Type": "application/json", }, json={"code": "123456"}, ) body = res.json() # body["verificationStatus"] == "verified" — same key, now full scope. ``` ### Response — 200 OK ```json theme={null} { "userId": "usr_4f1a2b3c4d5e6f7a8b9c0d1e", "verificationStatus": "verified" } ``` The `mk_user_*` you already hold is now **upgraded in place** to the full per-user scope set (`catalog:read`, `catalog:write`, `storefront:publish`, `me:*`). Do not rotate; do not re-issue. Catalog mutations now succeed. Publish still requires the user to also accept the dashboard ToS — see [Publishing](/concepts/publishing). ## Step 2b — Resend the code If the user never received the email or the 10-minute TTL elapsed, call resend with the same `mk_user_*` key. Rate-limited per user: 3/hour, 5/day. ```bash theme={null} curl -X POST https://api.mareaalcalina.com/v1/users/usr_xxx/resendVerification \ -H "Authorization: Bearer mk_user_xxxxxxxxxxxxxxxx" ``` Response — 200 OK: ```json theme={null} { "verificationStatus": "pending", "verificationExpiresAt": "2026-05-10T18:38:00.000Z" } ``` The old code is voided automatically; only the new code in the freshly-sent email is valid. ## Recoverable error envelopes Every non-2xx follows the §9.6 envelope: `{ type, code, message, doc, param, requestId, requestLogUrl, recoverable, retryAfterMs, nextActions[], upgrade }`. Branch on `error.type` and `error.code`; never on `error.message` (localized). ### Step 1 — `POST /v1/users` ```json theme={null} // 401 — missing header { "error": { "type": "auth", "code": "missing_authorization", "message": "Authorization header is required.", "doc": "https://docs.mareaalcalina.com/concepts/keys#authorization", "param": "Authorization", "requestId": "req_30a9358b-70bd-44f3-aa5d-8983b558ad84", "requestLogUrl": "https://mareaalcalina.com/developers/logs/req_30a9358b-70bd-44f3-aa5d-8983b558ad84", "recoverable": false, "retryAfterMs": null, "nextActions": [ { "label": "Get a developer key.", "method": null, "url": "https://mareaalcalina.com/developers/keys" } ], "upgrade": null } } ``` ```json theme={null} // 409 — email already exists on Marea { "error": { "type": "conflict", "code": "email_exists", "message": "An account with this email already exists.", "doc": "https://docs.mareaalcalina.com/concepts/errors#email_exists", "param": "email", "requestId": "req_30a9358b-70bd-44f3-aa5d-8983b558ad84", "requestLogUrl": "https://mareaalcalina.com/developers/logs/req_30a9358b-70bd-44f3-aa5d-8983b558ad84", "recoverable": false, "retryAfterMs": null, "nextActions": [ { "label": "The user already has a Marea account — send them to log in.", "method": null, "url": "https://mareaalcalina.com/login" } ], "upgrade": null } } ``` ```json theme={null} // 429 — dev key hit the 50/day bootstrap cap { "error": { "type": "rate_limited", "code": "rpd_exceeded", "message": "Developer key has reached 50 bootstraps today. Retry in 6h.", "doc": "https://docs.mareaalcalina.com/concepts/rate-limits#bootstrap-cap", "param": null, "requestId": "req_30a9358b-70bd-44f3-aa5d-8983b558ad84", "requestLogUrl": "https://mareaalcalina.com/developers/logs/req_30a9358b-70bd-44f3-aa5d-8983b558ad84", "recoverable": true, "retryAfterMs": 21600000, "nextActions": [ { "label": "Wait and retry after the reset.", "method": null, "url": null } ], "upgrade": null } } ``` Quick lookup table: | HTTP | `error.code` | What happened | Agent action | | ---- | ------------------------------------------------------------------------------------------ | ------------------------------------- | ----------------------------------------------------------------------------------- | | 400 | `invalid_request` (various) | Email malformed, missing field, etc. | Fix the body, reissue with a NEW `Idempotency-Key`. | | 401 | `missing_authorization` / `invalid_authorization_format` / `key_not_found` / `key_revoked` | Bad / missing / revoked dev key | Re-prompt for a valid `mk_dev_*` key. | | 403 | `insufficient_scope` | Dev key lacks `developer:bootstrap` | Issue a key with the right scope; read `requiredScopes` / `heldScopes` in the body. | | 409 | `email_exists` | The email already has a Marea account | Send the user to log in. | | 429 | `rpd_exceeded` | 50 bootstraps/day cap hit | Sleep `retryAfterMs`, then retry. | ### Step 2 — `POST /v1/users/:userId/verify` ```json theme={null} // 400 — wrong digits { "error": { "type": "invalid_request", "code": "code_invalid", "message": "Invalid verification code.", "doc": "https://docs.mareaalcalina.com/concepts/errors#code_invalid", "param": "code", "requestId": "req_...", "requestLogUrl": "https://mareaalcalina.com/developers/logs/req_...", "recoverable": true, "retryAfterMs": null, "nextActions": [ { "label": "Ask the user to re-read the 6 digits from the email.", "method": null, "url": null } ], "upgrade": null } } ``` ```json theme={null} // 410 — 10-minute TTL elapsed { "error": { "type": "invalid_request", "code": "code_expired", "message": "Verification code has expired. Request a new one.", "doc": "https://docs.mareaalcalina.com/concepts/errors#code_expired", "param": "code", "requestId": "req_...", "requestLogUrl": "https://mareaalcalina.com/developers/logs/req_...", "recoverable": true, "retryAfterMs": null, "nextActions": [ { "label": "Send a fresh code, then retry.", "method": "POST", "url": "/v1/users/usr_xxx/resendVerification" } ], "upgrade": null } } ``` ```json theme={null} // 429 — 3 failed attempts on the same code { "error": { "type": "rate_limited", "code": "too_many_attempts", "message": "Too many failed attempts. Request a new code.", "doc": "https://docs.mareaalcalina.com/concepts/errors#too_many_attempts", "param": "code", "requestId": "req_...", "requestLogUrl": "https://mareaalcalina.com/developers/logs/req_...", "recoverable": true, "retryAfterMs": null, "nextActions": [ { "label": "Request a fresh code, then retry.", "method": "POST", "url": "/v1/users/usr_xxx/resendVerification" } ], "upgrade": null } } ``` Quick lookup: | HTTP | `error.code` | What happened | Agent action | | ---- | ------------------- | --------------------------------------------------- | ------------------------------------------------------ | | 400 | `code_invalid` | Wrong digits | Ask the user to re-read the email — up to 3 attempts. | | 404 | `code_not_found` | No active code (already verified, or never sent) | Call resend, then retry. | | 404 | `user_not_found` | `:userId` doesn't match the calling key (leak-less) | Agent is using the wrong `mk_user_*` for this `usr_*`. | | 410 | `code_expired` | 10-minute TTL elapsed | Call resend, then retry. | | 429 | `too_many_attempts` | 3 failed attempts on the same code | Call resend (issues a new code), then retry. | Full error matrix at [/concepts/errors](/concepts/errors). Idempotency rules at [/concepts/safe-mutations](/concepts/safe-mutations). Rate-limit defaults at [/concepts/rate-limits](/concepts/rate-limits). ## Next steps * [Add and edit products](/quickstart/products) — once verified, use the `mk_user_*` key to manage the catalog. * [Publish a storefront](/quickstart/publish) — handle the 402 paywall, 422 empty, and 451 ToS gates. * [Install MCP in Claude Desktop / Cursor / Continue.dev](/quickstart/mcp) — replace REST with `marea.*` tools when the user has MCP installed. # Install Marea in Claude Desktop, Cursor, and Continue.dev Source: https://docs.mareaalcalina.com/quickstart/mcp Paste-token MCP install — 7 marea.* tools available over stdio (mcp-remote) or streamable HTTP. # Install Marea in your AI client in 60 seconds The Marea MCP server runs at `https://mcp.mareaalcalina.com/mcp` and exposes the catalog API as **7 `marea.*` tools** that AI clients can call directly. Install once with your developer key and the user's AI client will route catalog calls through MCP instead of REST — gaining MCP elicitation prompts (explicit user confirmation on publish, ToS, and plan upgrades) for free. ## Step 1 — Generate a developer key Sign in at [mareaalcalina.com/developers/keys](https://mareaalcalina.com/developers/keys) and create an `mk_dev_*` key with at least the `developer:bootstrap` scope. Keep the key secret. The MCP client passes it to the Marea server on every call. ## Step 2 — Add Marea to your client Marea speaks streamable HTTP MCP at `https://mcp.mareaalcalina.com/mcp`. Most clients today still expect a stdio MCP server, so the install bridges the remote HTTP endpoint via [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) — a small published npm shim that pipes stdio ↔ remote HTTP. Open `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS (or `%APPDATA%\Claude\claude_desktop_config.json` on Windows) and add the `marea` entry: ```json theme={null} { "mcpServers": { "marea": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.mareaalcalina.com/mcp", "--header", "Authorization: Bearer mk_dev_xxxxxxxxxxxxxxxx" ] } } } ``` Restart Claude Desktop. The `marea.*` tools appear in the tool picker. Open `~/.cursor/mcp.json` (or use Settings → MCP → Add) and add: ```json theme={null} { "mcpServers": { "marea": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.mareaalcalina.com/mcp", "--header", "Authorization: Bearer mk_dev_xxxxxxxxxxxxxxxx" ] } } } ``` Reload the Cursor window. The Marea tools appear in the agent picker. In your project's `~/.continue/config.json`, add Marea under `experimental.modelContextProtocolServers`: ```json theme={null} { "experimental": { "modelContextProtocolServers": [ { "transport": { "type": "stdio", "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.mareaalcalina.com/mcp", "--header", "Authorization: Bearer mk_dev_xxxxxxxxxxxxxxxx" ] } } ] } } ``` Reload the Continue extension. The Marea tools appear under the MCP section of the toolbox. Need to share access without sharing the key? Pass `Authorization: Bearer ` instead of `mk_dev_*` to limit the install to a single user's storefronts — `marea.bootstrap_user` is rejected but every other tool works. ## Step 3 — Confirm the install Ask your AI client: **"List the Marea tools you have."** You should see exactly seven: | Tool | Calls | Scope required | | -------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------- | | `marea.bootstrap_user` | `POST /v1/users` | `developer:bootstrap` (dev key) | | `marea.whoami` | `GET /v1/me` | any | | `marea.create_storefront` | `POST /v1/storefronts` | `catalog:write` (user key) | | `marea.update_storefront` | `PATCH /v1/storefronts/:storefrontId` | `catalog:write` | | `marea.create_product` | `POST /v1/storefronts/:storefrontId/products` | `catalog:write` | | `marea.update_product` | `PATCH /v1/storefronts/:storefrontId/products/:productId` | `catalog:write` | | `marea.publish_storefront` | `POST /v1/storefronts/:storefrontId/publish` | `storefront:publish` — fires an MCP elicitation prompt before sending the request | **Verification is REST-only.** `POST /v1/users/:userId/verify` and `POST /v1/users/:userId/resendVerification` are deliberately not exposed as MCP tools — the agent calls them directly with the restricted `mk_user_*` it received from `marea.bootstrap_user`. The 6-digit code flow needs the user in the loop; an MCP tool round-trip would add latency without changing the UX. ## Discovery Clients that support MCP discovery can also find the server via the well-known endpoint: ``` https://mcp.mareaalcalina.com/.well-known/mcp.json ``` The well-known doc lists the same 7 tools, their JSON schemas, and the auth requirement (`Bearer mk_dev_*` or `Bearer mk_user_*`). ## Troubleshooting * **"Cannot find `mcp-remote`"** — bump to npm ≥ 7 and Node ≥ 18; `npx -y` will fetch on first run. * **"401 missing\_authorization"** — the `--header` argument was dropped during JSON-edit (common when editors strip backslashes). Re-paste from this page exactly. * **"403 insufficient\_scope" on `marea.bootstrap_user`** — your dev key lacks `developer:bootstrap`. Re-issue at [mareaalcalina.com/developers/keys](https://mareaalcalina.com/developers/keys). * **Claude Desktop doesn't see the tools** — restart Claude Desktop fully (Cmd-Q on macOS); a tab reload isn't enough. ## Next steps * [Bootstrap a user](/quickstart/bootstrap) — same flow, narrated for the REST surface (verify is REST-only in MCP too). * [Add and edit products](/quickstart/products) — the calls behind `marea.create_product` and `marea.update_product`. * [Publish a storefront](/quickstart/publish) — including the 402 / 422 / 451 gates that `marea.publish_storefront` surfaces via MCP elicitation. # Add and edit products Source: https://docs.mareaalcalina.com/quickstart/products User-key CRUD on the catalog. POST a product (201), PATCH a product (200) — both partial-merge friendly, both honor Idempotency-Key. # Add and edit products Once a user is verified ([Bootstrap](/quickstart/bootstrap)) and you hold their `mk_user_*` key, you can add and edit products on any storefront they own. Both endpoints require scope `catalog:write` and honor `Idempotency-Key` (see [Safe mutations](/concepts/safe-mutations)). ## Add a product `POST /v1/storefronts/{storefrontId}/products` — returns **201 Created** with the full product DTO. ```bash curl theme={null} curl -X POST https://api.mareaalcalina.com/v1/storefronts/stf_9a8b7c6d/products \ -H "Authorization: Bearer mk_user_xxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -H "Accept-Language: es-MX" \ -H "Idempotency-Key: 2f1a8c4b-2e3a-4b9d-9f1a-8c4b2e3a4b9d" \ -d '{ "title": "Taco al pastor", "price": 25, "imageUrl": "https://example.com/pastor.jpg", "category": "Tacos", "description": "Marinated pork, pineapple, cilantro, onion." }' ``` ```javascript JavaScript theme={null} const res = await fetch( `https://api.mareaalcalina.com/v1/storefronts/${storefrontId}/products`, { method: "POST", headers: { "Authorization": `Bearer ${userKey}`, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID(), }, body: JSON.stringify({ title: "Taco al pastor", price: 25, imageUrl: "https://example.com/pastor.jpg", category: "Tacos", description: "Marinated pork, pineapple, cilantro, onion.", }), }, ); const { product } = await res.json(); ``` ```python Python theme={null} import uuid, requests res = requests.post( f"https://api.mareaalcalina.com/v1/storefronts/{storefront_id}/products", headers={ "Authorization": f"Bearer {user_key}", "Content-Type": "application/json", "Idempotency-Key": str(uuid.uuid4()), }, json={ "title": "Taco al pastor", "price": 25, "imageUrl": "https://example.com/pastor.jpg", "category": "Tacos", "description": "Marinated pork, pineapple, cilantro, onion.", }, ) product = res.json()["product"] ``` ### Response — 201 Created ```json theme={null} { "product": { "id": "prd_8c4b2e3a4b9d9f1a8c4b2e3a", "title": "Taco al pastor", "description": "Marinated pork, pineapple, cilantro, onion.", "price": 25, "salePrice": null, "category": "Tacos", "subcategory": null, "imageUrl": "https://example.com/pastor.jpg", "thumbnailUrl": null, "sku": null, "slug": null, "position": 12, "cartProduct": null, "hide": null, "stock": null, "tags": null, "extraProductsCategory": null, "imageProcessingPending": true, "createdAt": "2026-05-10T18:25:00.000Z", "updatedAt": "2026-05-10T18:25:00.000Z" } } ``` `imageProcessingPending: true` means Marea will sweep the source URL into its own CDN asynchronously; `imageUrl` continues to serve from the source until then. ### Required + optional fields | Field | Type | Required | Notes | | --------------------------- | -------------- | -------- | ----------------------------------------------------- | | `title` | string (1–200) | yes | | | `price` | number (≥ 0) | yes | Storefront currency. | | `description` | string | no | | | `salePrice` | number (≥ 0) | no | If set + below `price`, shown crossed-out. | | `category` / `subcategory` | string | no | | | `imageUrl` / `thumbnailUrl` | URL | no | Both swept into Marea CDN async (BL-CAT-10). | | `sku` / `slug` | string | no | | | `position` | int | no | Defaults to last + 1. | | `cartProduct` / `hide` | boolean | no | | | `stock` | int | no | Surfaced on the storefront as inventory remaining. | | `tags` | string\[] | no | | | `extraProductsCategory` | object\[] | no | Modifier groups (size, toppings). See OpenAPI schema. | ## Update a product `PATCH /v1/storefronts/{storefrontId}/products/{productId}` — returns **200 OK** with the full updated `ProductDto`. Every field is PATCHable (no immutable fields). ```bash curl theme={null} curl -X PATCH https://api.mareaalcalina.com/v1/storefronts/stf_9a8b7c6d/products/prd_8c4b2e3a \ -H "Authorization: Bearer mk_user_xxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6e" \ -d '{ "price": 28, "salePrice": 25 }' ``` ```javascript JavaScript theme={null} const res = await fetch( `https://api.mareaalcalina.com/v1/storefronts/${storefrontId}/products/${productId}`, { method: "PATCH", headers: { "Authorization": `Bearer ${userKey}`, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID(), }, body: JSON.stringify({ price: 28, salePrice: 25 }), }, ); const { product } = await res.json(); ``` ```python Python theme={null} res = requests.patch( f"https://api.mareaalcalina.com/v1/storefronts/{storefront_id}/products/{product_id}", headers={ "Authorization": f"Bearer {user_key}", "Content-Type": "application/json", "Idempotency-Key": str(uuid.uuid4()), }, json={"price": 28, "salePrice": 25}, ) product = res.json()["product"] ``` Partial update — only the fields you send change. **To clear an optional field, send `null`** (e.g. `{ "imageUrl": null }`). Omitting a field leaves it untouched. ## Idempotency Same `Idempotency-Key` + same body within 24 hours → original response replayed (no duplicate writes). Same key + **different** body → `409 idempotency_conflict`. See [Safe mutations](/concepts/safe-mutations). If your batch importer hits a transient network error mid-flight, retry with the **same** `Idempotency-Key` per product — the server is replay-safe. ## Plan caps and 402 mid-batch The single-product `POST` endpoint returns **`402 plan_limit` (`plan_max_products_reached`)** the moment a write would exceed the user's plan cap. Stop the batch as soon as you see it; surface `error.upgrade.upgradeUrl`; do not retry until the user upgrades. ```json theme={null} // 402 — adding this product would exceed the plan cap { "error": { "type": "plan_limit", "code": "plan_max_products_reached", "message": "Plan limit of 30 products reached. Upgrade to add more.", "doc": "https://docs.mareaalcalina.com/concepts/plan-limits#products", "param": "products", "requestId": "req_...", "requestLogUrl": "https://mareaalcalina.com/developers/logs/req_...", "recoverable": true, "retryAfterMs": null, "nextActions": [ { "label": "Upgrade the plan to add more products.", "method": null, "url": "https://mareaalcalina.com/upgrade?planSource=api" } ], "upgrade": { "currentPlan": "free", "requiredPlan": "basic", "upgradeUrl": "https://mareaalcalina.com/upgrade?planSource=api" } } } ``` ### Bulk-load alternative: 207 Multi-Status If you're seeding a whole catalog at once, use `POST /v1/storefronts` with the manifest's `products[]` array (or pass `initialStorefront.products` on `POST /v1/users`). When the manifest exceeds the plan cap, Marea returns **`207 Multi-Status`** — the storefront is created with products up to the cap and the response `errors[]` array lists what was skipped: ```json theme={null} { "storefront": { "id": "stf_...", "name": "Tacos La Marea", "published": false }, "errors": [ { "type": "plan_limit", "code": "products_over_limit", "message": "Skipped 'Taco al pastor': free tier allows 30 products.", "details": { "skippedCount": 5, "skippedProducts": [/* ... */] } } ] } ``` Surface the skipped list to the user and offer the upgrade; the accepted products are already live. ## Other recoverable errors ```json theme={null} // 404 — wrong storefrontId/productId, or cross-tenant (leak-less) { "error": { "type": "not_found", "code": "storefront_not_found", "message": "Storefront not found.", "doc": "https://docs.mareaalcalina.com/concepts/errors#not_found", "param": null, "requestId": "req_...", "requestLogUrl": "https://mareaalcalina.com/developers/logs/req_...", "recoverable": false, "retryAfterMs": null, "nextActions": [], "upgrade": null } } ``` ```json theme={null} // 400 — body validation (e.g. negative price) { "error": { "type": "invalid_request", "code": "validation_failed", "message": "price must be ≥ 0.", "doc": "https://docs.mareaalcalina.com/concepts/errors#validation_failed", "param": "price", "requestId": "req_...", "requestLogUrl": "https://mareaalcalina.com/developers/logs/req_...", "recoverable": true, "retryAfterMs": null, "nextActions": [ { "label": "Fix the body, then retry with a NEW Idempotency-Key.", "method": null, "url": null } ], "upgrade": null } } ``` Quick lookup: | HTTP | `error.type` / `code` | What happened | Agent action | | ---- | ----------------------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------- | | 400 | `invalid_request` / `validation_failed` | Body validation (negative price, missing title, etc.) | Fix the body, reissue with a NEW `Idempotency-Key`. | | 402 | `plan_limit` / `plan_max_products_reached` | This single-product write would exceed the plan cap | Surface `error.upgrade.upgradeUrl`; do not retry until upgrade. | | 404 | `not_found` / `storefront_not_found` or `product_not_found` | Wrong id, or this user-key doesn't own it (silent cross-tenant) | Confirm the ids; cannot infer ownership. | | 409 | `idempotency_conflict` | Same `Idempotency-Key` reused with a different body | Generate a fresh UUID for the retry. | | 429 | `rate_limited` / `rpm_exceeded` | Per-user `rpm: 60` cap | Sleep `Retry-After` (seconds), retry with the same key. | ## Cross-references * [Storefronts](/concepts/storefronts) — parent-object model. * [Plan limits](/concepts/plan-limits) — product caps per plan (Free 30, Basic 60, Pro 200, Business 2000+). * [Safe mutations](/concepts/safe-mutations) — `Idempotency-Key` semantics. # Publish a storefront Source: https://docs.mareaalcalina.com/quickstart/publish POST .../publish with the four gates: 402 (pre-paywall), 422 (empty), 451 (ToS), 200 (live publicUrl). Idempotent on republish. # Publish a storefront Publish is the one operation that takes a storefront from draft to public — the URL becomes shareable, indexable, and live for end customers. Required scope: `storefront:publish`. **Always require explicit user confirmation before calling** — publish is user-visible. ## Pre-flight checklist Before calling publish: 1. The storefront must have **at least one product** (else 422 `no_products`). 2. The user must be on **any active plan** — Free, Basic, Pro, Business, or Agency. Pre-paywall accounts (just-signed-up, no plan picked) get 402. 3. The user must have **accepted the dashboard ToS modal** (else 451). The agent cannot bypass this. Short-circuit with `GET /v1/me` if you want to surface a CTA before the publish round-trip: ```json theme={null} { "plan": { "tier": "free", "limits": { "canPublish": true, "maxProducts": 30, "maxStorefronts": 1 } }, "tosAcceptedAt": "2026-04-15T10:23:00Z" } ``` If `plan.limits.canPublish` is `false` or `tosAcceptedAt` is `null`, surface the appropriate next-action to the user before attempting publish. ## Request `POST /v1/storefronts/{storefrontId}/publish` — body is optional. ```bash curl theme={null} curl -X POST https://api.mareaalcalina.com/v1/storefronts/stf_9a8b7c6d/publish \ -H "Authorization: Bearer mk_user_xxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 2f1a8c4b-2e3a-4b9d-9f1a-8c4b2e3a4b9d" \ -d '{}' ``` ```javascript JavaScript theme={null} const res = await fetch( `https://api.mareaalcalina.com/v1/storefronts/${storefrontId}/publish`, { method: "POST", headers: { "Authorization": `Bearer ${userKey}`, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID(), }, body: JSON.stringify({}), }, ); const { storefront } = await res.json(); // Surface storefront._links.publicUrl to the user. ``` ```python Python theme={null} import uuid, requests res = requests.post( f"https://api.mareaalcalina.com/v1/storefronts/{storefront_id}/publish", headers={ "Authorization": f"Bearer {user_key}", "Content-Type": "application/json", "Idempotency-Key": str(uuid.uuid4()), }, json={}, ) storefront = res.json()["storefront"] ``` Send `{ "versionId": "ver_xxx" }` to publish a specific version snapshot; omit for auto-version (the common case — Marea creates a fresh snapshot from the current draft). ## 200 OK — success ```json theme={null} { "storefront": { "id": "stf_9a8b7c6d5e4f3a2b1c0d9e8f", "name": "Tacos La Marea", "language": "es", "currency": "MXN", "businessType": "restaurant", "published": true, "publishedDate": "2026-05-10T18:30:00.000Z", "_links": { "previewUrl": "https://marea.pro/preview/pv_8c4b2e3a4b9d", "publicUrl": "https://marea.pro/tacos-la-marea", "editUrl": "https://mareaalcalina.com/dashboard/menu/stf_9a8b7c6d5e4f3a2b1c0d9e8f" } } } ``` Surface `_links.publicUrl` to the user — that's the share-and-print-and-QR-code URL. ## The four error gates Every error follows the §9.6 envelope verbatim. Branch on `error.type` + `error.code`; surface `nextActions[]` to the user without paraphrasing. ### 402 — plan paywall (`plan_blocks_publish`) The user is pre-paywall (no plan picked yet). Surface `error.upgrade.upgradeUrl` as a CTA; do not retry until the user upgrades. ```json theme={null} { "error": { "type": "plan_limit", "code": "plan_blocks_publish", "message": "Your plan does not permit publishing. Upgrade to publish this storefront.", "doc": "https://docs.mareaalcalina.com/concepts/plan-limits#publish-paywall", "param": null, "requestId": "req_30a9358b-70bd-44f3-aa5d-8983b558ad84", "requestLogUrl": "https://mareaalcalina.com/developers/logs/req_30a9358b-70bd-44f3-aa5d-8983b558ad84", "recoverable": true, "retryAfterMs": null, "nextActions": [ { "label": "Pick a plan to publish.", "method": null, "url": "https://mareaalcalina.com/upgrade?planSource=api" } ], "upgrade": { "currentPlan": "free", "requiredPlan": "basic", "upgradeUrl": "https://mareaalcalina.com/upgrade?planSource=api" } } } ``` ### 422 — empty storefront (`no_products`) The storefront has 0 products. Use `nextActions[0].url` to add a product before retrying. ```json theme={null} { "error": { "type": "invalid_request", "code": "no_products", "message": "Cannot publish an empty storefront. Add at least one product first.", "doc": "https://docs.mareaalcalina.com/concepts/publishing#no-products", "param": null, "requestId": "req_30a9358b-70bd-44f3-aa5d-8983b558ad84", "requestLogUrl": "https://mareaalcalina.com/developers/logs/req_30a9358b-70bd-44f3-aa5d-8983b558ad84", "recoverable": true, "retryAfterMs": null, "nextActions": [ { "label": "Add at least one product before publishing.", "method": "POST", "url": "/v1/storefronts/stf_9a8b7c6d5e4f3a2b1c0d9e8f/products" } ], "upgrade": null } } ``` ### 451 — ToS not accepted (`tos_required`) The user hasn't accepted the dashboard ToS modal. The agent **cannot** bypass — the user must complete the modal in their browser. Retry with the same `Idempotency-Key` after the user accepts. ```json theme={null} { "error": { "type": "tos_not_accepted", "code": "tos_required", "message": "The account holder must accept the Marea Terms of Service before this storefront can be published.", "doc": "https://docs.mareaalcalina.com/concepts/tos-jurisdiction", "param": null, "requestId": "req_30a9358b-70bd-44f3-aa5d-8983b558ad84", "requestLogUrl": "https://mareaalcalina.com/developers/logs/req_30a9358b-70bd-44f3-aa5d-8983b558ad84", "recoverable": true, "retryAfterMs": null, "nextActions": [ { "label": "Open the dashboard so the user can accept the Terms of Service.", "method": "GET", "url": "https://mareaalcalina.com/dashboard/tos" } ], "upgrade": null } } ``` ### 404 — not found (`storefront_not_found`) Wrong `storefrontId`, or this user-key doesn't own it. Cross-tenant returns 404 (leak-less), not 403 — you cannot infer existence. ## Summary table | HTTP | `error.type` / `code` | Why | What to do | | ---- | ------------------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------- | | 402 | `plan_limit` / `plan_blocks_publish` | Pre-paywall account | Surface `error.upgrade.upgradeUrl`; do not retry until upgrade. | | 422 | `invalid_request` / `no_products` | 0 products | Use `error.nextActions[0]` (POST products URL), then retry. | | 451 | `tos_not_accepted` / `tos_required` | ToS modal not completed | Surface `nextActions[0].url` (dashboard). Agent cannot bypass. Retry with same `Idempotency-Key` after user accepts. | | 404 | `not_found` / `storefront_not_found` | Wrong id or cross-tenant | Confirm id + ownership; cannot retry blindly. | | 429 | `rate_limited` / `rpm_exceeded` | Per-user `rpm: 60` cap | Sleep `Retry-After`, retry with same key. | ## Idempotent republishing Calling publish on an already-published storefront with the same `versionId` is a **no-op** that returns 200 with the same DTO. To republish a new version, omit `versionId` (auto-version) — Marea creates a fresh snapshot, runs the publish, and the live URL points at the new content. You can keep the same `Idempotency-Key` across retries of the same publish attempt; generate a fresh key for a new republish. ## Cross-references * [Publishing concept](/concepts/publishing) — full gate semantics + idempotency rules. * [ToS jurisdiction](/concepts/tos-jurisdiction) — what the 451 ToS modal contains. * [Plan limits](/concepts/plan-limits) — per-plan storefront caps + the publish-block-on-pre-paywall rule.