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

# Add and edit products

> User-key CRUD on the catalog. POST a product (201), PATCH a product (200) — both partial-merge friendly, both honor Idempotency-Key.

export const LLMBlock = ({action, method, path, keyType, bodyExample, summary, errors, endpoint}) => <>
    <h2>For agents</h2>
    <p>Copy this block into your AI assistant to give it everything it needs:</p>
    <pre>
      <code>{`────────────────────────────────────────────────────────────
You can ${action} via the Marea Catalog API.
Endpoint: ${method} https://api.mareaalcalina.com${path}
Header: Authorization: Bearer <${keyType}>
Body schema: ${bodyExample}
What this does: ${summary}
Errors: ${errors}
Full reference (markdown): https://docs.mareaalcalina.com/api/${endpoint}.md
────────────────────────────────────────────────────────────`}</code>
    </pre>
  </>;

# Add and edit products

Every catalog operation uses your `mk_user_*` key. You get it one of two ways:

* **You own a Marea store.** Mint `$MAREA_USER_KEY` from your dashboard (the `mk_user_*` represents *you*). This is the common case.
* **You're a partner.** The user-key was returned in the `POST /v1/users` response when you bootstrapped the user — see [Bootstrap a user account](/quickstart/bootstrap).

```bash theme={null}
export MAREA_USER_KEY=mk_user_xxxxxxxxxxxxxxxx
export STOREFRONT_ID=stf_xxxxxxxxxxxxxxxx     # GET /v1/storefronts if you don't know it
```

Both endpoints below 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.

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.mareaalcalina.com/v1/storefronts/$STOREFRONT_ID/products \
    -H "Authorization: Bearer $MAREA_USER_KEY" \
    -H "Content-Type: application/json" \
    -H "Accept-Language: es-MX" \
    -H "Idempotency-Key: $(uuidgen)" \
    -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"]
  ```
</CodeGroup>

### 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).

<CodeGroup>
  ```bash curl theme={null}
  curl -X PATCH https://api.mareaalcalina.com/v1/storefronts/$STOREFRONT_ID/products/$PRODUCT_ID \
    -H "Authorization: Bearer $MAREA_USER_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: $(uuidgen)" \
    -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"]
  ```
</CodeGroup>

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": "invalid_request",
    "message": "price must be ≥ 0.",
    "doc": "https://docs.mareaalcalina.com/concepts/errors",
    "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`                                                        | Body validation (negative price, missing title, malformed field) — see `error.param` for which field failed       | 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`                                     | Wrong storefront id, or this user-key doesn't own it (silent cross-tenant). Product not-found also surfaces here. | 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` / `rate_limit_exceeded` (message contains `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.

<LLMBlock action="add a product to a Marea storefront" method="POST" path="/v1/storefronts/{storefrontId}/products" keyType="mk_user_*" bodyExample={`{ "title": "Taco al pastor", "price": 25, "category": "Tacos" }`} summary="Adds a product to an existing storefront the user-key owns. Returns 201 with the full product DTO. Honors Idempotency-Key. Required: title (1-200 chars), price (≥ 0). Optional: description, salePrice, category, subcategory, imageUrl, thumbnailUrl, sku, slug, position, cartProduct, hide, stock, tags, extraProductsCategory. For bulk imports, prefer POST /v1/storefronts with manifest.products[] which returns 207 Multi-Status when the plan cap is exceeded." errors="402 plan_limit/plan_max_products_reached (surface upgrade.upgradeUrl; do not retry); 404 not_found (wrong id or cross-tenant — silent); 400 invalid_request (fix body, NEW Idempotency-Key); 409 idempotency_conflict (new UUID); 429 rate_limited (sleep retryAfterMs, retry with same key)." endpoint="catalog/create-product" />
