Guide
projects

Deploy API

Create API tokens and trigger deployments programmatically from CI, a CMS, or any HTTP client.


The Deploy API lets you trigger a deployment of your project from outside the dashboard — from a CI pipeline, a CMS webhook, or any script that can make an HTTP request. You authenticate with a bearer token that you create on this page.

To open it, go to your project's Settings, and under Integrations click Deploy API.

Note: Managing deploy tokens requires an organization admin role. If you don't have it, you'll be redirected to the project settings page.

Watch: create a deploy token

Create a token

  1. Click New token in the top-right.
  2. In the New deploy token dialog, fill in:
    • Name — a label so you remember what the token is for (for example, GitHub Actions production). It's also shown in deploy notifications.
    • Scope — choose one of:
      • Live deployment — always deploys your project's deploy branch (typically main) to the production URL.
      • Preview branch — deploys one specific branch to a preview URL. A Branch field appears for you to name it.
      • Any branch in this project — the caller chooses the branch and whether it's live or preview, per request. Useful for A/B variants and CI scripts that build many branches under one token.
    • Expires inNever, 7 days, 30 days, 90 days, or 1 year. After it expires, the token stops working and returns a "token expired" error.
  3. Click Create token.

Warning: The token is shown once, right after creation, in a highlighted box with a Copy button. Copy it now and store it somewhere safe — it won't be shown again. If you lose it, revoke it and create a new one.

Treat tokens like passwords: keep them out of source control, and give each integration its own token with the narrowest scope it needs.

Manage existing tokens

Each token appears in the Tokens list with its name, a masked prefix, what it deploys, when it was created, and when it was last used (or Never used). Expired tokens are flagged.

To revoke a token, click Revoke on its row and confirm. The token stops working immediately, so any integration using it will need a new one.

Trigger a deployment

The page shows the exact base URL for your project. It looks like this:

https://<api-host>/v1/projects/<your-project-slug>/deployments

There are two endpoints:

Method Path Purpose
POST /v1/projects/<slug>/deployments Trigger a new deployment
GET /v1/projects/<slug>/deployments/<deployment-id> Poll deployment status

Authenticate every request by sending your token in the Authorization header:

Authorization: Bearer pdt_...

Trigger with curl

curl -X POST https://<api-host>/v1/projects/<slug>/deployments \
  -H "Authorization: Bearer pdt_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: ci-build-${BUILD_ID}" \
  -d '{"branch": "main", "triggeredBy": "ci-build-${BUILD_ID}"}'

Trigger with fetch (JavaScript)

const res = await fetch('https://<api-host>/v1/projects/<slug>/deployments', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pdt_...',
    'Content-Type': 'application/json',
    // Same value on every retry of this deploy — see Idempotency-Key below.
    'Idempotency-Key': 'ci-build-42',
  },
  body: JSON.stringify({
    branch: 'main',           // omit for branch-scoped tokens
    // isLive: false,         // optional override; inferred from branch
    triggeredBy: 'ci-build',
  }),
})
const { deploymentId, statusUrl } = await res.json()

Trigger with PHP

$ch = curl_init('https://<api-host>/v1/projects/<slug>/deployments');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer pdt_...',
    'Content-Type: application/json',
    // Same value on every retry of this deploy — see Idempotency-Key below.
    'Idempotency-Key: ci-build-42',
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'branch' => 'main',         // omit for branch-scoped tokens
    'triggeredBy' => 'ci-build',
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
$deploymentId = $response['deploymentId'];
$statusUrl    = $response['statusUrl'];

Trigger with Python

import requests

res = requests.post(
    'https://<api-host>/v1/projects/<slug>/deployments',
    headers={
        'Authorization': 'Bearer pdt_...',
        # Same value on every retry of this deploy — see Idempotency-Key below.
        'Idempotency-Key': 'ci-build-42',
    },
    json={
        'branch': 'main',          # omit for branch-scoped tokens
        # 'isLive': False,         # optional override; inferred from branch
        'triggeredBy': 'ci-build',
    },
)
data = res.json()
deployment_id = data['deploymentId']
status_url = data['statusUrl']

Tip: The dashboard's API documentation section shows these same snippets pre-filled with your project's real URL — and, right after you create a token, with the token itself.

Request body

