# Landing pages

A landing page is a focused, single-product conversion page. They're independent of the storefront catalogue — you can have a landing page with no live product (for upcoming launches), or one tied to a specific product for paid ads.

Sections (image carousels, order forms, fake visitors, countdowns, etc.) can be built through the API with the section endpoints below, or in the dashboard.

## Plan limits

| Plan                   | Landing pages (all statuses — drafts count)   |
| ---------------------- | --------------------------------------------- |
| Free                   | 0 (one-time purchase: 1000 DZD/lifetime each) |
| Pro                    | 3                                             |
| Unlimited / Enterprise | unlimited                                     |

The cap applies on every create path, the API included, and counts every landing page including drafts. `POST /v1/landing-pages` (and `POST /v1/landing-pages/generate`) on a store at its limit answers `403 limit_reached`. A Free-plan store can create a page only while it has an unused purchased slot; `POST /v1/landing-pages` marks that page `is_purchased: true`, which is what makes it visible on the storefront.

## `GET /v1/landing-pages`

List landing pages. Cursor-paginated. Served fresh on every call, like `GET /v1/landing-pages/{id}`.

**Auth:** platform key with `landing_pages:read`, which `GET /v1/landing-pages/{id}` needs too. A key without it gets `403 forbidden`.

### Query parameters

| Param    | Type                | Notes      |
| -------- | ------------------- | ---------- |
| `limit`  | int 1–200           | Default 50 |
| `cursor` | string              | Opaque     |
| `status` | `active` \| `draft` | Filter     |

An unrecognised `status` is ignored, returning all pages rather than a `400`.

### Response 200

```
{

  "data": {

    "items": [

      {

        "id":            42,

        "title":         "Black T-Shirt — 30% off",

        "slug":          "black-tshirt-30-off",

        "public_url":    "https://your-store.example.com/landing/black-tshirt-30-off",

        "status":        "active",

        "language":      "ar",

        "product_id":    26,

        "views":         1543,

        "is_purchased":  false,

        "created_at":    "2026-03-01 10:00:00",

        "updated_at":    "2026-03-15 14:22:11"

      }

    ],

    "next_cursor": null,

    "has_more": false

  }

}
```

## `GET /v1/landing-pages/{id}`

Detail with `section_count`.

```
{

  "data": {

    "id":               42,

    "title":            "Black T-Shirt — 30% off",

    "slug":             "black-tshirt-30-off",

    "public_url":       "https://your-store.example.com/landing/black-tshirt-30-off",

    "status":           "active",

    "language":         "ar",

    "product_id":       26,

    "views":            1543,

    "is_purchased":     false,

    "meta_title":       "Black T-Shirt — Cotton 200gsm — 30% off | DZBuild",

    "meta_description": "Limited-time offer on our cotton black t-shirt.",

    "section_count":    7,

    "created_at":       "2026-03-01 10:00:00",

    "updated_at":       "2026-03-15 14:22:11"

  }

}
```

### Field reference

| Field                             | Notes                                                                                                                                                                                                                                 |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status`                          | Strict `active` or `draft` only — there is no `archived` state for landing pages.                                                                                                                                                     |
| `language`                        | `ar`, `fr` or `en`.                                                                                                                                                                                                                   |
| `public_url`                      | Live address of the page: the store's address (its custom domain once it is live, otherwise its subdomain), then `/landing/` and the slug. `null` while the store has no address. Use it as is rather than building the URL yourself. |
| `product_id`                      | The linked product, or `null`. An order-taking section (`order_form`, `order_button`, `product_offers`) needs this or its own `settings.product_id`. Once set, PATCH can switch it to another product but not back to `null`.         |
| `views`                           | **Read-only.** Counted each time the public page is viewed; the API cannot write it and there is no way to reset it.                                                                                                                  |
| `is_purchased`                    | `true` once the page has been bought outright (1000 DZD/lifetime). On the Free plan this is what makes the page visible on the storefront.                                                                                            |
| `section_count`                   | Detail endpoint only — a live count of the page's sections, computed per request.                                                                                                                                                     |
| `meta_title` / `meta_description` | SEO tags. See the note under create.                                                                                                                                                                                                  |

## `POST /v1/landing-pages` — create

**Auth:** platform key with `landing_pages:write` and `landing_pages:read`. The answer reads the page back, so with `landing_pages:write` alone the page is saved and the call answers `403 forbidden`; the same holds for PATCH and `/publish`. **Requires `Idempotency-Key`.**

### Body

| Field              | Type                   | Required | Notes                                                                                                                                                                                                                                  |
| ------------------ | ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `title`            | string, 1 to 255 bytes | ✅       | The limit counts bytes, not letters: an Arabic letter takes 2 bytes, so an Arabic title tops out near 127 letters                                                                                                                      |
| `slug`             | string                 |          | Auto-derived from `title` if omitted. A slug you supply here is stored **without** normalisation — send a clean one                                                                                                                    |
| `status`           | `active` \| `draft`    |          | Default `draft`. Any other value is silently coerced to `draft`                                                                                                                                                                        |
| `language`         | `ar` \| `fr` \| `en`   |          | Default `ar`. Any other value is silently coerced to `ar`                                                                                                                                                                              |
| `product_id`       | int                    |          | Must belong to your store; the page links to this product                                                                                                                                                                              |
| `meta_title`       | string ≤ 255           |          | SEO title. Omit it via the API and it is stored and returned as `null` (unlike the dashboard form, which copies `title` into it). The public page still renders `title` as a fallback, so the visible page title is correct either way |
| `meta_description` | string                 |          | SEO description                                                                                                                                                                                                                        |

Slugs are made unique within your store by appending `-2`, `-3`, … An empty slug base falls back to `landing-` plus 6 hex characters.

### Errors

| Code                                                        | Cause                                                                                                   |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `bad_request` "Body must be valid JSON"                     | Wrong Content-Type or malformed JSON                                                                    |
| `bad_request` "title is required (1-255 chars)"             | Missing or over-long title                                                                              |
| `bad_request` "product\_id N does not belong to this store" | Cross-store id                                                                                          |
| `limit_reached` (403)                                       | The store is at its plan's landing page limit (drafts count). On Free: no unused purchased slot is left |

### Request

```
curl -X POST 'https://api.dzbuild.app/v1/landing-pages' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Content-Type: application/json" \

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{

    "title":      "Black T-Shirt — 30% off",

    "language":   "ar",

    "product_id": 26,

    "status":     "draft"

  }'
