# Changes and undo

Most configuration writes made through the API are recorded as changes: store settings, design and theme, home page sections, categories, stock, promo codes, pixels, shipping rates and settings, and landing page sections. Each change keeps the values it replaced, and an undo writes them back through the same checks as the original write. Writes made on the store by Copilot, a connected AI assistant (Claude or ChatGPT) or an installed app are recorded too. Changes saved in the dashboard are not.

The three endpoints below list the changes, read one in full and undo one. The writes under `/v1/store/home-layout` and the courier rate sync answer with a `change_id`. For the other writes, find the change with `GET /v1/changes`.

## What is recorded

| `entity`              | Recorded by                                                                                  | `entity_id`                                        | Scope to read it in full | Scope to undo it      |
| --------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------ | --------------------- |
| `store.settings`      | `PATCH /v1/store`                                                                            | `settings`                                         | `store:read`             | `store:write`         |
| `store.design`        | `PATCH /v1/store/design`                                                                     | `design`                                           | `store:read`             | `store:write`         |
| `store.theme`         | `POST /v1/store/theme`, `POST /v1/store/fast-checkout-theme`, `POST /v1/store/variant-style` | `theme`                                            | `store:read`             | `store:write`         |
| `store.home_sections` | `PATCH /v1/store/home-sections`                                                              | `home_sections`                                    | `store:read`             | `store:write`         |
| `store.home_layout`   | Every write under `/v1/store/home-layout`                                                    | `layout`                                           | `store:read`             | `store:write`         |
| `category`            | `POST /v1/categories`, `PATCH` and `DELETE /v1/categories/{id}`                              | Category id                                        | `products:read`          | `products:write`      |
| `stock`               | `POST /v1/products/{id}/stock`                                                               | Product id                                         | `products:read`          | `products:write`      |
| `promo_code`          | `POST /v1/promo-codes`, `PATCH` and `DELETE /v1/promo-codes/{id}`                            | Promo code id                                      | `promos:read`            | `promos:write`        |
| `pixels`              | `POST /v1/pixels`, `PATCH` and `DELETE /v1/pixels/{id}`                                      | The pixel's id on DZBuild, not its `pixel_id`      | `pixels:read`            | `pixels:write`        |
| `shipping.rates`      | `POST /v1/shipping/rates`, `POST /v1/shipping/rates/sync`                                    | `rates`                                            | `shipping:read`          | `shipping:write`      |
| `shipping.settings`   | `PATCH /v1/shipping/settings`                                                                | `settings`                                         | `shipping:read`          | `shipping:write`      |
| `lp.section`          | The section writes under `/v1/landing-pages/{id}/sections`                                   | Section id, or `lp:` and the page id for a reorder | `landing_pages:read`     | `landing_pages:write` |

* These writes are not recorded and cannot be undone: products with their images, variants, offers, add-ons and quantity rules (stock is recorded), orders, landing pages themselves, the category order, couriers, webhooks and keys.
* `lp.page` is accepted as an `entity` filter, but no write records it.
* Recording does not block a write. When a change cannot be recorded, for example because its before or after values pass 256 KB, the write still goes through and cannot be undone. Shipping rate writes are the exception: they check the size first and answer `422 snapshot_too_large` instead of running.
* Every scope in the table is among the default scopes of a new key, so a key created from the dashboard (**Settings → API**, `/dashboard/api`) can use the three endpoints. Scopes are frozen when a key is created, so an older key that lacks the scope it needs answers `403 forbidden`: create a new key from the dashboard.

## `GET /v1/changes`

The store's changes, newest first, without the values they replaced.

**Auth:** platform key with `store:read`. An installed app's token gets `403 forbidden` (`Apps cannot use this endpoint`).

### Query parameters

