# Promo codes

These four endpoints manage the store's promo codes, the codes buyers type at checkout to lower what they pay. They work on the same codes as the promo codes page of the dashboard, described in [Discount codes](https://dzbuild.com/docs/selling/discount-codes.md), and they can also rename a code, which the dashboard cannot.

A code takes its discount off the products subtotal, never off shipping. A `percentage` code takes that share of the subtotal. A `fixed` code subtracts its amount in DZD and never takes off more than the subtotal. A code cannot be limited to a product, a category or a customer, cannot cap the discount on a large cart, and cannot give free shipping.

## Before you start

* The **Promo codes** add-on must be active on the store (`/dashboard/addons`). The list works without it and tells you in `addon_enabled`. Every write answers `409 addon_inactive` until the add-on is on, and buyers cannot use any code at checkout while it is off.
* The key needs the promo scopes. Keys created from the dashboard (`/dashboard/api`) get both. Scopes are frozen when a key is created, so a key that lacks them answers `403 forbidden`: create a new key from the dashboard.
* `starts_at` and `expires_at` are in Algeria time, in the form `YYYY-MM-DD HH:MM:SS`.

| Scope          | Description                                                                     |
| -------------- | ------------------------------------------------------------------------------- |
| `promos:read`  | Read promo codes.                                                               |
| `promos:write` | Create, update and delete promo codes. This changes the prices your buyers pay. |

## The promo code object

| Field                      | Type           | Meaning                                                                                   |
| -------------------------- | -------------- | ----------------------------------------------------------------------------------------- |
| `id`                       | int            | The code's id in the store.                                                               |
| `code`                     | string         | What buyers type. Upper case, `A-Z`, `0-9`, `-` and `_`, unique in the store.             |
| `discount_type`            | string         | `percentage` or `fixed`.                                                                  |
| `discount_value`           | number         | The percentage (above 0, at most 100) or the amount in DZD.                               |
| `min_order_amount`         | number or null | The products subtotal an order must reach for the code to apply. `null` means no minimum. |
| `max_uses`                 | int or null    | How many orders can use the code. `null` means unlimited.                                 |
| `used_count`               | int            | How many orders have used it. Read-only.                                                  |
| `is_active`                | bool           | An inactive code is refused at checkout.                                                  |
| `starts_at`                | string or null | Before this time the code is refused. `null` means it works right away.                   |
| `expires_at`               | string or null | After this time the code is refused. `null` means it never expires.                       |
| `created_at`, `updated_at` | string         | `YYYY-MM-DD HH:MM:SS`, server time.                                                       |

## `GET /v1/promo-codes`

The store's codes, newest first. There is no call that reads a single code by id: page through this list.

**Auth:** platform key with `promos:read`.

### Query parameters