```

Returns **200** (not 201) and the same shape as `GET /v1/landing-pages/{id}`. The new landing page has no sections: add them with the section endpoints below. The publish check does not run on create, so create the page as a `draft` and publish it once its sections are in place; a page created with `status: active` goes live empty.

## `PATCH /v1/landing-pages/{id}`

Partial update.

PATCH validates more strictly than create: an invalid `status` returns `400 bad_request` ("status must be active or draft") and an invalid `language` returns `400` ("language must be ar, fr, or en") instead of being coerced. `title` must still be 1 to 255 bytes. A `slug` sent on PATCH **is** normalised, unlike on create. Setting `status` to `active` runs the publish check (see `/publish` below). Once a page has a product, `product_id: null` answers `422 product_required`; send another product id to switch products.

```
curl -X PATCH 'https://api.dzbuild.app/v1/landing-pages/42' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Content-Type: application/json" \

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{ "title": "Black T-Shirt — Spring promo" }'
```

Renaming auto-regenerates `slug` only if you didn't pass `slug` explicitly. When the slug changes, by a rename or explicitly, the old address keeps working and redirects to the new one.

## Sections

A page renders its sections from top to bottom. Section reads need `landing_pages:read`, section writes need `landing_pages:write`, and every write needs an `Idempotency-Key`.

| Endpoint                                              | Body                                                                                 | Answer                                                                             |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| `GET /v1/landing-page-section-types`                  |                                                                                      | `200`: `section_types`, each a `type` with its `default_settings`                  |
| `GET /v1/landing-pages/{id}/sections`                 |                                                                                      | `200`: `landing_page_id` and `sections` in render order (up to 500, not paginated) |
| `POST /v1/landing-pages/{id}/sections`                | `section_type` (or `type`), optional `settings`                                      | `201`: the new `section`, added at the end of the page                             |
| `POST /v1/landing-pages/{id}/sections/batch`          | `sections`: up to 30 objects with `type` (or `section_type`) and optional `settings` | `201`: the new `sections`. All or nothing: one bad entry saves none                |
| `POST /v1/landing-pages/{id}/sections/reorder`        | `sections`: every section id of the page, once each, in the new order                | `200`: the sections in their new order                                             |
| `PATCH /v1/landing-pages/{id}/sections/{section_id}`  | `settings`, optional `replace`                                                       | `200`: the updated `section`                                                       |
| `DELETE /v1/landing-pages/{id}/sections/{section_id}` |                                                                                      | `200`: `{ "deleted": true, "id": 901, "landing_page_id": 42 }`                     |

A section carries `id`, `section_type`, `sort_order` and `settings`. Answers that read the page back (the list, reorder and PATCH) also carry `created_at` and `updated_at`. The 14 types are `image`, `order_form`, `order_button`, `free_text`, `contact_button`, `countdown`, `fake_visitors`, `special_offer`, `price_display`, `product_offers`, `custom_form`, `image_carousel`, `announcement_bar` and `testimonials`. Read `GET /v1/landing-page-section-types` before writing, so your settings keys match what the page renders.

* Settings you send are merged over the type's defaults, so one call can create a fully set up section. On PATCH they are merged over what is stored; send `"replace": true` to start again from the type's defaults. Nested objects merge key by key; lists such as `slides`, `items`, `offers` and `fields` are replaced whole. The section type cannot change.
* An `order_form`, `order_button` or `product_offers` section needs a product: the page's `product_id` or its own `settings.product_id`. Without one the call answers `422 landing_page_has_no_product`. A `settings.product_id` from another store answers `422 validation_error`.
* The order form fields `show_name`, `show_phone` and `show_wilaya` are always saved as `true` in any section that has them.
* A `slides` list takes at most 20 entries and an `items` list at most 30 (`422 too_many_items`). Encoded settings may not pass 262144 bytes (`422 settings_too_large`). An unknown type answers `422 invalid_section_type`.
* On a write, a page outside your store answers `404 landing_page_not_found` (the list and the check answer `404 not_found`), and a section that is not on the page answers `404 section_not_found`. A reorder list that repeats or leaves out a section answers `422 validation_error`.
* Section changes appear in `GET /v1/changes`. An edit, a deletion or a reorder can be undone with `POST /v1/changes/{id}/undo`, and a deleted section that comes back through an undo gets a new id. Adding a section cannot be undone: delete it instead.

```
curl -X POST 'https://api.dzbuild.app/v1/landing-pages/42/sections/batch' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Content-Type: application/json" \

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{

    "sections": [

      { "type": "announcement_bar" },

      { "type": "order_form" }

    ]

  }'