| Param    | Type   | Default | Notes                                                                                                            |
| -------- | ------ | ------- | ---------------------------------------------------------------------------------------------------------------- |
| `entity` | string | none    | Only the changes of one `entity` from the table above. Any other value is ignored and the whole list comes back. |
| `limit`  | int    | 25      | 1 to 100. A smaller value counts as 1, a larger one as 100.                                                      |
| `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/changes?limit=2' \

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

### Response 200

```
{

  "data": {

    "items": [

      {

        "id": 120,

        "entity": "shipping.rates",

        "entity_id": "rates",

        "action": "update",

        "summary": "Shipping rates updated for 2 wilaya(s)",

        "undone_at": null,

        "created_at": "2026-10-06 11:02:17",

        "undone": false

      },

      {

        "id": 119,

        "entity": "promo_code",

        "entity_id": "7",

        "action": "update",

        "summary": "Updated promo code SUMMER10",

        "undone_at": null,

        "created_at": "2026-10-06 10:52:30",

        "undone": false

      }

    ],

    "next_cursor": "MTE5",

    "has_more": true

  }

}
```

| Field        | Meaning                                                                                       |
| ------------ | --------------------------------------------------------------------------------------------- |
| `id`         | The change id, for `GET /v1/changes/{id}` and the undo.                                       |
| `entity`     | What was changed. See the table above.                                                        |
| `entity_id`  | Which item was changed, as a string. See the table above.                                     |
| `action`     | `create`, `update` or `delete`. An undo is recorded as an `update`.                           |
| `summary`    | A short description in English. An undo reads `Undo of change #` followed by the id it undid. |
| `undone`     | `true` once the change has been undone.                                                       |
| `undone_at`  | When the change was undone, otherwise `null`.                                                 |
| `created_at` | `YYYY-MM-DD HH:MM:SS`, server time. `undone_at` uses the same format.                         |

## `GET /v1/changes/{id}`

One change with `before`, the values it replaced, and `after`, the values it wrote. Their shape depends on the entity: some keep only the fields the write touched, others the whole item.

**Auth:** platform key with `store:read`, plus the read scope of the change's entity from the table above. An installed app's token gets `403 forbidden`.

### Request

