# Keys (your own keys)

Manage the API keys for **your** store. All key operations require a **platform key** that you created. A public key gets `403 forbidden` ("Key management requires a platform key"), a key held by Copilot or a connected AI assistant (Claude or ChatGPT) gets `403` ("This connection cannot manage API keys"), and an installed app's token gets `403` ("Apps cannot use this endpoint").

> **Heads up:** A key minted via the API is **active immediately**. There's no email confirmation, no admin approval. If you mint a key with broad scopes and leak it, the leaker can act with that key's full scope until you `DELETE` it. Keep secrets out of repos and shared screens.

## Getting your first key

The API is **Enterprise-only**: keys can only be issued for stores on an active Enterprise plan, and minting for any other plan fails. Generate your first key from the merchant dashboard at **Settings → API** (`/dashboard/api`), available to the store owner; the secret is shown once, at creation. You can also mint and rotate keys with the endpoints below. A store can hold up to **3 active keys** that you created, in the dashboard or through `POST /v1/keys`; revoke one to free a slot. Keys held by Copilot, a connected AI assistant (Claude or ChatGPT) or an installed app do not use these slots. Minting past the cap through `POST /v1/keys` answers `400 bad_request` ("Key limit reached for this store").

The API is also currently **pilot-gated**. Keys you mint inherit your key's pilot enrolment, so they work — but any key created outside the pilot returns `403 forbidden` ("API is in pilot mode; key not enrolled") on every call.

## `GET /v1/keys`

List the keys belonging to your store (excludes revoked keys; revoked keys are kept in audit history but hidden from this listing). Keys held by Copilot, a connected AI assistant (Claude or ChatGPT) or an installed app are not listed.

**Auth:** platform key.

### Response 200

```
{

  "data": {

    "items": [

      {

        "key_id":          "dzpk_live_xxxxxxxxxxxxxx",

        "type":            "platform",

        "name":            "production-server-1",

        "scopes":          ["store:read", "products:read", "orders:read", "orders:write"],

        "rate_limit_tier": "enterprise",

        "pilot":           true,

        "status":          "active",

        "last_used_at":    "2026-04-30 19:35:49",

        "last_used_ip":    "203.0.113.42",

        "created_at":      "2026-04-30 19:27:55",

        "expires_at":      null

      }

    ]

  }

}
```

Notes:

* `last_used_at` is refreshed at most once a minute per key, so it can lag behind your latest call.
* `last_used_ip` is the source IP of the caller as seen by the API — the original client IP, not an intermediate proxy address.
* Secrets are NEVER returned by `GET`.
* `expires_at` is always `null` — nothing sets it and keys do not auto-expire. Revoke them explicitly.
* Keys with `status: "suspended"` still appear in this listing; only revoked keys are hidden.
* `key_id` is always exactly 24 characters including the `dzpk_live_` / `dzpub_live_` prefix.

## `POST /v1/keys` — mint

Create a new key. **The secret is returned exactly once** — save it immediately.

**Auth:** platform key. **Requires `Idempotency-Key`**, but a mint is never stored for replay because the response carries secrets: retrying with the same `Idempotency-Key` mints a second key, so check `GET /v1/keys` before you retry.

### Body

| Field  | Type                   | Required | Notes                                                                                                                                                                 |
| ------ | ---------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | `platform` \| `public` |          | Default `platform`, so always send it explicitly. A new platform key never gets more scopes than the calling key holds (see below). An out-of-set value returns `400` |
| `name` | string ≤ 100           |          | Free-form label. Defaults to `default`. A longer name is cut at 100 bytes, not 100 characters (an Arabic letter takes 2 bytes), so keep it short                      |

> **Tier and pilot enrolment are inherited from the calling key, and a new platform key never gets more scopes than the calling key holds.** A platform key minted through `POST /v1/keys` receives the default platform scopes the calling key already has; when the calling key holds none of them, the call answers `403 forbidden` ("This key holds none of the scopes a new platform key can carry"). The default platform set, which keys generated in the dashboard receive in full, is `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` and `whatsapp:send`. Public keys always get `signups:write` and `events:write`. Contact support if you need a key issued with a reduced scope set.

### Request

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

  -H "Authorization: Bearer $DZ_KEY" \

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

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

  -d '{ "type": "platform", "name": "ci-deploy-key" }'
```

### Response 200

Key creation returns HTTP **200**, not 201 — check `data.key_id` rather than the status code.

```
{

  "data": {

    "key_id":         "dzpk_live_a3f9...",

    "bearer_token":   "dzpk_live_a3f9..........73ad…",

    "signing_secret": "<64-character hex signing secret>",

    "note":           "Save these now — secrets are not retrievable."

  }

}
```

For a **platform** key, `bearer_token` is what you put in `Authorization: Bearer ...` — its format is `{key_id}.{48-character hex secret}`. For a **public** key, `bearer_token` is `null` and you use `signing_secret` to compute HMAC signatures (see [Authentication](https://dzbuild.com/api-docs/authentication.md)).

## `DELETE /v1/keys/{key_id}` — revoke

**Auth:** platform key. **Requires `Idempotency-Key`.** A key that is not in your store, or one held by Copilot, a connected AI assistant (Claude or ChatGPT) or an installed app, answers `404 not_found`. Revoking a key that is already revoked still answers `revoked: true`.

```
curl -X DELETE 'https://api.dzbuild.app/v1/keys/dzpk_live_a3f9...' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Idempotency-Key: revoke-a3f9"
```

Response:

```
{ "data": { "revoked": true, "key_id": "dzpk_live_a3f9..." } }
```

What happens:

* The key's status flips to `revoked` immediately and it disappears from `GET /v1/keys`.
* The revocation is recorded in your key audit history.
* Revocation can take up to \~60 s to propagate everywhere; until then the key may still succeed on some calls. If you need an immediate hard cut-off, contact support.

## Best practices

* **One key per environment** — a `staging` key and a `production` key. Don't share.
* **One key per integration** — a key for Zapier, a key for your CRM sync, a key for your analytics pipeline. Easier to revoke a single integration without breaking others.
* **Rotate periodically** — every 90 days for production keys is a sensible cadence.
* **Audit `last_used_at`** — keys that haven't been used in 30+ days are candidates for revocation.
* **Never email a secret** — paste it once into your secrets store and never again.