```

## `GET /v1/landing-pages/{id}/check`

Reports what a buyer would find broken on the page. **Auth:** `landing_pages:read`.

The answer carries `landing_page_id`, `status`, `product_id`, `sections` (how many were checked), `publishable`, `blocked_by` (the first blocking message, or `null`) and `problems`. Each problem has `code`, `severity` (`blocking` or `warning`) and `message`; problems tied to one section also carry `section_id`.

| Code                         | Severity   | Meaning                                                                                                                                                         |
| ---------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `empty_page`                 | `blocking` | The page has no sections, so it renders blank                                                                                                                   |
| `order_form_without_product` | `blocking` | A section takes orders, but neither the page nor the section names a product, so orders would land at 0 DA                                                      |
| `section_product_not_found`  | `blocking` | A section points at a product that is not in your store                                                                                                         |
| `no_order_form`              | `warning`  | Nothing on the page can take an order                                                                                                                           |
| `multiple_order_forms`       | `warning`  | More than one `order_form` or `order_button` renders                                                                                                            |
| `variants_need_page_product` | `warning`  | A section names a product with variants while the page has no product. Variant pickers render only from the page's own product, so set `product_id` on the page |

Publishing is refused while a blocking problem remains (see below).

## `POST /v1/landing-pages/{id}/publish`

Convenience: flip status to `active`. Equivalent to `PATCH ... { status: "active" }`, and refused the same way: while `GET /v1/landing-pages/{id}/check` reports a blocking problem, the call answers `422 page_not_publishable` and the error carries the `problems` list. Edits that do not send `status` skip this check, even on a live page.

```
curl -X POST 'https://api.dzbuild.app/v1/landing-pages/42/publish' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Idempotency-Key: publish-42-$(date +%s)"
```

## `POST /v1/landing-pages/generate`

Builds a landing page from one of your products with AI. It answers `202` at once with a task to poll; a run takes about two minutes. **Auth:** the `ai:generate` scope. Keys created in the dashboard or with `POST /v1/keys` and third-party apps do not carry it and get `403 forbidden`; Claude, ChatGPT and DZBuild Copilot connections carry it. **Requires `Idempotency-Key`.**

| Field         | Type                 | Required | Notes                                                           |
| ------------- | -------------------- | -------- | --------------------------------------------------------------- |
| `title`       | string               | ✅       | At least 3 characters; cut at 255                               |
| `product_id`  | int                  | ✅       | An active product of your store with at least one image         |
| `description` | string               |          | Brief for the page, cut at 2000 characters                      |
| `language`    | `ar` \| `fr` \| `en` |          | Default `ar`                                                    |
| `size`        | `medium` \| `tall`   |          | Default `medium`. `medium` costs 20 AI credits, `tall` costs 45 |

The answer carries `task_id`, `size`, `credits_charged`, `eta_seconds` and `poll`. Credits are taken when the run starts and given back when the run fails or times out. Errors: `400 bad_request` (title missing or shorter than 3 characters), `402 quota_exceeded` (not enough AI credits), `403 limit_reached` (plan landing page limit, with `limit` and `current`), `409 already_processing` (one generation per store at a time), `422 product_required`, `product_not_found` or `product_has_no_image`, `429 rate_limited` or `too_many_concurrent`, `503 provider_unavailable` (no credits taken).

Poll `GET /v1/landing-pages/generate/{task_id}` with `landing_pages:read`. It answers `task_id`, `status` (`processing` while the page is built, then `completed` or `failed`), `landing_page_id` once the page exists, `current_step` and `error`. A run still processing after 10 minutes is marked `failed` with error `timeout` at the next poll, and its credits are refunded.

## `DELETE /v1/landing-pages/{id}`

Hard delete. The page's sections are removed with it.

```
curl -X DELETE 'https://api.dzbuild.app/v1/landing-pages/42' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Idempotency-Key: del-42"
```

Response: `{ "data": { "deleted": true, "id": 42 } }`.
