Skip to main content

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

EndpointPermissionReturns
GET /v1/whoamiprojects:read{ user: { id, name, email }, workspace: { id, name, slug } }
GET /v1/workspacesprojects:read{ workspaces: [{ id, name, slug }] }: the token's one workspace

Tokens

EndpointPermissionReturns
GET /v1/tokenstokens:read{ tokens: Token[] }
POST /v1/tokenstokens:write201 { token: Token, secret }
DELETE /v1/tokens/:idtokens:write204

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

EndpointPermissionReturns
GET /v1/projectsprojects:read{ projects: Project[] }
POST /v1/projectsprojects:create201 { project }
GET /v1/projects/:projectprojects:read{ project }
PATCH /v1/projects/:projectprojects:update{ project }
DELETE /v1/projects/:projectprojects:delete202 { projectId, status: "deleting" }

A Project is { id, name, slug, organizationId, rootDirectory, framework, buildCommand, installCommand, outputDirectory, createdAt }.

  • POST takes { name, framework?, rootDirectory?, buildCommand?, installCommand?, outputDirectory? }. Settings you leave out are detected at build time. A name already in the workspace is 409 project_name_taken.
  • PATCH takes the same fields. Omitted fields don't change, and null clears a setting.
  • GET /v1/projects takes ?project=<slug> or ?projectId= to narrow the list to one.
  • Deleting a project removes its deployments and domains in the background.

Environment variables

EndpointPermissionReturns
GET /v1/projects/:project/envenv:read{ envs: EnvVar[] }
POST /v1/projects/:project/envenv:write201 { created: EnvVar[] }
PATCH /v1/projects/:project/env/:idenv:write{ env }
DELETE /v1/projects/:project/env/:idenv:write204

An EnvVar is { id, key, target, gitBranch, comment, sensitive, value?, createdAt, updatedAt }. target lists production, preview, and development.

  • GET takes ?target= to filter, and ?decrypt=true to include value for variables that aren't sensitive.
  • POST takes 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 is 409 env_conflict; with ?upsert=true, it's replaced instead.
  • PATCH takes { key?, value?, target?, gitBranch?, comment? }.

Changes apply to new deployments. See Environment variables.

Deployments

EndpointPermissionReturns
POST /v1/projects/:project/deploymentsdeployments:create202 { deployment }
POST /v1/projects/:project/deployment-uploadsdeployments:create201 { uploadId, uploadUrl, expiresAt }
PUT <uploadUrl>None202 { deploymentId }
GET /v1/deployment-uploads/:iddeployments:read{ uploadId, status, deploymentId, expiresAt }
GET /v1/projects/:project/deploymentsdeployments:read{ deployments, pagination }
GET /v1/deployments/:iddeployments:read{ deployment }
GET /v1/deployments/by-url?url=deployments:read{ deployment } for a generated deployment URL
GET /v1/deployments/:id/logsdeployments:read{ logs: [{ id, message, level, createdAt }] }: the build log
GET /v1/deployments/:id/runtime-logsdeployments:read{ records }: requests the deployment served
PATCH /v1/deployments/:id/canceldeployments: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:

PartDescription
?target=production or preview (default)
?prebuilt=trueThe archive includes a prebuilt .vercel/output; skip the framework build
x-easel-metaJSON object of string pairs to attach as meta
x-easel-gitJSON { commitHash?, commitMessage?, branch?, repository? } to show on the deployment
tar czf - . | curl -sSf -X POST \
  -H "Authorization: Bearer $EASEL_TOKEN" \
  -H "Content-Type: application/gzip" \
  -H 'x-easel-meta: {"appId":"42"}' \
  --data-binary @- \
  "https://app.easel.sh/api/v1/projects/trip-planner/deployments?target=production"

JSON. { target?, meta?, prebuilt?, gitMetadata?, ...source }, where the source is exactly one of:

FieldDescription
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 responseMeaning
202 { deploymentId }The deployment was created
400, 413The body was rejected; fix it and retry the same URL
404No such upload URL
410 upload_consumedThe URL already created a deployment
410 upload_expiredThe URL expired
410 upload_revokedThe 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:

QueryDescription
targetproduction or preview
statusComma-separated statuses, such as READY,ERROR
branchA Git branch
limitPage size. See Limits
cursorpagination.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