```
curl 'https://api.dzbuild.app/v1/changes/118' \

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

### Response 200

```
{

  "data": {

    "id": 118,

    "key_id": "dzpk_live_xxxxxxxxxxxxxx",

    "entity": "category",

    "entity_id": "12",

    "action": "update",

    "summary": "Updated category #12 (name, slug)",

    "undone_at": null,

    "undone_by_id": null,

    "created_at": "2026-10-06 10:41:05",

    "before": {"name": "Shoes", "slug": "shoes"},

    "after": {"name": "Sneakers", "slug": "sneakers"},

    "undone": false

  }

}
```

The answer carries the fields of the list, plus these.

| Field          | Meaning                                                                         |
| -------------- | ------------------------------------------------------------------------------- |
| `key_id`       | The key that made the change, or `null` for an undo made from the dashboard.    |
| `undone_by_id` | The id of the change that undid this one, otherwise `null`.                     |
| `before`       | The values the change replaced. `null` for a `create`.                          |
| `after`        | The values the change wrote. `null` for a `delete` and for a courier rate sync. |

A pixel's access token is never kept in a change. When the pixel has one, `before` and `after` show `••••••••` in its place.

### Errors

| HTTP | Code          | Cause                                                                                                                                        |
| ---- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | `bad_request` | The id in the path is not all digits.                                                                                                        |
| 403  | `forbidden`   | The key lacks `store:read` or the read scope of the change's entity (`Missing scope: ...`), or the call comes from an installed app's token. |
| 404  | `not_found`   | No change with this id in the store.                                                                                                         |

## `POST /v1/changes/{id}/undo`

Writes the change's `before` values back through the same checks as the original write, then records the undo as a new change. No request body.

**Auth:** platform key with the undo scope of the change's entity from the table above; `store:read` is not needed. **Requires `Idempotency-Key`.** An installed app's token gets `403 forbidden` (`Apps cannot use this endpoint`). The store owner sees the last 20 changes of each installed app on that app's page in the dashboard (`/dashboard/apps/{id}`, in the list of its latest changes) and can undo them there with the undo button.

### What the undo does

1. A change that created something has nothing to restore and answers `422 nothing_to_restore`: delete the item instead. Adding a home page section through `/v1/store/home-layout` is the exception, because every home layout write is recorded as an `update` of the whole layout.
2. An `update` is undone by writing the `before` values back over what is there now. Only the home page checks for later changes and answers `409 layout_changed` (see [Home page sections](https://dzbuild.com/api-docs/resources/home-layout.md)). To walk back several changes to one item, undo them newest first.
3. A deleted category comes back with its id, without its image. A deleted promo code comes back with its id when no other code has taken it, and with its usage count.
4. A deleted pixel comes back with its id when it is still free, but without its access token and without its product, category and landing page assignments. Undoing a pixel update leaves its current access token in place.
5. A deleted landing page section comes back with a new id.
6. A stock undo sets each value back to the number recorded before the change, whatever orders did to the stock since.
7. Undoing `POST /v1/shipping/rates` puts the prior prices back, and removes the rate of a wilaya that had none before the change. Undoing a courier rate sync puts back every price the sync overwrote, and keeps the rates it added for wilayas that had none.
8. The undo is recorded as a change of its own, `undo_change_id`, which you can undo to apply the original change again. When the original change has no `after` (a delete or a courier rate sync), undoing the undo answers `422 nothing_to_restore`.
9. A change can be undone once. A later undo answers `409 already_undone`, and of two undos sent at the same moment only one runs.
10. When the restore itself fails (`restore_target_missing`, a refused value or a `500`), the change is not marked as undone, so you can retry once the cause is fixed. The retry rules are below.

The undo answer does not carry the restored values. Read the item again. Through `api.dzbuild.app`, the short read cache (`GET /v1/store` and every `GET` under it, the `GET /v1/products` and `GET /v1/landing-pages` lists) can still return the old values for up to 30 seconds after the undo.

### Request

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

  -H "Authorization: Bearer $DZ_KEY" \

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

### Response 200

```
{

  "data": {

    "undone": true,

    "change_id": 118,

    "entity": "category",

    "undo_change_id": 121

  }

}
```

| Field            | Meaning                                                                     |
| ---------------- | --------------------------------------------------------------------------- |
| `undone`         | Always `true` on a `200`.                                                   |
| `change_id`      | The change that was undone.                                                 |
| `entity`         | Its entity.                                                                 |
| `undo_change_id` | The change that records this undo, or `null` when it could not be recorded. |

### Errors

| HTTP | Code                      | Cause                                                                                                                                     |
| ---- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | `bad_request`             | The id in the path is not all digits, or `Idempotency-Key` is missing or malformed.                                                       |
| 403  | `forbidden`               | The key lacks the undo scope of the change's entity (`Missing scope: ...`), or the call comes from an installed app's token.              |
| 404  | `not_found`               | No change with this id in the store.                                                                                                      |
| 409  | `already_undone`          | The change was already undone.                                                                                                            |
| 409  | `layout_changed`          | Home page only: the layout changed after this change.                                                                                     |
| 422  | `not_undoable`            | This kind of change cannot be undone.                                                                                                     |
| 422  | `nothing_to_restore`      | The change created something, or holds no values to restore. Delete the item instead.                                                     |
| 422  | `restore_target_missing`  | What the change touched no longer exists, for example a category or a landing page section deleted since.                                 |
| 422  | `idempotency_key_reuse`   | The same `Idempotency-Key` was already used for a different request, such as the undo of another change.                                  |
| 4xx  | The original write's code | The checks of the original write refuse the values, for example `invalid_wilaya` on shipping rates, or a theme that is no longer offered. |
| 500  | `server_error`            | The undo could not run. The change stays undoable.                                                                                        |

### Retries and `Idempotency-Key`

The first answer to a key is stored for 24 hours, a `4xx` included. A retry with the same key for the same change gets that answer back with `Idempotency-Replay: 1`, and no second undo runs. So after you fix the cause of an error, retry with a new key. A `5xx` or `429` answer is never stored, so retry it with the same key. See [Idempotency](https://dzbuild.com/api-docs/idempotency.md).