| Param       | Type   | Default | Notes                                                                                                                       |
| ----------- | ------ | ------- | --------------------------------------------------------------------------------------------------------------------------- |
| `is_active` | string | none    | `true`, `1`, `yes` or `on` returns the active codes. Any other value returns the inactive ones. Leave it out for all codes. |
| `limit`     | int    | 50      | 1 to 200.                                                                                                                   |
| `cursor`    | string | none    | `next_cursor` of the previous page. See [Pagination](https://dzbuild.com/api-docs/pagination.md).                           |

### Request

```
curl 'https://api.dzbuild.app/v1/promo-codes?is_active=true' \

  -H "Authorization: Bearer $DZ_KEY"
```

### Response 200

```
{

  "data": {

    "items": [

      {

        "id": 20,

        "code": "WELCOME10",

        "discount_type": "percentage",

        "discount_value": 10,

        "min_order_amount": 3000,

        "max_uses": 100,

        "used_count": 7,

        "is_active": true,

        "starts_at": null,

        "expires_at": "2026-11-30 23:59:00",

        "created_at": "2026-10-06 14:20:11",

        "updated_at": "2026-10-06 14:20:11"

      }

    ],

    "next_cursor": null,

    "has_more": false,

    "addon_enabled": true

  }

}
```

`addon_enabled` says whether the **Promo codes** add-on is active. When it is `false`, the list still answers, but writes fail and buyers cannot use the codes.

## `POST /v1/promo-codes`

Creates a code. It works at checkout as soon as it is created, unless you send `is_active: false` or a later `starts_at`.

**Auth:** platform key with `promos:write`. **Requires `Idempotency-Key`.**

### Body

| Field                   | Type           | Required | Notes                                                                                                                                                                                                   |
| ----------------------- | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `code`                  | string         | yes      | Spaces around it are removed and letters are upper-cased, then it must be 2 to 30 characters of `A-Z`, `0-9`, `-` or `_`. A code that already exists in the store answers `409 code_exists`.            |
| `discount_type`         | string         | yes      | `percentage` or `fixed`, in any case. There is no default.                                                                                                                                              |
| `discount_value`        | number         | yes      | Above 0. At most 100 for `percentage`, and 100 needs the merchant's confirmation (see the section on 100% discounts below). No upper limit for `fixed`.                                                 |
| `min_order_amount`      | number or null | no       | Minimum products subtotal in DZD. `0`, `""` or `null` means no minimum.                                                                                                                                 |
| `max_uses`              | int or null    | no       | 1 or more. `0`, `""` or `null` means unlimited.                                                                                                                                                         |
| `is_active`             | bool           | no       | Defaults to `true`. Send a JSON boolean: a string such as `"false"` is read as `true`.                                                                                                                  |
| `starts_at`             | string or null | no       | A common date-time form, such as `2026-11-01 08:00` or an ISO 8601 string. Stored as `YYYY-MM-DD HH:MM:SS` in Algeria time; a value with a UTC offset is converted. `null` or `""` means no start date. |
| `expires_at`            | string or null | no       | Same forms. It must be in the future and after `starts_at`. `null` or `""` means no expiry.                                                                                                             |
| `confirm_token`         | string         | no       | Only for a 100% discount.                                                                                                                                                                               |
| `confirm_full_discount` | bool           | no       | Only for a 100% discount.                                                                                                                                                                               |

### Request

```
curl -X POST 'https://api.dzbuild.app/v1/promo-codes' \

  -H "Authorization: Bearer $DZ_KEY" \

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

  -H "Idempotency-Key: promo-welcome10-create" \

  -d '{"code": "welcome10", "discount_type": "percentage", "discount_value": 10, "min_order_amount": 3000, "max_uses": 100, "expires_at": "2026-11-30 23:59"}'
```

### Response 201

```
{

  "data": {

    "id": 20,

    "code": "WELCOME10",

    "discount_type": "percentage",

    "discount_value": 10,

    "min_order_amount": 3000,

    "max_uses": 100,

    "used_count": 0,

    "is_active": true,

    "starts_at": null,

    "expires_at": "2026-11-30 23:59:00",

    "created_at": "2026-10-06 14:20:11",

    "updated_at": "2026-10-06 14:20:11"

  }

}
```

## `PATCH /v1/promo-codes/{id}`

Changes only the fields you send, with the same rules as the create. A field you leave out keeps its value.

**Auth:** platform key with `promos:write`. **Requires `Idempotency-Key`.**

* `code` can be changed. The new text must be free in the store, otherwise the call answers `409 code_exists`.
* `discount_type` and `discount_value` are checked as a pair. Sending only `discount_type: "percentage"` on a 500 DZD `fixed` code answers `422 invalid_discount_value`, because 500 is above 100. Send both.
* A new `expires_at` must be in the future. A code that has already expired stays editable as long as you do not send `expires_at`.
* `used_count` cannot be written.
* An empty body changes nothing and returns the code.
* A code of another store answers `404`, like a code that does not exist.

### Request

```
curl -X PATCH 'https://api.dzbuild.app/v1/promo-codes/20' \

  -H "Authorization: Bearer $DZ_KEY" \

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

  -H "Idempotency-Key: promo-20-extend" \

  -d '{"max_uses": 200, "expires_at": "2026-12-31 23:59"}'
```

### Response 200

```
{

  "data": {

    "id": 20,

    "code": "WELCOME10",

    "discount_type": "percentage",

    "discount_value": 10,

    "min_order_amount": 3000,

    "max_uses": 200,

    "used_count": 7,

    "is_active": true,

    "starts_at": null,

    "expires_at": "2026-12-31 23:59:00",

    "created_at": "2026-10-06 14:20:11",

    "updated_at": "2026-10-20 09:05:42"

  }

}
```

## `DELETE /v1/promo-codes/{id}`

Deletes the code, so buyers can no longer use it. Orders already placed with it keep their discount. To pause a code without losing its usage count, send `is_active: false` with `PATCH` instead.

**Auth:** platform key with `promos:write`. **Requires `Idempotency-Key`.**

### Request

```
curl -X DELETE 'https://api.dzbuild.app/v1/promo-codes/20' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Idempotency-Key: promo-20-delete"
```

### Response 200

```
{

  "data": {

    "deleted": true,

    "id": 20

  }

}
```

## 100% discounts

A `percentage` code at 100 makes the goods free for every buyer who has the code. A create, or an update that sends `discount_type` or `discount_value`, is not written on the first call when the result is a 100% `percentage` code:

1. The call answers `422 confirmation_required` and writes nothing. The error carries `confirm_token` (single use, valid `600` seconds), `action` and `will_change`, a summary to show the merchant.
2. Once the merchant approves, repeat the same body with `confirm_token` added and a **new** `Idempotency-Key` (the first key is bound to the body without the token). A token that expired, was already used, or no longer matches the request answers `422 confirmation_stale` with a fresh token.

With a key created from the dashboard you can skip the round trip by sending `confirm_full_discount: true` in the first call. The DZBuild Copilot cannot use that flag and always goes through the token.

```
{

  "error": {

    "code": "confirmation_required",

    "message": "A 100% discount makes every order free ...",

    "confirm_token": "cft_xxxxxxxxxxxxxxxx",

    "confirm_token_expires_in": 600,

    "action": "promo.full_discount:new",

    "will_change": {

      "action": "Create a promo code that makes orders free",

      "code": "FREEGIFT",

      "discount": "100% off the whole order subtotal",

      "reversible": true,

      "note": "Any customer with this code pays 0 for the goods. Orders already placed with it cannot be reversed by deleting the code."

    }

  }

}
```

On an update, `action` ends with the code's id instead of `new`, and `will_change.action` reads `Change promo code #20 to make orders free`.

```
curl -X POST 'https://api.dzbuild.app/v1/promo-codes' \

  -H "Authorization: Bearer $DZ_KEY" \

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

  -H "Idempotency-Key: promo-freegift-approved" \

  -d '{"code": "FREEGIFT", "discount_type": "percentage", "discount_value": 100, "max_uses": 1, "confirm_token": "cft_REPLACE_WITH_TOKEN"}'
```

## Change history and undo

Every create, update and delete made through the API is recorded. Codes changed on the dashboard page are not recorded, so they cannot be undone through the API.

* `GET /v1/changes?entity=promo_code` lists the promo code changes, newest first, with `store:read`. Each item carries the change `id`, the code's id as `entity_id`, the `action` (`create`, `update` or `delete`) and a `summary`.
* `POST /v1/changes/{id}/undo` needs `promos:write`, an `Idempotency-Key` and the add-on active, like any write.
* Undoing an update puts the earlier values back, with no confirmation, even a 100% discount or an expiry date that has passed. If the code was deleted since, the undo answers `422 restore_target_missing`.
* Undoing a delete re-creates the code with its usage count, and keeps its id when that id is still free.
* Undoing a create answers `422 nothing_to_restore`: delete the code instead.
* An installed app's token cannot read or undo changes: both answer `403 forbidden`.

```
curl -X POST 'https://api.dzbuild.app/v1/changes/500/undo' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Idempotency-Key: undo-500"
```

```
{

  "data": {

    "undone": true,

    "change_id": 500,

    "entity": "promo_code",

    "undo_change_id": 510

  }

}
```

A change undone a second time answers `409 already_undone`.

## Errors

| HTTP | Code                                          | Cause                                                                                                                                                                                 |
| ---- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | `bad_request`                                 | The id in the path is not all digits, the body is not valid JSON, or `Idempotency-Key` is missing or malformed.                                                                       |
| 403  | `forbidden`                                   | `Missing scope: promos:read` or `Missing scope: promos:write`, or `API access requires an active Enterprise plan` for a merchant key whose store is not on an active Enterprise plan. |
| 404  | `not_found`                                   | No promo code with this id in the store.                                                                                                                                              |
| 409  | `addon_inactive`                              | The Promo codes add-on is not active on the store. Writes only.                                                                                                                       |
| 409  | `code_exists`                                 | Another code of the store already has this text.                                                                                                                                      |
| 422  | `invalid_code`                                | `code` is shorter than 2 or longer than 30 characters, or has a character other than `A-Z`, `0-9`, `-` and `_`.                                                                       |
| 422  | `invalid_discount_type`                       | `discount_type` is missing, or is not `percentage` or `fixed`.                                                                                                                        |
| 422  | `invalid_discount_value`                      | `discount_value` is missing, not a number, 0 or less, or above 100 for `percentage`.                                                                                                  |
| 422  | `invalid_min_order_amount`                    | `min_order_amount` is not a number.                                                                                                                                                   |
| 422  | `invalid_max_uses`                            | `max_uses` is not a number, or is below 1.                                                                                                                                            |
| 422  | `invalid_starts_at`, `invalid_expires_at`     | The date cannot be read.                                                                                                                                                              |
| 422  | `expires_at_in_past`                          | The `expires_at` you sent is not in the future.                                                                                                                                       |
| 422  | `invalid_date_window`                         | `expires_at` is not after `starts_at`.                                                                                                                                                |
| 422  | `confirmation_required`, `confirmation_stale` | A 100% discount waits for the merchant's approval. See the section above.                                                                                                             |
| 422  | `idempotency_key_reuse`                       | The same `Idempotency-Key` was used with a different body or path.                                                                                                                    |
| 500  | `server_error`                                | The write failed. Retry with the same `Idempotency-Key`.                                                                                                                              |

A `4xx` answer is stored with its `Idempotency-Key` for 24 hours and replayed to a retry with the same body. After you activate the add-on or fix the body, send the call with a new key. Errors that every endpoint shares, such as `401`, `402` and `429`, are in [Errors](https://dzbuild.com/api-docs/errors.md), and the retry rules are in [Idempotency](https://dzbuild.com/api-docs/idempotency.md).

## Known limits

* **Uses can pass `max_uses`.** When several buyers check out with the same code at the same moment, `used_count` can end above `max_uses`.
* **No webhook.** Creating, changing or deleting a promo code sends no webhook event.