EndpointPermissionReturns
POST /v1/files/missingfiles:write{ missing: string[] }
POST /v1/filesfiles:write200

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

EndpointPermissionReturns
POST /v1/projects/:project/promote/:deploymentIddeployments:promote200 or 202 { deployment, rebuilt }
POST /v1/projects/:project/rollback/:deploymentIddeployments:promote200 { 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: 200 with the same deployment.
  • Promoting a Preview deployment starts a Production build of it with Production environment variables: 202 with the new deployment and rebuilt: 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.

See Promote and roll back.

Project domains

EndpointPermissionReturns
GET /v1/projects/:project/domainsdomains:read{ domains: ProjectDomain[] }
POST /v1/projects/:project/domainsdomains:write201 { domain }, or 200 if the project already has it
GET /v1/projects/:project/domains/:domaindomains:read{ domain }
POST /v1/projects/:project/domains/:domain/verifydomains:write{ domain }
DELETE /v1/projects/:project/domains/:domaindomains:write204

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

EndpointPermissionReturns
GET /v1/wildcard-domainsdomains:read{ wildcardDomains: WildcardDomain[] }
POST /v1/wildcard-domainsdomains:write201 { wildcardDomain }, or 200 if the workspace already has it
GET /v1/wildcard-domains/:domaindomains:read{ wildcardDomain }
POST /v1/wildcard-domains/:domain/verifydomains:write{ wildcardDomain }
DELETE /v1/wildcard-domains/:domaindomains:write204

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

EndpointPermissionReturns
GET /v1/domain-targetdomains:read{ domainTarget }, null when there's none
PUT /v1/domain-targetdomains:write{ domainTarget }
POST /v1/domain-target/verifydomains:write{ domainTarget }
DELETE /v1/domain-targetdomains:write204

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

EndpointPermissionReturns
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

EndpointPermissionReturns
GET /v1/webhookswebhooks:read{ webhooks: Webhook[] }
POST /v1/webhookswebhooks:write201 { webhook, secret }
DELETE /v1/webhooks/:idwebhooks:write204

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

EndpointPermissionReturns
GET /v1/projects/:project/requestsdeployments: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.

StatuscodeMeaning
400invalid_json, invalid_body, invalid_queryThe request didn't parse or validate; error names the field
400invalid_meta, invalid_git_metadataThe x-easel-meta or x-easel-git header is invalid
400invalid_source, invalid_archive, empty_body, upload_interruptedThe source couldn't be used. invalid_archive means the body isn't gzip
400invalid_cursorThe pagination cursor is invalid
400invalid_idempotency_keyThe Idempotency-Key is too long
400token_scopeA token request breaks the minting rules
400invalid_envAn environment variable is invalid
400invalid_domainThe domain name is invalid
400invalid_webhook_urlThe webhook URL isn't public HTTPS on port 443
400not_ready, not_production, already_current, not_eligible, no_routesThe deployment can't be promoted or rolled back to
401unauthorizedThe token is missing, invalid, expired, or lacks the permission
403forbiddenThe token's user lacks the workspace role, or the token names another workspace
403token_scopeWorkspace domain setup with a token limited to projects
403project_out_of_scopeA webhook's projectIds are outside the token's projects
403wildcard_domain_not_yoursThe name is under another workspace's wildcard domain
403project_paused, workspace_suspendedDeploys are blocked for the project or workspace
404not_found, project_not_foundIt doesn't exist, or it's outside the token's scope
409project_name_takenThe workspace already has a project with that name
409env_conflictA variable with that key and branch exists in an overlapping environment
409domain_in_useAnother workspace holds the domain; records has the proof to add
409domain_in_projectThe domain is on another project in your workspace; resend with move: true to move it
409webhook_limit_reachedThe workspace has the most webhooks it can have
409not_cancellable, conflictThe deployment already finished, or traffic changed at the same time; retry
409idempotency_in_progressA request with this Idempotency-Key is still running
410upload_consumed, upload_expired, upload_revokedThe upload URL can't be used; mint a new one
413payload_too_largeThe body is over its limit
422idempotency_key_reusedThis Idempotency-Key was used for a different request
429rate_limitedThe token's rate limit; wait as error says
503wildcard_domains_unavailableWildcard 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 verify again; 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.