---
title: "API reference"
description: "Every dply HTTP API v1 endpoint, with the ability it needs, its parameters and an example response."
---

This page lists every endpoint in the dply HTTP API v1. For authentication, rate limits, errors and how tokens are scoped to your role, read [HTTP API](/docs/api) first.

All paths are relative to `https://edge.dply.io/api/v1`. Every request needs `Authorization: Bearer dply_…` and `Accept: application/json`. Requests with a body send `Content-Type: application/json`.

In the examples, `$DPLY_TOKEN` holds your token and `$SITE` holds an app ID from [List apps](#list-apps).

> [!NOTE]
> The API calls apps "sites" in paths and field names (`/edge/sites`, `site_id`). They are the same apps you see in the dashboard.

## Endpoint summary

| Method | Path | Ability |
|---|---|---|
| `GET` | `/account` | `account.read` |
| `GET` | `/account/organizations` | `account.read` |
| `GET` | `/account/sessions` | `account.read` |
| `DELETE` | `/account/sessions/{token}` | `account.write` |
| `GET` | `/account/git` | `account.read` |
| `POST` | `/account/git` | `account.write` |
| `DELETE` | `/account/git/{account}` | `account.write` |
| `GET` | `/capabilities` | `account.read` |
| `GET` | `/billing` | `billing.read` |
| `GET` | `/billing/breakdown` | `billing.read` |
| `GET` | `/billing/invoices` | `billing.read` |
| `GET` | `/edge/sites` | `edge.read` |
| `GET` | `/edge/sites/{site}` | `edge.read` |
| `PUT` | `/edge/sites/{site}/icon` | `edge.write` |
| `DELETE` | `/edge/sites/{site}/icon` | `edge.write` |
| `GET` | `/edge/sites/{site}/deployments` | `edge.read` |
| `POST` | `/edge/sites/{site}/deployments` | `edge.deploy` |
| `GET` | `/edge/sites/{site}/deployments/{deployment}` | `edge.read` |
| `POST` | `/edge/sites/{site}/deployments/{deployment}/rollback` | `edge.deploy` |
| `GET` | `/edge/sites/{site}/previews` | `edge.read` |
| `POST` | `/edge/sites/{site}/previews` | `edge.deploy` |
| `DELETE` | `/edge/sites/{site}/previews/{preview}` | `edge.deploy` |
| `POST` | `/edge/sites/{site}/previews/{preview}/promote` | `edge.deploy` |
| `GET` | `/edge/sites/{site}/domains` | `edge.read` |
| `POST` | `/edge/sites/{site}/domains` | `edge.write` |
| `POST` | `/edge/sites/{site}/domains/{hostname}/verify` | `edge.write` |
| `DELETE` | `/edge/sites/{site}/domains/{hostname}` | `edge.write` |
| `GET` | `/edge/sites/{site}/aliases` | `edge.read` |
| `GET` | `/edge/sites/{site}/access` | `edge.read` |
| `PATCH` | `/edge/sites/{site}/access` | `edge.write` |
| `POST` | `/edge/sites/{site}/cache/purge` | `edge.write` |
| `GET` | `/edge/sites/{site}/usage` | `edge.read` |
| `GET` | `/edge/sites/{site}/logs` | `edge.read` |
| `GET` | `/edge/sites/{site}/env` | `edge.env.read` |
| `PUT` | `/edge/sites/{site}/env` | `edge.env.write` |
| `PATCH` | `/edge/sites/{site}/env/{key}` | `edge.env.write` |
| `DELETE` | `/edge/sites/{site}/env/{key}` | `edge.env.write` |
| `POST` | `/edge/lint` | `edge.read` |
| `GET` | `/edge/sites/{site}/databases` | `edge.read` |
| `GET` | `/edge/sites/{site}/databases/{database}` | `edge.read` |
| `POST` | `/edge/sites/{site}/databases/{database}/query` | `edge.write` |
| `GET` | `/edge/sites/{site}/databases/{database}/exports` | `edge.read` |
| `POST` | `/edge/sites/{site}/databases/{database}/exports` | `edge.write` |
| `POST` | `/edge/sites/{site}/databases/{database}/imports/upload-link` | `edge.write` |
| `POST` | `/edge/sites/{site}/databases/{database}/imports` | `edge.write` |
| `POST` | `/edge/sites/{site}/databases/{database}/restore` | `edge.write` |
| `POST` | `/edge/sites/{site}/databases/{database}/restore-as-new` | `edge.write` |
| `GET` | `/edge/databases` | `edge.read` |
| `POST` | `/edge/databases` | `edge.write` |
| `DELETE` | `/edge/databases/{database}` | `edge.write` |
| `POST` | `/edge/databases/{database}/query` | `edge.write` |
| `GET` | `/edge/buckets` | `edge.read` |
| `POST` | `/edge/buckets` | `edge.write` |
| `DELETE` | `/edge/buckets/{bucket}` | `edge.write` |
| `GET` | `/edge/mail/providers` | `edge.write` |
| `POST` | `/edge/mail/providers` | `edge.write` |
| `PATCH` | `/edge/mail/providers/{provider}` | `edge.write` |
| `DELETE` | `/edge/mail/providers/{provider}` | `edge.write` |
| `POST` | `/edge/mail/providers/{provider}/test` | `edge.write` |
| `GET` | `/edge/messages` | `edge.read` |
| `POST` | `/edge/messages/enable` | `edge.write` |
| `POST` | `/edge/messages/tokens` | `edge.write` |
| `DELETE` | `/edge/messages/tokens/{token}` | `edge.write` |
| `GET` | `/edge/messages/keys` | `edge.write` |
| `POST` | `/edge/messages/keys/rotate` | `edge.write` |
| `GET` | `/edge/queues` | `edge.read` |
| `POST` | `/edge/queues` | `edge.write` |
| `DELETE` | `/edge/queues/{queue}` | `edge.write` |
| `POST` | `/edge/queues/{queue}/messages` | `edge.write` |
| `GET` | `/edge/kv` | `edge.read` |
| `GET` | `/edge/kv/{store}/keys` | `edge.read` |
| `GET` | `/edge/kv/{store}/keys/{key}` | `edge.read` |
| `PUT` | `/edge/kv/{store}/keys/{key}` | `edge.write` |
| `DELETE` | `/edge/kv/{store}/keys/{key}` | `edge.write` |
| `GET` | `/status-pages` | `edge.read` |
| `POST` | `/status-pages` | `edge.write` |
| `GET` | `/status-pages/{page}` | `edge.read` |
| `PATCH` | `/status-pages/{page}` | `edge.write` |
| `DELETE` | `/status-pages/{page}` | `edge.write` |
| `POST` | `/status-pages/{page}/monitors` | `edge.write` |
| `DELETE` | `/status-pages/{page}/monitors/{monitor}` | `edge.write` |
| `POST` | `/status-pages/{page}/incidents` | `edge.write` |
| `PATCH` | `/status-pages/{page}/incidents/{incident}` | `edge.write` |
| `GET` | `/notifications/channels` | `notifications.read` |
| `GET` | `/notifications/events` | `notifications.read` |
| `POST` | `/notifications/channels/{channel}/test` | `notifications.write` |
| `GET` | `/sites/{site}/notifications` | `notifications.read` |
| `POST` | `/sites/{site}/notifications` | `notifications.write` |
| `POST` | `/auth/device/start` | None (CLI sign-in) |
| `POST` | `/auth/device/poll` | None (CLI sign-in) |

For endpoints under `/edge/sites/{site}` and `/sites/{site}`, your role must also allow the action on that app: `GET` needs view access, every other method needs change access. See [Roles & permissions](/docs/roles-and-permissions).

## Account

### Get the current token's account

`GET /account` · `account.read`

Returns the token's user, its organization (with your role there) and the token itself.

```bash
curl https://edge.dply.io/api/v1/account \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json"
```

```json
{
  "data": {
    "user": { "id": "01j9…", "name": "Ada Lovelace", "email": "ada@example.com" },
    "organization": {
      "id": "01j8…",
      "name": "Acme",
      "role": "admin",
      "is_current": true,
      "projects_count": 0
    },
    "token": {
      "id": "01ja…",
      "name": "github-actions-deploy",
      "prefix": "dply_AbCdEfGhIjK",
      "masked": "dply_AbCdEfGhIjK…",
      "abilities": ["edge.read", "edge.deploy"],
      "user": { "id": "01j9…", "name": "Ada Lovelace", "email": "ada@example.com" },
      "last_used_at": "2026-09-26T14:02:11+00:00",
      "expires_at": null,
      "created_at": "2026-09-01T09:30:00+00:00",
      "is_cli": false,
      "is_current": true
    }
  }
}
```

`role` is `owner`, `admin`, `member` or `deployer`. `abilities` is empty for a token created without an ability list, which grants every ability.

### List your organizations

`GET /account/organizations` · `account.read`

Every organization the token's user belongs to. `is_current` marks the token's own organization. A token only acts on its own organization.

```json
{
  "data": [
    { "id": "01j8…", "name": "Acme", "role": "admin", "is_current": true },
    { "id": "01j7…", "name": "Side project", "role": "owner", "is_current": false }
  ]
}
```

### List CLI sessions

`GET /account/sessions` · `account.read`

Lists tokens created by `dply-edge login` in this organization, newest use first. Owners and admins see every member's sessions; other roles see their own. Each item has the same shape as `token` in [Get the current token's account](#get-the-current-tokens-account). Tokens created on **Profile → API keys** are not included.

### Git accounts

Your linked GitHub, GitLab and Bitbucket accounts and access tokens. Their `id` is the `account_id` the GitHub webhook endpoints take.

| Endpoint | What it does |
|---|---|
| `GET /account/git` | Each account's `id`, `provider`, `kind` (`oauth` or `token`) and `label`, plus `link_url`: the page where you link one by OAuth. |
| `POST /account/git` | Add a personal access token. Body: `provider` (`github`, `gitlab` or `bitbucket`), `token`, optional `label` and `api_base_url` (a self-hosted GitLab, for example). The token is checked with the provider first; a refusal returns `422` saying what to fix. |
| `DELETE /account/git/{account}` | Remove a token, or unlink an OAuth account. Unlinking your only way to sign in returns `422` until you set a password or add a passkey. |

### Revoke a CLI session

`DELETE /account/sessions/{token}` · `account.write`

| Parameter | In | Description |
|---|---|---|
| `token` | path | The session's token ID. |

Owners and admins can revoke any member's session. Other roles can revoke only their own.

```bash
curl -X DELETE https://edge.dply.io/api/v1/account/sessions/01ja… \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json"
```

```json
{ "message": "CLI session revoked.", "revoked_current": false }
```

If you revoke the token making the request, the message is `CLI session revoked. This token no longer works.` and `revoked_current` is `true`. An unknown ID returns `404` with `CLI session not found.`

### Get instance capabilities

`GET /capabilities` · `account.read`

Describes what this dply instance offers. The CLI reads it before `dply-edge init`.

```json
{
  "data": {
    "instance": { "url": "https://edge.dply.io", "name": "dply" },
    "kinds": {
      "edge": {
        "enabled": true,
        "cli_create_supported": false,
        "cli_create": false,
        "create_url": "https://edge.dply.io/…",
        "requires_git": true
      }
    }
  }
}
```

## Billing

All billing endpoints need `billing.read`, and the token's user must be an organization owner or admin. Otherwise they return `403` with `Org admin access is required to view billing.` Amounts are in US cents.

### Get the billing summary

`GET /billing` · `billing.read`

```json
{
  "data": {
    "organization_id": "01j8…",
    "summary": {
      "monthly_total_cents": 2000,
      "daily_run_rate_cents": 67,
      "interval": "month",
      "subscribed": true,
      "stripe_status": "active",
      "next_invoice_at": "2026-10-01",
      "edge_count": 12
    },
    "plan": { "key": "pro", "label": "Pro", "price_cents": 2000 },
    "monthly_total_cents": 2000,
    "managed_subtotal_cents": 2000,
    "is_free": false,
    "counts": { "edge": 12 },
    "subscription": {
      "active": true,
      "status": "active",
      "on_grace_period": false,
      "ends_at": null,
      "interval": "month",
      "items": [{ "price_id": "price_…", "quantity": 1 }]
    }
  }
}
```

With no subscription, `subscription` is `{ "active": false, "items": [] }`.

### Get the cost breakdown

`GET /billing/breakdown` · `billing.read`

The current month's estimate by category and by line item. Categories with a zero amount are left out.

```json
{
  "data": {
    "monthly_total_cents": 5200,
    "categories": [
      { "key": "plan", "label": "Pro", "cents": 2000, "color": "bg-brand-forest/70" },
      { "key": "usage", "label": "Usage", "cents": 3200, "color": "bg-brand-sage/50" }
    ],
    "line_items": [
      { "label": "Pro plan", "quantity": 1, "unit_cents": 2000, "line_cents": 2000, "detail": null },
      { "label": "Delivery (requests, bandwidth, site storage)", "quantity": 1, "unit_cents": 3200, "line_cents": 3200, "detail": null }
    ]
  }
}
```

Category keys are `plan`, `seats` and `usage`. `color` is a dashboard style hint; ignore it in your own tools.

### List invoices

`GET /billing/invoices` · `billing.read`

The last 24 invoices, newest first.

```json
{
  "data": {
    "invoices": [
      { "id": "in_…", "number": "ACME-0007", "date": "2026-09-01", "total_cents": 2400, "status": "paid", "paid": true }
    ]
  }
}
```

## Apps

### List apps

`GET /edge/sites` · `edge.read`

Every Edge app in the token's organization that the token's user can view, sorted by name. Preview apps are included, marked with `is_preview`.

```bash
curl https://edge.dply.io/api/v1/edge/sites \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json"
```

```json
{
  "data": [
    {
      "id": "01j9z3k6x8m2q4r5t7v9w0y1a2",
      "organization_id": "01j8…",
      "name": "marketing",
      "slug": "marketing",
      "status": "edge_active",
      "is_preview": false,
      "parent_site_id": null,
      "runtime_mode": "static",
      "hostname": "marketing.dply.app",
      "live_url": "https://marketing.dply.app",
      "dashboard_url": "https://edge.dply.io/projects/01j9…/sites/01j9z3k6…",
      "repository": "acme/marketing",
      "branch": "main",
      "repo_root": null,
      "active_deployment_id": "01ja…",
      "preview_protection": { "mode": "off", "enabled": false },
      "created_at": "2026-08-01T10:00:00+00:00",
      "updated_at": "2026-09-26T13:40:00+00:00"
    }
  ]
}
```

| Field | Description |
|---|---|
| `status` | `edge_provisioning`, `edge_active`, `edge_failed` or `edge_deleting`. |
| `runtime_mode` | `static`, `hybrid`, `ssr` or `container`. |
| `hostname`, `live_url` | The app's dply hostname and URL. |
| `repo_root` | The app's directory inside a monorepo, or `null`. |
| `preview_protection` | The access rule in force. On a preview it is the parent's rule and adds `inherited_from_site_id`. |

### Get an app

`GET /edge/sites/{site}` · `edge.read`

| Parameter | In | Description |
|---|---|---|
| `site` | path | The app ID. |

Returns one app in the shape above, wrapped in `data`. Returns `404` with `Edge site not found.` if the ID is not an Edge app in the token's organization.

### Set an app's icon

`PUT /edge/sites/{site}/icon` · `edge.write`

Send the image itself as the request body: PNG, JPEG, WebP, GIF or ICO, at most 1 MB. The type is read from the file, not the `Content-Type` header. Anything else returns `422`. `DELETE /edge/sites/{site}/icon` goes back to the app's favicon, or its initials.

```bash
curl -X PUT https://edge.dply.io/api/v1/edge/sites/01j9…/icon \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json" \
  --data-binary @icon.png
```

## Deployments

A deployment object:

```json
{
  "id": "01ja…",
  "site_id": "01j9…",
  "status": "live",
  "git_commit": "9f2c1ab7e4d0c3b2a1f0e9d8c7b6a5f4e3d2c1b0",
  "git_branch": "main",
  "storage_prefix": "sites/01j9…/01ja…",
  "pruned": false,
  "cf_kv_version": "…",
  "aliases": ["9f2c1ab-marketing.dply.app"],
  "repo_config": {
    "source_path": "dply.yaml",
    "build_overrides": {},
    "redirect_count": 3,
    "rewrite_count": 0,
    "header_rule_count": 1,
    "warning_count": 0
  },
  "failure_reason": null,
  "published_at": "2026-09-26T13:40:00+00:00",
  "failed_at": null,
  "created_at": "2026-09-26T13:38:12+00:00"
}
```

`status` is `building`, `publishing`, `live`, `failed` or `superseded`. `pruned` is `true` once the deployment's files have been removed, after which it cannot be rolled back to.

### List deployments

`GET /edge/sites/{site}/deployments` · `edge.read`

| Parameter | In | Description |
|---|---|---|
| `limit` | query | Number of deployments, newest first. Default `20`, between `1` and `100`. |

```bash
curl "https://edge.dply.io/api/v1/edge/sites/$SITE/deployments?limit=5" \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json"
```

Returns `{ "data": [ …deployment objects… ] }`.

### Trigger a deployment

`POST /edge/sites/{site}/deployments` · `edge.deploy`

With an empty body, dply rebuilds from the tip of the app's branch. With `commit`, it deploys that commit; if the commit was already built, dply publishes the existing build.

| Parameter | In | Rules |
|---|---|---|
| `commit` | body | Optional. 7 to 40 hexadecimal characters. |
| `branch` | body | Optional, up to 200 characters. Used only with `commit`. |
| `branch_tip` | body | Optional boolean. Same as an empty body. |

```bash
curl -X POST https://edge.dply.io/api/v1/edge/sites/$SITE/deployments \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"commit": "9f2c1ab"}'
```

Returns `202` with the new deployment in `data`. Poll [Get a deployment](#get-a-deployment) until `status` is `live` or `failed`. Returns `422` with a `message` if the deploy cannot start.

### Get a deployment

`GET /edge/sites/{site}/deployments/{deployment}` · `edge.read`

Returns one deployment in `data`, or `404` with `Deployment not found.`

### Roll back to a deployment

`POST /edge/sites/{site}/deployments/{deployment}/rollback` · `edge.deploy`

Points production at an earlier deployment. The deployment must not be pruned. Returns the deployment in `data`, or `422` with the reason.

```bash
curl -X POST https://edge.dply.io/api/v1/edge/sites/$SITE/deployments/01ja…/rollback \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json"
```

## Previews

Previews are separate apps linked to a parent. Call these endpoints with the parent app's ID; a preview ID in `{site}` returns `404`.

### List previews

`GET /edge/sites/{site}/previews` · `edge.read`

Returns the parent's live previews, newest first, as app objects (see [List apps](#list-apps)).

### Create a preview

`POST /edge/sites/{site}/previews` · `edge.deploy`

| Parameter | In | Rules |
|---|---|---|
| `commit` | body | Required. 7 to 40 hexadecimal characters. |
| `branch` | body | Optional, up to 200 characters. Defaults to the parent's branch, or `main`. |
| `ref_kind` | body | Optional. `branch`, `tag` or `commit`. |

```bash
curl -X POST https://edge.dply.io/api/v1/edge/sites/$SITE/previews \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"commit": "4be1d02", "branch": "feature/pricing"}'
```

Returns `202` with the preview app in `data`.

### Delete a preview

`DELETE /edge/sites/{site}/previews/{preview}` · `edge.deploy`

Queues the preview's teardown.

```json
{ "message": "Preview teardown queued." }
```

Status `202`. Returns `404` with `Preview not found.` if the preview does not belong to the parent.

### Promote a preview

`POST /edge/sites/{site}/previews/{preview}/promote` · `edge.deploy`

Copies the preview's build to production. The preview keeps running. Returns the new production deployment in `data`, or `422` with the reason.

## Domains

### List domains

`GET /edge/sites/{site}/domains` · `edge.read`

```json
{
  "data": [
    {
      "hostname": "www.example.com",
      "mode": "manual",
      "dns_status": "active",
      "ssl_status": "active",
      "cname_target": "marketing.dply.app",
      "cf_custom_hostname_id": "…",
      "ownership_verification": null,
      "dply_verification": null,
      "analytics_zone": null,
      "attached_at": "2026-09-02T08:00:00+00:00",
      "verified_at": "2026-09-02T08:05:00+00:00",
      "error": null,
      "ssl_error": null
    }
  ]
}
```

When a domain needs a DNS record to prove ownership, `ownership_verification` or `dply_verification` holds the record to create. See [Domain verification](/docs/domain-verification).

### Add a domain

`POST /edge/sites/{site}/domains` · `edge.write`

| Parameter | In | Rules |
|---|---|---|
| `hostname` | body | Required. A valid hostname with at least one dot, up to 253 characters. |

```bash
curl -X POST https://edge.dply.io/api/v1/edge/sites/$SITE/domains \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"hostname": "www.example.com"}'
```

Returns `202` with the DNS record to create:

```json
{
  "data": [
    { "name": "www.example.com", "type": "CNAME", "value": "marketing.dply.app", "status": "pending" }
  ]
}
```

Plan limits on custom domains apply. Returns `422` with the reason when the domain cannot be added.

### Verify a domain

`POST /edge/sites/{site}/domains/{hostname}/verify` · `edge.write`

Re-checks DNS and certificate status for a domain already added to the app. Returns the domain's updated record in `data`, or `"data": null` if the hostname is not attached to this app.

### Remove a domain

`DELETE /edge/sites/{site}/domains/{hostname}` · `edge.write`

```json
{ "message": "Custom domain removed." }
```

## Aliases

### List deployment aliases

`GET /edge/sites/{site}/aliases` · `edge.read`

Per-deployment hostnames for the newest 50 live or superseded deployments.

```json
{
  "data": [
    {
      "hostname": "9f2c1ab-marketing.dply.app",
      "deployment_id": "01ja…",
      "git_commit": "9f2c1ab7e4…",
      "git_branch": "main",
      "published_at": "2026-09-26T13:40:00+00:00"
    }
  ]
}
```

## Access protection

These endpoints manage the app's password or dply-account protection. See [Access control](/docs/access-control). Calling them with a preview's ID returns `422` with `Configure preview protection on the parent Edge site.`

### Get access protection

`GET /edge/sites/{site}/access` · `edge.read`

```json
{
  "data": {
    "site_id": "01j9…",
    "mode": "dply_account",
    "enabled": true,
    "password_set": false,
    "allowed_emails": ["ada@example.com"],
    "account_login_url": "https://edge.dply.io/projects/sites/01j9…/preview-access"
  }
}
```

`account_login_url` appears only when `mode` is `dply_account` and protection is on. The password itself is never returned.

### Update access protection

`PATCH /edge/sites/{site}/access` · `edge.write`

| Parameter | In | Rules |
|---|---|---|
| `mode` | body | Required. `off`, `password` or `dply_account`. |
| `password` | body | Optional, up to 200 characters. Used with `password` mode. |
| `allowed_emails` | body | Optional array of up to 100 email addresses. Used with `dply_account` mode. |

```bash
curl -X PATCH https://edge.dply.io/api/v1/edge/sites/$SITE/access \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"mode": "password", "password": "correct horse battery staple"}'
```

Returns the updated rule in the shape above.

## Cache

### Purge the cache

`POST /edge/sites/{site}/cache/purge` · `edge.write`

Send either `tag` or `paths`. If both are sent, `tag` wins.

| Parameter | In | Rules |
|---|---|---|
| `tag` | body | Up to 128 characters: letters, digits, `.`, `_` and `-`. |
| `paths` | body | Array of up to 100 paths, each up to 2048 characters. |

```bash
curl -X POST https://edge.dply.io/api/v1/edge/sites/$SITE/cache/purge \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"paths": ["/", "/pricing"]}'
```

```json
{ "data": { "ok": true, "purged_keys": 2, "message": "…" } }
```

Returns `200` when the purge succeeded and `422` (same body, `"ok": false`) when it did not. See [Caching](/docs/caching).

## Usage

### Get app usage

`GET /edge/sites/{site}/usage` · `edge.read`

| Parameter | In | Description |
|---|---|---|
| `days` | query | Days of daily history. Default `30`, between `1` and `90`. |

Returns this billing period's requests, egress, storage and cost estimate for the app, plus a `daily` series. Usage is collected once a day.

```json
{
  "data": {
    "site_id": "01j9…",
    "site_name": "marketing",
    "platform_cents": 0,
    "platform_kind": "included",
    "usage_cents": 0,
    "total_cents": 0,
    "requests": 184220,
    "bytes_egress": 5210093312,
    "daily": [{ "date": "2026-09-25", "label": "Sep 25", "requests": 6120, "bytes_egress": 173004800, "cost_cents": 0 }],
    "requests_7d": 41200,
    "bytes_egress_7d": 1180000000,
    "last_collected_date": "2026-09-25",
    "byo_cloudflare": false
  }
}
```

The response has more fields than shown (storage operations, `usage_detail`, `peak_day`, `tracked_hostnames`). Before the first daily collection, or for a preview, `data` is `{ "message": "Usage not yet available — wait for the first nightly rollup." }`. See [Usage & metering](/docs/usage).

## Logs

### Tail request logs

`GET /edge/sites/{site}/logs` · `edge.read`

Returns requests served by the app, oldest first. `dply-edge edge logs --tail` polls this endpoint.

| Parameter | In | Description |
|---|---|---|
| `since` | query | ISO 8601 timestamp. Only requests after it are returned. Default: 60 seconds ago. |
| `limit` | query | Maximum rows. Default `100`, between `1` and `500`. |

```bash
curl "https://edge.dply.io/api/v1/edge/sites/$SITE/logs?since=2026-09-26T14:00:00Z" \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json"
```

```json
{
  "data": [
    {
      "occurred_at": "2026-09-26T14:00:03+00:00",
      "deployment_id": "01ja…",
      "hostname": "www.example.com",
      "method": "GET",
      "path": "/pricing",
      "status": 200,
      "duration_ms": 14,
      "bytes_egress": 18342,
      "cache_status": "HIT",
      "country": "US"
    }
  ],
  "meta": {
    "since": "2026-09-26T14:00:00+00:00",
    "count": 1,
    "tail_cursor": "2026-09-26T14:00:03+00:00"
  }
}
```

To follow the log, pass `meta.tail_cursor` as `since` on the next request. See [Logs](/docs/logs).

## Environment variables

These endpoints manage the app's production environment variables. Values are encrypted and never returned. Changes apply on the next deploy. `{site}` accepts the app ID or slug.

Keys must be uppercase, start with a letter, and contain only `A`–`Z`, `0`–`9` and `_` (up to 128 characters). These names are reserved: `HOST_MAP`, `ASSETS`, `ARTIFACTS`, `DEPLOYMENT_ID`, `SITE_ID`, `STORAGE_PREFIX`, `EDGE_ANALYTICS`, `LOG_INGEST_BASE_URL`, `LOG_INGEST_KEY`, `ENVIRONMENT`.

### List variable keys

`GET /edge/sites/{site}/env` · `edge.env.read`

```json
{
  "data": [
    { "key": "API_BASE_URL", "updated_at": "2026-09-20T10:00:00+00:00", "created_at": "2026-08-01T10:00:00+00:00" }
  ]
}
```

### Replace all variables

`PUT /edge/sites/{site}/env` · `edge.env.write`

The body is a JSON object of key-value pairs. It replaces the whole set: keys you leave out are deleted.

```bash
curl -X PUT https://edge.dply.io/api/v1/edge/sites/$SITE/env \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"API_BASE_URL": "https://api.example.com", "FEATURE_FLAGS": "beta"}'
```

Returns the resulting key list, as in [List variable keys](#list-variable-keys). If any key is invalid, nothing is saved and the response is `422`:

```json
{ "message": "Invalid env payload.", "errors": { "host_map": "Key must be ALL_CAPS, start with a letter, and contain only A–Z, 0–9, and underscores." } }
```

> [!WARNING]
> `PUT` deletes every production variable not in the body. To change one variable, use `PATCH`.

### Set one variable

`PATCH /edge/sites/{site}/env/{key}` · `edge.env.write`

| Parameter | In | Rules |
|---|---|---|
| `key` | path | A valid key (see above). |
| `value` | body | A string or other scalar. |

```bash
curl -X PATCH https://edge.dply.io/api/v1/edge/sites/$SITE/env/API_BASE_URL \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"value": "https://api.example.com"}'
```

```json
{ "data": { "key": "API_BASE_URL", "updated_at": "2026-09-26T14:10:00+00:00", "created_at": "2026-08-01T10:00:00+00:00" } }
```

### Delete a variable

`DELETE /edge/sites/{site}/env/{key}` · `edge.env.write`

```json
{ "deleted": 1 }
```

`deleted` is `0` if the key did not exist.

## Config linting

### Lint a config file

`POST /edge/lint` · `edge.read`

Validates the contents of a `dply.yaml` or `dply.json` without deploying. See [Configuration files](/docs/configuration-files).

| Parameter | In | Rules |
|---|---|---|
| `path` | body | Required. The file name, up to 255 characters, for example `dply.yaml`. |
| `content` | body | Required. The file contents, up to 65,536 characters. |

```bash
curl -X POST https://edge.dply.io/api/v1/edge/lint \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --rawfile c dply.yaml '{path: "dply.yaml", content: $c}')"
```

```json
{
  "data": {
    "ok": true,
    "source_path": "dply.yaml",
    "errors": [],
    "warnings": [],
    "summary": {
      "redirects": 3, "rewrites": 0, "headers": 1,
      "build_keys": ["command", "output"],
      "tags": 0, "snippets": 0, "forms": 1
    }
  }
}
```

Status is `200` when `ok` is `true` and `422` when the file has errors.

## App databases

An app's Postgres, MySQL and MongoDB databases. `{database}` is the database's ID or name. Creating, attaching, resizing and deleting one stay in the dashboard. See [Databases](/docs/resources/databases).

### List an app's databases

`GET /edge/sites/{site}/databases` · `edge.read`

The primary comes first.

```json
{
  "data": [
    {
      "id": "01jd…", "name": "shop", "engine": "postgres", "primary": true, "env_prefix": "",
      "host": "pg-01jd….db.dply.io", "region": "weur", "size": "0.25", "suspend_seconds": 300, "disk_gb": 1,
      "last_backup_at": "2026-09-29T03:00:00+00:00", "transfer": null, "restore": null
    },
    { "id": "01je…", "name": "reports", "engine": "mysql", "primary": false, "env_prefix": "REPORTS", "…": "…" }
  ]
}
```

`env_prefix` is empty for the primary, which uses `DB_*` and `DATABASE_URL`. Another database's connection is behind its prefix, like `REPORTS_DB_HOST` and `REPORTS_DATABASE_URL`.

### Get a database

`GET /edge/sites/{site}/databases/{database}` · `edge.read`

The same fields. `transfer` is the last export or import and `restore` is the last restore, each with a `status` of `running`, `done` or `failed`.

### Run a query

`POST /edge/sites/{site}/databases/{database}/query` · `edge.write`

| Parameter | In | Rules |
|---|---|---|
| `sql` | body | Postgres and MySQL: one read-only statement, up to 100,000 characters. |
| `collection` | body | MongoDB: the collection to search. |
| `filter` | body | MongoDB: an optional JSON filter. The default is `{}`. |

```bash
curl -X POST https://edge.dply.io/api/v1/edge/sites/$SITE/databases/reports/query \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"sql": "SELECT id, total FROM orders ORDER BY id DESC"}'
```

```json
{ "data": { "columns": ["id", "total"], "rows": [[42, 1999]], "truncated": false, "ms": 3 } }
```

At most 200 rows come back; `truncated` says there were more. A query the database rejects returns `422`. A database that doesn't answer returns `502`.

### Export a database

`POST /edge/sites/{site}/databases/{database}/exports` · `edge.write`

Starts an export and returns `202` with the database, its `transfer.status` set to `running`. An export or import that is already running returns `422`.

`GET /edge/sites/{site}/databases/{database}/exports` · `edge.read` lists the exports, newest first, each with a download `url`.

### Import a database

`POST /edge/sites/{site}/databases/{database}/imports/upload-link` · `edge.write`

| Field | In | Notes |
| --- | --- | --- |
| `file` | body | Required. Letters, digits, dot, dash and underscore. |

Returns `{"data": {"file": "20261029T140000-app.pgdump", "url": "…"}}`. `PUT` the dump to `url` within an hour (`curl -fT app.pgdump "<url>"`), then load it by the returned `file`. The format is the one an export makes: `pg_dump -Fc` for Postgres, a gzipped `mysqldump` for MySQL, a `mongodump` archive for MongoDB.

`POST /edge/sites/{site}/databases/{database}/imports` · `edge.write`

| Field | In | Notes |
| --- | --- | --- |
| `from` | body | Required. `imports` for an uploaded file, `exports` for one of its exports. |
| `file` | body | Required. The uploaded `file`, or an export's `file`. |

Returns `202` with `transfer.status` set to `running`. For Postgres and MySQL, the tables in the file are replaced and the others are left alone. For MongoDB, everything in the database is replaced. An export or import that is already running returns `422`.

### Restore to a point in time

`POST /edge/sites/{site}/databases/{database}/restore` · `edge.write`

| Parameter | In | Rules |
|---|---|---|
| `at` | body | Required. A moment in the last 7 days. A time without a zone is read as UTC. |

Returns `202` with `restore.status` set to `running`. A time outside the window, or a restore already running, returns `422`.

### Restore into a new database

`POST /edge/sites/{site}/databases/{database}/restore-as-new` · `edge.write`

| Parameter | In | Rules |
|---|---|---|
| `at` | body | Required. As for a restore. |
| `name` | body | Required. The new database's name, unused in the organization. |

Creates a database of the same engine, size and disk, attached to the app, and loads `{database}` into it as it was at `at`. `{database}` is only read. Returns `202` with the new database, `restore.status` `running` and `restore.from` the source. A time outside the window or a name already taken returns `422`, and nothing is created.

## Databases

Edge SQL databases belong to the organization. See [Edge SQL (D1)](/docs/resources/sql).

### List databases

`GET /edge/databases` · `edge.read`

```json
{
  "data": [
    { "id": "01jb…", "name": "app-db", "cloudflare_id": "…", "created_at": "2026-09-10T09:00:00+00:00" }
  ]
}
```

### Create a database

`POST /edge/databases` · `edge.write`

| Parameter | In | Rules |
|---|---|---|
| `name` | body | Required. Lowercase letters, numbers and dashes, up to 40 characters. |
| `location` | body | Optional: `wnam`, `enam`, `weur`, `eeur`, `apac` or `oc`. Left out, the database is placed automatically. |

Returns `201` with the database in `data`. A taken name or the plan's database limit returns `422`.

### Delete a database

`DELETE /edge/databases/{database}` · `edge.write`

Deletes the database and all its data. Apps bound to it lose the binding.

### Run SQL

`POST /edge/databases/{database}/query` · `edge.write`

| Parameter | In | Rules |
|---|---|---|
| `database` | path | The database ID or name. |
| `sql` | body | Required, up to 100,000 characters. |
| `params` | body | Optional array of bound parameters for `?` placeholders. |

```bash
curl -X POST https://edge.dply.io/api/v1/edge/databases/app-db/query \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"sql": "SELECT id, email FROM users WHERE id = ?", "params": [42]}'
```

Returns the database's query result in `data`. SQL errors return `422` with the database's message.

> [!WARNING]
> This runs any SQL, including writes and schema changes, against the live database.

## Buckets

Storage buckets belong to the organization. An app attaches one as a resource (`POST /edge/sites/{site}/resources` with `mode: attach`).

### List buckets

`GET /edge/buckets` · `edge.read`

```json
{ "data": [{ "id": "dply-…-uploads", "name": "uploads", "used_by": [{ "site_id": "01jd…", "site": "shop", "disk": "uploads" }] }] }
```

### Create a bucket

`POST /edge/buckets` · `edge.write`

| Parameter | In | Rules |
|---|---|---|
| `name` | body | Required. 2 to 31 lowercase letters, numbers and dashes. |
| `location` | body | Optional: `wnam`, `enam`, `weur`, `eeur`, `apac` or `oc`. |

Returns `201` with the bucket's `id` and `name`. A taken name returns `422`.

### Delete a bucket

`DELETE /edge/buckets/{bucket}` · `edge.write`

`bucket` is its name or id. A bucket an app still uses, or one with files in it, returns `422`.

## Mail

The organization's mail providers. An app attaches one as a `mail` resource and gets `MAIL_*` on its next deploy. They hold credentials, so every Mail endpoint needs `edge.write` and a role that can manage the organization. Secrets are never returned.

| Endpoint | What it does |
|---|---|
| `GET /edge/mail/providers` | The providers, their non-secret settings, and the apps using each. |
| `POST /edge/mail/providers` | Body: `name` (lowercase letters, numbers, dashes), `provider` (`smtp`, `cloudflare`, `ses`, `postmark`, `resend`, or `mailgun`), `fields` (the provider's settings, e.g. `host`, `port`, `username`, `password` for SMTP). Errors come back keyed `name`, `provider` or `fields.<field>`. |
| `PATCH /edge/mail/providers/{provider}` | Same body; anything left out keeps its saved value, secrets included. `provider` is its id or name. |
| `DELETE /edge/mail/providers/{provider}` | Refused (`422`) while an app uses it. |
| `POST /edge/mail/providers/{provider}/test` | Sends one test email. Body: `from` (required), `to` (defaults to your own address). `422` with the provider's error when it fails. |

## Messages

dply's HTTP message queue for the organization. Every Messages endpoint returns `404` until it is turned on for your organization.

| Endpoint | What it does |
|---|---|
| `GET /edge/messages` | Whether it is on, `MESSAGES_URL`, and the tokens (label, last four characters, app). |
| `POST /edge/messages/enable` | Turn it on, with new signing keys. |
| `POST /edge/messages/tokens` | Make a publishing token. Body: `label` (required, up to 60 characters). Returns `201` with the `secret` once; only its hash is kept. |
| `DELETE /edge/messages/tokens/{token}` | Revoke a token by its id. |
| `GET /edge/messages/keys` | `current` and `next` signing keys, for `MESSAGES_SIGNING_KEY` and `MESSAGES_NEXT_SIGNING_KEY`. Needs `edge.write`: they are secrets. |
| `POST /edge/messages/keys/rotate` | The next key becomes current and a new next key is made. Returns the new pair. |

## Queues

Queues belong to the organization. See [Queues](/docs/resources/queues).

### List queues

`GET /edge/queues` · `edge.read`

```json
{ "data": [{ "id": "01jc…", "name": "emails", "cloudflare_name": "…" }] }
```

### Create a queue

`POST /edge/queues` · `edge.write`

| Parameter | In | Rules |
|---|---|---|
| `name` | body | Required. Lowercase letters, numbers and dashes, up to 40 characters. |

Returns `201` with the queue in `data`. A taken name or the plan's queue limit returns `422`.

### Delete a queue

`DELETE /edge/queues/{queue}` · `edge.write`

Deletes the queue and any messages waiting in it.

### Send a message

`POST /edge/queues/{queue}/messages` · `edge.write`

| Parameter | In | Rules |
|---|---|---|
| `queue` | path | The queue ID or name. |
| `body` | body | Required. Any JSON value. |

```bash
curl -X POST https://edge.dply.io/api/v1/edge/queues/emails/messages \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"body": {"type": "welcome", "user_id": 42}}'
```

```json
{ "data": { "queued": true } }
```

Status `202`.

## Key-value stores

Key-value stores belong to the organization. Create and delete them in the dashboard. See [Key-value (KV)](/docs/resources/key-value).

These endpoints are for admin work: seeding, inspecting and fixing keys from a script or CI. They share **60 requests per minute per organization** across all its tokens. Over that, they return `429`. Apps should read and write through their internal host or binding, which has no such limit.

Listing and reading keys, as well as writing and deleting them, also need the token's user to be an organization owner or admin: values often hold sessions and tokens. Otherwise they return `403`. Listing stores does not.

If Cloudflare doesn't answer, these endpoints return `503`. A store created in the last two minutes may not be listed yet.

In the paths, `store` is the store's ID or name. `key` is the key itself, URL-encoded. A `/` in a key can stay as it is.

### List stores

`GET /edge/kv` · `edge.read`

```json
{ "data": [{ "id": "4f1c…", "name": "cache" }] }
```

### List keys

`GET /edge/kv/{store}/keys` · `edge.read`

| Parameter | In | Rules |
|---|---|---|
| `prefix` | query | Optional. Only keys starting with this. |
| `cursor` | query | Optional. The `cursor` from the previous page. |

Returns up to 1,000 keys per page. `cursor` is `null` on the last page.

```json
{
  "data": [{ "name": "user:1", "expiration": 1790000000, "metadata": { "plan": "pro" } }],
  "cursor": "AArAbN…"
}
```

### Read a key

`GET /edge/kv/{store}/keys/{key}` · `edge.read`

```json
{ "data": { "key": "user:1", "value": "hello", "encoding": "utf-8", "metadata": { "plan": "pro" } } }
```

A value that isn't valid UTF-8 comes back base64-encoded, with `encoding` set to `base64`. A missing key returns `404`.

### Write a key

`PUT /edge/kv/{store}/keys/{key}` · `edge.write`

| Parameter | In | Rules |
|---|---|---|
| `value` | body | Required. A string of up to 1 MB. |
| `ttl` | body | Optional. Seconds until the key expires, 60 or more. |
| `expires_at` | body | Optional. A unix time at least 60 seconds ahead. Send `ttl` or `expires_at`, not both. |
| `metadata` | body | Optional JSON object, up to 1024 bytes. |

```bash
curl -X PUT https://edge.dply.io/api/v1/edge/kv/cache/keys/user:1 \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"value": "hello", "ttl": 3600, "metadata": {"plan": "pro"}}'
```

```json
{ "data": { "key": "user:1", "saved": true } }
```

### Delete a key

`DELETE /edge/kv/{store}/keys/{key}` · `edge.write`

```json
{ "data": { "key": "user:1", "deleted": true } }
```

## Status pages

The organization's status pages. `{page}` is its id or slug. Viewers can read; deleting a page needs an owner or admin.

| Endpoint | What it does |
|---|---|
| `GET /status-pages` | The pages, with `slug`, `is_public` and the public `url`. |
| `POST /status-pages` | Body: `name` (required, up to 120 characters), `description`. New pages are public. |
| `GET /status-pages/{page}` | The page, what it shows (`monitors`), and its incidents with their updates. |
| `PATCH /status-pages/{page}` | Any of `name`, `description`, `is_public`. |
| `DELETE /status-pages/{page}` | Deletes the page and its incidents. |
| `POST /status-pages/{page}/monitors` | Body: `site` (an app id), optional `uptime_monitor` (one of that app's uptime checks), optional `label`. `422` when it is already on the page. |
| `DELETE /status-pages/{page}/monitors/{monitor}` | Take it off the page. |
| `POST /status-pages/{page}/incidents` | Body: `title`, `impact` (`none`, `minor`, `major` or `critical`), `message` (the first update). Starts as `investigating`. |
| `PATCH /status-pages/{page}/incidents/{incident}` | Body: `body` (a new update), `state` (`investigating`, `identified`, `monitoring` or `resolved`), or both. |

## Notifications

Channels are where alerts go (Slack, email, webhooks and others). Routing decides which events reach which channel for an app. See [Notification channels](/docs/notifications).

### List channels

`GET /notifications/channels` · `notifications.read`

The channels the token's user can route events to: their personal channels, channels of teams whose channels they can manage, and, for organization owners and admins, the organization's channels.

```json
{
  "data": [
    { "id": "01jd…", "label": "#deploys", "type": "slack", "destination": "Acme workspace", "owner": "Organization" }
  ]
}
```

`destination` is a redacted description, never the full webhook URL or key. `owner` is `Organization`, `Team` or `User`.

### List the event catalog

`GET /notifications/events` · `notifications.read`

| Parameter | In | Description |
|---|---|---|
| `subject` | query | `site` for the groups that can apply to an app. Omit it for the full catalog. |

```json
{
  "data": [
    {
      "key": "edge",
      "label": "Edge",
      "events": [
        { "key": "edge.deploy.succeeded", "label": "Edge deploy succeeded" },
        { "key": "edge.deploy.failed", "label": "Edge deploy failed (action required)" }
      ]
    }
  ]
}
```

> [!NOTE]
> The full catalog also lists event groups that do not apply to Edge apps. Use `GET /sites/{site}/notifications` to see exactly which events an app can route.

### Send a test message

`POST /notifications/channels/{channel}/test` · `notifications.write`

Sends the channel's test message, the same as **Test** in the dashboard.

```json
{ "data": { "ok": true, "message": "…" } }
```

`200` when the provider accepted it, `422` when it did not, `404` when the channel is not available to the token.

### Get an app's event routing

`GET /sites/{site}/notifications` · `notifications.read`

`{site}` accepts the app ID or slug. Returns the event groups that apply to the app and, for each channel you can use, the events routed to it.

```json
{
  "data": {
    "groups": [
      { "key": "site_uptime", "label": "…", "events": [{ "key": "site.uptime.down", "label": "Down & recovered" }] },
      { "key": "edge", "label": "Edge", "events": [{ "key": "edge.deploy.failed", "label": "Edge deploy failed (action required)" }] }
    ],
    "channels": [
      { "id": "01jd…", "label": "#deploys", "type": "slack", "events": ["edge.deploy.failed"] }
    ]
  }
}
```

### Change an app's event routing

`POST /sites/{site}/notifications` · `notifications.write`

Adds and removes events for one channel. Events you do not mention are left as they are.

| Parameter | In | Rules |
|---|---|---|
| `channel` | body | Required. A channel ID from [List channels](#list-channels). |
| `subscribe` | body | Array of event keys to add. |
| `unsubscribe` | body | Array of event keys to remove. |

```bash
curl -X POST https://edge.dply.io/api/v1/sites/$SITE/notifications \
  -H "Authorization: Bearer $DPLY_TOKEN" -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"channel": "01jd…", "subscribe": ["edge.deploy.failed", "site.uptime.down"]}'
```

```json
{
  "data": {
    "channel": "01jd…",
    "events": ["edge.deploy.failed", "site.uptime.down"],
    "added": 1,
    "removed": 0
  }
}
```

An event that does not apply to the app returns `422` with `Unknown event for this subject: …` and the list of valid keys in `events`. Sending neither `subscribe` nor `unsubscribe` returns `422`. A channel the token cannot use returns `404`.

## CLI sign-in

The CLI uses these two unauthenticated endpoints for `dply-edge login`. You do not need them unless you are building your own client. See [CLI](/docs/cli).

### Start a sign-in

`POST /auth/device/start`

```json
{
  "device_code": "…",
  "user_code": "ABCD-EFGH",
  "verification_uri": "https://edge.dply.io/auth/device",
  "verification_uri_complete": "https://edge.dply.io/auth/device?user_code=ABCD-EFGH",
  "expires_in": 900,
  "interval": 2
}
```

Send the user to `verification_uri_complete` to approve. The code expires after 15 minutes.

### Poll for the token

`POST /auth/device/poll`

| Parameter | In | Rules |
|---|---|---|
| `device_code` | body | Required, 16 to 128 characters. |

Poll every `interval` seconds. The response is `{ "status": "pending" }`, `"denied"` or `"expired"` until the user approves, then once:

```json
{ "status": "authorized", "token": "dply_…" }
```

The token is returned only once. Later polls with the same code return `expired`.

## Related

- [HTTP API](/docs/api)
- [CLI](/docs/cli)
- [Roles & permissions](/docs/roles-and-permissions)
- [Deployments](/docs/deployments)