POST accepts an optional JSON body (Content-Type: application/json, max 8 KB):

Field Type When Description
triggeredBy string optional Free-form label shown next to the token name in deploy notifications (max 100 chars).
branch string required for any branch tokens; ignored for branch-scoped tokens The branch to deploy.
isLive boolean optional override Defaults to true when branch matches the project's deploy branch (typically main), otherwise false. Pass it explicitly only for edge cases.

For a branch-scoped token (Live deployment or Preview branch), you can omit the body entirely — the token already knows what to deploy.

Response

A successful POST returns 202 Accepted with the deployment id and the URL to poll:

{
  "deploymentId": "01J...",
  "statusUrl": "https://<api-host>/v1/projects/<slug>/deployments/01J..."
}

Poll deployment status

Send a GET to the statusUrl with the same bearer token:

curl https://<api-host>/v1/projects/<slug>/deployments/<deployment-id> \
  -H "Authorization: Bearer pdt_..."

The response reports progress:

{
  "deploymentId": "01J...",
  "status": "running",
  "createdAt": "2026-05-07T12:00:00.000Z",
  "startedAt": "2026-05-07T12:00:01.000Z",
  "completedAt": null,
  "publishedUrl": null,
  "errorMessage": null
}

The status field moves through:

pending → queued → running → completed

Or it ends in a terminal failure state:

  • failed — the build or deploy hit an error; see errorMessage.
  • cancelled — a newer deployment for the same branch superseded this one.

When a deployment completes, publishedUrl points at the live result.

Error codes

Every error returns JSON shaped like {"error": {"code": "...", "message": "..."}}. Switch on code in your scripts — the human-readable message may change.

HTTP Code Meaning
400 invalid_body Request body is malformed JSON or has wrong field types.
400 body_too_large Request body exceeds 8 KB.
400 branch_required Token is "any branch" but body has no "branch" field.
400 invalid_branch Branch name fails validation (charset, length, ".." check).
400 invalid_idempotency_key Idempotency-Key header has bad chars or is too long.
401 missing_authorization No Authorization: Bearer header sent.
401 invalid_token Token is wrong or has been revoked.
401 token_rejected Token was rejected for a reason none of the codes above covers. Generate a new one.
403 invalid_token_scope Token has no project scope. Generate a new one.
403 token_not_for_project URL slug doesn't match the project this token is scoped to (or the project moved orgs).
404 deployment_not_found No deployment with that ID for this project.
409 builds_disabled Managed builds are disabled for this project — deploy it through your own CI instead.
409 idempotency_conflict Same Idempotency-Key was previously used for a different branch, or for the other of live/preview.
410 token_expired Token reached its expiry date. Generate a new one.
410 token_disabled Token has been disabled. Generate a new one.
429 rate_limited Token exceeded its 60 req/min rate limit.

Good to know

  • Idempotency-Key. To make retries safe, send an Idempotency-Key header. A second POST within 24 hours using the same key (and same branch) returns the original deploymentId instead of creating a duplicate, and the replay carries an Idempotent-Replayed: true header.

    The key identifies the deploy you want, not the attempt. Every retry of the same deploy has to send the same value — a fresh UUID per attempt protects nothing, because each one asks for a different deploy. Derive it from whatever makes this deploy unique: a build id, a commit SHA, a content hash. A scheduled caller that has none of those can use the run it belongs to, e.g. nightly-2026-08-24 — the date of the run, not the current time. (1–200 chars, charset A-Z a-z 0-9 ._:/+=-.)

    The key is tied to what it deployed. Reusing it for a different branch, or to flip a preview to live, answers 409 idempotency_conflict rather than quietly re-aiming a deployment that is still waiting.

    A replay returns the existing deployment instead of creating a second one. If that deployment is still waiting — an earlier attempt created it but never got it started — the replay puts the queue back in motion, so retrying is what recovers it. When the branch already has a deployment running, the replay changes nothing and answers queued; poll the status URL rather than sending the request again.

  • Rate limits. Each token is limited to 60 requests per minute; exceeding it returns 429 Too Many Requests. Bursts to the same branch are coalesced — only the latest deployment runs — while different branches deploy in parallel.

  • Builds disabled. If the project has managed builds disabled, a POST returns 409 with code builds_disabled — deploy the site through your own CI instead.