# Authentication

Two key types.

## Platform key (server-to-server, full CRUD)

Use a Bearer token from your backend:

```
Authorization: Bearer <key_id>.<key_secret>
```

The format is `key_id.key_secret`, both halves are required. `key_id` is the 24-character identifier that already begins with `dzpk_live_` (for example `dzpk_live_xxxxxxxxxxxxxx.<48-hex secret>`), so don't add the prefix again. The key secret is 48 hex characters and is shown **once** at creation, never again.

## Public key (external sites & apps; HMAC-signed)

For counting end-user **signups** and custom **events** from a site or app you run outside DZBuild. Your backend calls `POST /v1/signups` / `POST /v1/events` and signs each request — never expose the signing secret to a browser.

Headers:

```
Authorization: DZ-Public <key_id>

X-DZ-Timestamp: <unix_seconds>

X-DZ-Nonce: <32-hex>

X-DZ-Signature: hex(hmac_sha256(signing_secret, key_id + "\n" + nonce + "\n" + ts + "\n" + sha256(body)))
```

Here too, `key_id` already begins with `dzpub_live_` — pass it exactly as returned. The signing secret is 64 hex characters.

The nonce is single-use per key for one hour and prevents replay. The timestamp must be within ±5 minutes.

A new public key is inert until it is activated

A `type: public` key you create through `POST /v1/keys` returns `401` until it has been activated for API traffic. Contact support right after creating one to have it activated. Platform (Bearer) keys are not affected — they work immediately.

## Scopes

Every key has a list of scopes. Default platform scopes: `store:read`, `store:write`, `products:read`, `products:write`, `orders:read`, `orders:write`, `customers:read`, `landing_pages:read`, `landing_pages:write`, `promos:read`, `promos:write`, `pixels:read`, `pixels:write`, `shipping:read`, `shipping:write`, `webhooks:read`, `webhooks:write`, `usage:read`, `analytics:read`, `whatsapp:read`, `whatsapp:send`. A key created in the dashboard gets exactly this list. `delivery:send` and `ai:generate` are not in it, so a key created in the dashboard or through `POST /v1/keys` gets `403` on `POST /v1/orders/{id}/send-to-delivery` and `POST /v1/landing-pages/generate`.

Default public scopes: `signups:write`, `events:write`.

Scopes are checked on reads as well as writes: a key without `orders:read` gets `403` with `Missing scope: orders:read` on `GET /v1/orders`. `store:write` covers the store settings, design, theme and home page writes. `signups:write` and `events:write` cover `POST /v1/signups` and `POST /v1/events`.

Key management (`/v1/keys`) is gated by key **type**, not by scope: a public key calling it gets `403` — "Key management requires a platform key".

A platform key minted through `POST /v1/keys` gets only the scopes the calling key already holds (`403` when they share none). A `type: public` key always gets `signups:write` and `events:write`.

## Creating a key

The owner of a store on an active Enterprise plan creates personal keys in the dashboard at **Settings → API** (`/dashboard/api`). A new key is enrolled in the pilot automatically and works at once: there is nothing to ask support for. The page shows two values, once, when the key is created:

* **Bearer token**: the whole credential, already in the `key_id.key_secret` form. Send it as it is, `Authorization: Bearer <bearer token>`, without adding anything to it.
* **Signing secret**: never goes in the `Authorization` header, and Bearer calls don't use it. Keep it private like the token.

A store holds at most 3 active personal keys. Keys held by installed apps, Copilot or a connector don't take one of those slots.

## Assistant connections

A connection made for Copilot or a connected AI assistant (Claude or ChatGPT) can cover several of the merchant's stores, with one key per store. Two endpoints let a key held by such a connection read which stores it covers and change which one is active. Any other key, including one created in the dashboard, gets `404 not_found` with `This key does not belong to an assistant connection.`

### `GET /v1/connection`

Needs `store:read`.

```
curl https://api.dzbuild.app/v1/connection \

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

```
{ "data": { "connection_id": 100, "client_name": "Claude", "active_store_id": 10,

            "stores": [ { "id": 10, "name": "My store", "slug": "my-store" },

                        { "id": 20, "name": "My second store", "slug": "my-second-store" } ] },

  "meta": { "request_id": "...", "api_version": "v1" } }
```

`client_name` is the name of the assistant app. A connection approved before connections could cover several stores lists its one store.

### `POST /v1/connection/active-store`

Needs `store:write` and an `Idempotency-Key`. `store_id` in the body names the store to make active.

```
curl -X POST https://api.dzbuild.app/v1/connection/active-store \

  -H "Authorization: Bearer $DZ_KEY" \

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

  -H "Idempotency-Key: active-store-20" \

  -d '{"store_id": 20}'
```

The answer is the connection after the move, in the same shape as `GET /v1/connection`. The active store is a pointer, not a permission: every request still acts on the one store its key belongs to. A store outside the connection answers `403 store_not_in_connection`, and a body without `store_id` answers `400 bad_request`.
