API reference
Every Easel API endpoint by area, with its request, response, and permission, plus error codes, troubleshooting, and FAQ.
Every endpoint is under https://app.easel.sh/api/v1. Authentication,
idempotency, pagination, and the error format are the same for all of them;
see API. Request and response types are exported by the
SDK under the same names used here.
Paths take a project's ID or its slug wherever they say :project. A project,
deployment, or domain outside the token's scope returns 404, as if it didn't
exist.
Workspace
| Endpoint | Permission | Returns |
|---|---|---|
GET /v1/whoami | projects:read | { user: { id, name, email }, workspace: { id, name, slug } } |
GET /v1/workspaces | projects:read | { workspaces: [{ id, name, slug }] }: the token's one workspace |
Tokens
| Endpoint | Permission | Returns |
|---|---|---|
GET /v1/tokens | tokens:read | { tokens: Token[] } |
POST /v1/tokens | tokens:write | 201 { token: Token, secret } |
DELETE /v1/tokens/:id | tokens:write | 204 |
POST body: { name, permissions, projectIds?, expiresIn }, with expiresIn
in seconds. permissions maps a resource to its actions, such as
{ "deployments": ["create", "read"] }. A Token is
{ id, name, start, permissions, projectIds, expiresAt, createdAt }, where
start is the token's first characters and projectIds is null for every
project. secret is the token itself and is returned only here.
The minting rules, and what each permission allows, are in Tokens.
Projects
| Endpoint | Permission | Returns |
|---|---|---|
GET /v1/projects | projects:read | { projects: Project[] } |
POST /v1/projects | projects:create | 201 { project } |
GET /v1/projects/:project | projects:read | { project } |
PATCH /v1/projects/:project | projects:update | { project } |
DELETE /v1/projects/:project | projects:delete | 202 { projectId, status: "deleting" } |
A Project is { id, name, slug, organizationId, rootDirectory, framework, buildCommand, installCommand, outputDirectory, createdAt }.
POSTtakes{ name, framework?, rootDirectory?, buildCommand?, installCommand?, outputDirectory? }. Settings you leave out are detected at build time. A name already in the workspace is409 project_name_taken.PATCHtakes the same fields. Omitted fields don't change, andnullclears a setting.GET /v1/projectstakes?project=<slug>or?projectId=to narrow the list to one.- Deleting a project removes its deployments and domains in the background.
Environment variables
| Endpoint | Permission | Returns |
|---|---|---|
GET /v1/projects/:project/env | env:read | { envs: EnvVar[] } |
POST /v1/projects/:project/env | env:write | 201 { created: EnvVar[] } |
PATCH /v1/projects/:project/env/:id | env:write | { env } |
DELETE /v1/projects/:project/env/:id | env:write | 204 |
An EnvVar is { id, key, target, gitBranch, comment, sensitive, value?, createdAt, updatedAt }. target lists production, preview, and
development.
GETtakes?target=to filter, and?decrypt=trueto includevaluefor variables that aren't sensitive.POSTtakes one{ key, value, target, gitBranch?, comment?, sensitive? }or an array of them, applied together. A variable with the same key and branch in an overlapping environment is409 env_conflict; with?upsert=true, it's replaced instead.PATCHtakes{ key?, value?, target?, gitBranch?, comment? }.
Changes apply to new deployments. See Environment variables.
Deployments
| Endpoint | Permission | Returns |
|---|---|---|
POST /v1/projects/:project/deployments | deployments:create | 202 { deployment } |
POST /v1/projects/:project/deployment-uploads | deployments:create | 201 { uploadId, uploadUrl, expiresAt } |
PUT <uploadUrl> | None | 202 { deploymentId } |
GET /v1/deployment-uploads/:id | deployments:read | { uploadId, status, deploymentId, expiresAt } |
GET /v1/projects/:project/deployments | deployments:read | { deployments, pagination } |
GET /v1/deployments/:id | deployments:read | { deployment } |
GET /v1/deployments/by-url?url= | deployments:read | { deployment } for a generated deployment URL |
GET /v1/deployments/:id/logs | deployments:read | { logs: [{ id, message, level, createdAt }] }: the build log |
GET /v1/deployments/:id/runtime-logs | deployments:read | { records }: requests the deployment served |
PATCH /v1/deployments/:id/cancel | deployments:cancel | { deployment } |
A deployment is { id, projectId, source, target, status, queuedAt, buildingAt, readyAt, errorAt, cancelledAt, primaryUrl, gitSource, meta, aliases }. status is one of QUEUED, PROVISIONING, BUILDING,
ASSIGNING_DOMAINS, READY, ERROR, CANCELLED, SKIPPED, SUPERSEDED,
or SPUN_DOWN; see Deployment statuses.
Create a deployment
Send the source in one of these forms. Every form drops .env files and
version control directories; see
What Easel drops.
A gzip tarball as the body. Set Content-Type: application/gzip, and put
the rest in the query and headers:
| Part | Description |
|---|---|
?target= | production or preview (default) |
?prebuilt=true | The archive includes a prebuilt .vercel/output; skip the framework build |
x-easel-meta | JSON object of string pairs to attach as meta |
x-easel-git | JSON { commitHash?, commitMessage?, branch?, repository? } to show on the deployment |
JSON. { target?, meta?, prebuilt?, gitMetadata?, ...source }, where the
source is exactly one of:
| Field | Description |
|---|---|
inlineFiles | [{ file, data, encoding?, mode? }], with data as UTF-8 text or base64 (encoding: "base64") |
files | [{ file, contentHash, size, mode }] for files already uploaded with POST /v1/files |
archive | { contentHash, size } of a tarball already uploaded with POST /v1/files |
A JSON request has a size cap, and larger sources go as a tarball or a file list. See Limits.
Upload URLs
POST /v1/projects/:project/deployment-uploads takes { target?, meta?, expiresIn? }. PUT a gzip tarball to the uploadUrl it returns, with no
Authorization header. The upload's status is pending until a deployment
is created, then received with its deploymentId, or expired.
PUT response | Meaning |
|---|---|
202 { deploymentId } | The deployment was created |
400, 413 | The body was rejected; fix it and retry the same URL |
404 | No such upload URL |
410 upload_consumed | The URL already created a deployment |
410 upload_expired | The URL expired |
410 upload_revoked | The token that minted it is no longer valid, its user left the workspace, or its project was deleted |
See Deploy source.
List deployments
GET /v1/projects/:project/deployments returns the newest first, and takes:
| Query | Description |
|---|---|
target | production or preview |
status | Comma-separated statuses, such as READY,ERROR |
branch | A Git branch |
limit | Page size. See Limits |
cursor | pagination.nextCursor from the previous page |
Runtime logs
GET /v1/deployments/:id/runtime-logs takes ?since= (an ISO timestamp) and
?limit= (see Limits), and returns
the newest requests first. Each record is { requestId, method, path, host, statusCode, durationMs, lastEventAt, deploymentId, cacheCdn, functionInvoked, functionRoute, logCount, firstLogMessage, outcome }.
Cancel
Cancelling works on a deployment that's queued or building. A finished one is
409.
Files
| Endpoint | Permission | Returns |
|---|---|---|
POST /v1/files/missing | files:write | { missing: string[] } |
POST /v1/files | files:write | 200 |
POST /v1/files/missing takes { hashes: ["sha256:<hex>", …] } and returns
the ones your workspace hasn't uploaded. POST /v1/files takes one file's raw
bytes, with its SHA-256 in hex in the x-content-hash header. Then list the
files in a JSON deployment request. The SDK does all of this for you.
Traffic
| Endpoint | Permission | Returns |
|---|---|---|
POST /v1/projects/:project/promote/:deploymentId | deployments:promote | 200 or 202 { deployment, rebuilt } |
POST /v1/projects/:project/rollback/:deploymentId | deployments:promote | 200 { deployment, rebuilt: false } |
Both also need the token's user to be a workspace Owner or Admin.
- Promoting a Ready Production deployment serves it now:
200with the same deployment. - Promoting a Preview deployment starts a Production build of it with
Production environment variables:
202with the new deployment andrebuilt: true. It serves once it's Ready. - Rolling back serves an earlier Ready Production deployment without a rebuild, and turns off automatic promotion until the next promote.
Project domains
| Endpoint | Permission | Returns |
|---|---|---|
GET /v1/projects/:project/domains | domains:read | { domains: ProjectDomain[] } |
POST /v1/projects/:project/domains | domains:write | 201 { domain }, or 200 if the project already has it |
GET /v1/projects/:project/domains/:domain | domains:read | { domain } |
POST /v1/projects/:project/domains/:domain/verify | domains:write | { domain } |
DELETE /v1/projects/:project/domains/:domain | domains:write | 204 |
POST takes { name, move? }. With move: true, a name on another project
in your workspace moves here, keeping its target, status, and certificate
(200); without it, that's 409 domain_in_project. A ProjectDomain is { name, projectId, status, records, error, verifiedAt, createdAt }, with status one of pending_dns,
provisioning_tls, live, or error, and records as
[{ type, name, value }]. verify checks now and waits briefly for the
certificate. See Your users' domains.
Wildcard domains
| Endpoint | Permission | Returns |
|---|---|---|
GET /v1/wildcard-domains | domains:read | { wildcardDomains: WildcardDomain[] } |
POST /v1/wildcard-domains | domains:write | 201 { wildcardDomain }, or 200 if the workspace already has it |
GET /v1/wildcard-domains/:domain | domains:read | { wildcardDomain } |
POST /v1/wildcard-domains/:domain/verify | domains:write | { wildcardDomain } |
DELETE /v1/wildcard-domains/:domain | domains:write | 204 |
POST takes { domain }, such as yourplatform.app. :domain accepts the
domain with or without *.. A WildcardDomain is { domain, dnsMode, status, records, error, verifiedAt, createdAt }; its records stay listed while it's
live, because renewals use them. These endpoints need a token that isn't
limited to projects. See
Set up your domains.
Branded CNAME target
| Endpoint | Permission | Returns |
|---|---|---|
GET /v1/domain-target | domains:read | { domainTarget }, null when there's none |
PUT /v1/domain-target | domains:write | { domainTarget } |
POST /v1/domain-target/verify | domains:write | { domainTarget } |
DELETE /v1/domain-target | domains:write | 204 |
PUT takes { host } and replaces any previous target. A domainTarget is
{ host, status, records, error, verifiedAt }, with status one of
pending_dns, live, or error. records is the CNAME from the host to
its own Easel target. Once it's live, project domains in the workspace verify
through it as well as through their own targets. Removing it keeps live
domains live, but a later check of a domain whose CNAME only reaches the
removed target fails. These endpoints need a token that isn't
limited to projects. See
Set a branded CNAME target.
Usage
| Endpoint | Permission | Returns |
|---|---|---|
GET /v1/usage?from=&to=&projectId= | usage:read | { from, to, projects: ProjectUsage[] } |
A ProjectUsage is { projectId, requests, functionInvocations, functionDurationGbHours, dataTransferGb, buildMinutes, meters }. See
Usage and billing.
Webhooks
| Endpoint | Permission | Returns |
|---|---|---|
GET /v1/webhooks | webhooks:read | { webhooks: Webhook[] } |
POST /v1/webhooks | webhooks:write | 201 { webhook, secret } |
DELETE /v1/webhooks/:id | webhooks:write | 204 |
POST takes { url, events, projectIds? }. A Webhook is { id, url, events, projectIds, createdAt }, with an empty projectIds for every
project. secret is returned only here. Creating and deleting also need the
token's user to be an Owner or Admin. See
Webhooks.
Requests
| Endpoint | Permission | Returns |
|---|---|---|
GET /v1/projects/:project/requests | deployments:read | { records } |
GET /v1/requests/:requestId/events?projectId= | deployments:read | { events: [{ id, kind, ts, message, level, fields }] } |
The project's request log, as the dashboard shows it, and one request's
events and function logs. requests takes deploymentId, q, from, to,
since, levels, and limit. These back easel logs; see
Inspect deployments and logs.
Errors
Every error has a JSON body { error, code?, records? }. error is a message
you can show; code is stable for your code to check; records lists DNS
records when adding one is the fix.
| Status | code | Meaning |
|---|---|---|
400 | invalid_json, invalid_body, invalid_query | The request didn't parse or validate; error names the field |
400 | invalid_meta, invalid_git_metadata | The x-easel-meta or x-easel-git header is invalid |
400 | invalid_source, invalid_archive, empty_body, upload_interrupted | The source couldn't be used. invalid_archive means the body isn't gzip |
400 | invalid_cursor | The pagination cursor is invalid |
400 | invalid_idempotency_key | The Idempotency-Key is too long |
400 | token_scope | A token request breaks the minting rules |
400 | invalid_env | An environment variable is invalid |
400 | invalid_domain | The domain name is invalid |
400 | invalid_webhook_url | The webhook URL isn't public HTTPS on port 443 |
400 | not_ready, not_production, already_current, not_eligible, no_routes | The deployment can't be promoted or rolled back to |
401 | unauthorized | The token is missing, invalid, expired, or lacks the permission |
403 | forbidden | The token's user lacks the workspace role, or the token names another workspace |
403 | token_scope | Workspace domain setup with a token limited to projects |
403 | project_out_of_scope | A webhook's projectIds are outside the token's projects |
403 | wildcard_domain_not_yours | The name is under another workspace's wildcard domain |
403 | project_paused, workspace_suspended | Deploys are blocked for the project or workspace |
404 | not_found, project_not_found | It doesn't exist, or it's outside the token's scope |
409 | project_name_taken | The workspace already has a project with that name |
409 | env_conflict | A variable with that key and branch exists in an overlapping environment |
409 | domain_in_use | Another workspace holds the domain; records has the proof to add |
409 | domain_in_project | The domain is on another project in your workspace; resend with move: true to move it |
409 | webhook_limit_reached | The workspace has the most webhooks it can have |
409 | not_cancellable, conflict | The deployment already finished, or traffic changed at the same time; retry |
409 | idempotency_in_progress | A request with this Idempotency-Key is still running |
410 | upload_consumed, upload_expired, upload_revoked | The upload URL can't be used; mint a new one |
413 | payload_too_large | The body is over its limit |
422 | idempotency_key_reused | This Idempotency-Key was used for a different request |
429 | rate_limited | The token's rate limit; wait as error says |
503 | wildcard_domains_unavailable | Wildcard domains can't be added right now |
Troubleshooting
A wildcard domain stays in pending_dns
error names the record that's wrong. The usual causes:
- A TXT record at
_acme-challenge. A name can't have a CNAME and other records. Delete the TXT record, often left by an earlier certificate tool, then add the CNAME. - The wildcard CNAME points elsewhere, such as at another domain's target. Each wildcard domain has its own; copy the value from the dashboard.
- The record is proxied. Set it to DNS-only.
Then select Check DNS, or call POST /v1/wildcard-domains/:domain/verify.
A domain is stuck in provisioning_tls
DNS is right, and the certificate hasn't been issued yet. Usually this clears within minutes. If it doesn't:
- Check for CAA records that don't allow Let's Encrypt. See CAA records.
- Make sure no proxy answers in front of Easel.
- Call
verifyagain; it starts issuance if it stalled.
For a custom domain, see Domains troubleshooting.
An upload URL returns 410
code says why: the URL already created a deployment, it expired, or the
token that minted it is no longer valid. Mint a new URL for each publish, with
an expiresIn that covers your sandbox's startup time. A body Easel rejected
with 400 or 413 doesn't use up the URL.
A project returns 404, but it exists
The token can't see it. A token limited to projects returns 404 for every
other project and its deployments and domains. Check the token's
projectIds with GET /v1/tokens, and that it belongs to the project's
workspace.
A request returns 401 with a valid token
The token lacks the permission the endpoint needs, or it expired. Each endpoint's permission is listed above.
FAQ
Do you compete with us?
No. Easel doesn't make an app builder. We run the apps; your users never see Easel.
What can our users' apps be?
Any app Easel builds: a supported framework, or plain
HTML with Vercel-style functions in an api/ directory. See
What your users' apps can be.
How do apps get from our product onto Easel?
Your backend calls the API, usually through the SDK. A sandbox uploads the app to a single-use upload URL, or your backend streams an archive or sends the files. See Deploy source.
What happens to apps nobody visits?
They stay deployed and keep their URLs, and an idle app doesn't hold a machine. See Every publish is a deployment.
Can our users use their own domains?
Yes. Each app can have a name under your wildcard domain, live at once, and your users can connect their own domains with one DNS record. See Your users' domains.
Can we bill our users for what their apps use?
Yes. GET /v1/usage reports what each app used. See
Usage and billing.