The ticket API lets your own systems file a card onto an Everframe board directly — a failing CI run, a cron job, a Slack bot, anything that is not a human clicking around the dashboard.
curl -X POST https://everframe.dev/api/v1/tickets \
-H "Authorization: Bearer $EVERFRAME_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "Checkout crashes on iOS 18"}'
Get a token
Settings → API tokens → Create. An organisation admin role is required for every token operation — create, list, rename, re-scope, revoke.
Each token has:
- A name, up to 80 characters. This becomes the ticket’s author in Everframe,
on the card and in activity history, so name it after the system using it
(
ci-bot,support-triage), not a person. Tokens are deliberately left out of the@mentionlist — there is nobody behind one to notify. - A scope: all projects, or a chosen list. Narrowing to only the projects an integration actually needs limits the blast radius if the token leaks. A CI job that files against one project has no business holding a token that reaches every other one.
The token is shown once. Everframe stores only a hash and cannot show it again. Lose it and you revoke it and create a new one.
An API token is not an SDK key. An SDK key ships inside your application — a web bundle or a decompiled binary yields it — which is why the endpoint it authenticates only ever accepts reports. An API token can create tickets in your projects. Never embed one in an application you ship: it belongs in CI secrets or a server environment.
Authenticate
Authorization: Bearer evf_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Any problem with the credential — missing, malformed, unknown, or revoked —
returns 401 { "error": "invalid_api_token" }, deliberately without saying
which. Telling a caller a token is “revoked” rather than “invalid” would confirm
it once existed. Revocation takes effect immediately.
Create a ticket
POST /api/v1/tickets
| Field | Type | Notes |
|---|---|---|
title | string, required | 1–200 characters, plain text. |
description | string | Up to 20 000 characters, plain text. |
projectId | uuid | Defaults to the token’s only reachable project; required once it reaches more than one. |
boardId | uuid | Defaults to the project’s first board. |
columnId | uuid | Defaults to the board’s leftmost column. |
priority | string | none, low, medium, high. Defaults to none. |
assigneeUserId | uuid, nullable | Must be a member of the organisation. |
agentRepositoryId | uuid, nullable | Must be a repository already bound to the project. |
startsAt | string, nullable | YYYY-MM-DD. |
dueAt | string, nullable | YYYY-MM-DD. |
attachmentIds | uuid[] | Up to 10 ids from POST /api/v1/attachments. Claimed with the ticket. |
projectId, boardId and columnId resolve in order. Omit the project and the
token must reach exactly one — the request fails rather than guessing once it
reaches more than one. Omit the board and you get the project’s first; omit the
column and you get that board’s leftmost.
Assigning to the agent starts a run. If
assigneeUserIdnames the organisation’s AI agent, Everframe enqueues an autonomous run against the ticket — the same as assigning a card to the agent in the dashboard. It consumes credits and may open a pull request. That is what makesagentRepositoryIduseful, since it selects which bound repository the run works against. If you only want a card on the board, do not setassigneeUserIdto the agent.
On success, 201:
{
"id": "<uuid>",
"title": "Checkout crashes on iOS 18",
"projectId": "<uuid>",
"boardId": "<uuid>",
"columnId": "<uuid>",
"createdAt": "<ISO 8601>",
"url": "<dashboard deep link>"
}
url is a deep link straight to the new card — print it in CI output so a
person can click through.
Upload a file
POST /api/v1/attachments
Content-Type: multipart/form-data
Upload one file, then reference its id from a ticket or a comment. Send the file
as a single multipart part (any field name); the request carries exactly one
file. The token needs tickets:write. projectId is an optional query
parameter here, with the same rules as for creating a ticket.
On success, 201:
{
"id": "<uuid>",
"filename": "shot.png",
"contentType": "image/png",
"sizeBytes": 48213
}
An upload is unclaimed until a ticket or comment uses its id. It belongs to the project it was uploaded to and to the token’s principal: another token cannot claim it, and an id from a different project is rejected. Unclaimed uploads are cleaned up after about 24 hours, so claim them promptly.
Limits and accepted types:
- 25 MB per file, one file per request.
- PNG, JPEG, GIF, WebP, MP4, WebM, QuickTime, PDF, ZIP, JSON, CSV and plain
text. The type is detected from the file’s bytes, not from the filename or the
Content-Typeyou send. SVG, HTML and other markup are refused. - 60 uploads per hour per token.
- At most 50 unclaimed uploads at a time, per token per project.
Upload errors:
| Status | Error | Meaning |
|---|---|---|
400 | invalid_input | Not a multipart request, or no file part. |
400 | too_many_files | More than one file. |
400 | empty_file | The file has no bytes. |
400 | unsupported_type | The detected type is not accepted. |
409 | too_many_unclaimed_attachments | Claim or wait out the existing unclaimed uploads first. |
413 | file_too_large | Over 25 MB; the body carries the limit in bytes. |
429 | — | Over the hourly upload limit. |
502 | storage_unavailable | File storage is not available; retry later. |
Plus the shared 401, 403 and 404 project_not_found / 400 project_required
errors from Errors.
Create a ticket with files
Upload first, then pass the ids to POST /api/v1/tickets:
ID=$(curl -s -X POST https://everframe.dev/api/v1/attachments \
-H "Authorization: Bearer $EVERFRAME_TOKEN" \
-F "[email protected]" | jq -r .id)
curl -X POST https://everframe.dev/api/v1/tickets \
-H "Authorization: Bearer $EVERFRAME_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"title\": \"Checkout crashes on iOS 18\", \"attachmentIds\": [\"$ID\"]}"
The ids are claimed in the same transaction as the ticket. If any id cannot be claimed, the whole request fails and no ticket is created (see the errors below).
Attach files to an existing ticket
POST /api/v1/tickets/:ticket/attachments
{ "attachmentIds": ["<uuid>", "<uuid>"] }
attachmentIds is required: 1 to 10 ids from the upload endpoint. A ticket holds
at most 10 attachments of its own; a request that would push it past that fails
whole with too_many_attachments.
:ticket is either the ticket’s uuid or its number (the #123 shown in
the dashboard, sent as 123). The request is resolved within
one project, so a token that reaches more than one project must pass
?projectId=<uuid> as a query parameter, whichever reference form you use. A
token that reaches only one project never needs it. A multi-project token that
omits projectId gets 400 project_required.
On success, 200, the ticket plus its attachment count:
{
"id": "<uuid>",
"title": "Checkout crashes on iOS 18",
"projectId": "<uuid>",
"boardId": "<uuid>",
"columnId": "<uuid>",
"createdAt": "<ISO 8601>",
"url": "<dashboard deep link>",
"attachmentCount": 2
}
Comment on a ticket
POST /api/v1/tickets/:ticket/comments
| Field | Type | Notes |
|---|---|---|
body | string, required | 1–20 000 characters, plain text. |
attachmentIds | uuid[] | Up to 10 ids from the upload endpoint. Belong to the comment. |
:ticket and projectId follow the same rules as for attaching files: a uuid
or number, with projectId required for a token that reaches
several projects. Unrecognised fields are rejected.
The comment is authored by the token’s name and marked as written through the
API. Files attached to a comment belong to the comment and do not count against
the ticket’s own 10 attachments.
On success, 201:
{
"id": "<comment uuid>",
"ticketId": "<uuid>",
"createdAt": "<ISO 8601>",
"url": "<dashboard deep link to the ticket>"
}
curl -X POST "https://everframe.dev/api/v1/tickets/42/comments?projectId=$PROJECT_ID" \
-H "Authorization: Bearer $EVERFRAME_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ci-run-${GITHUB_RUN_ID}-comment" \
-d "{\"body\": \"Reproduced on build 812\", \"attachmentIds\": [\"$ID\"]}"
From the CLI
The everframe CLI wraps the endpoints above, so you can file a ticket or add a
screenshot from a terminal without writing curl. It uploads each file, then
creates the ticket, attaches, or comments in one go. Run it with
npx @everframe/cli ticket ... or install @everframe/cli globally; ticket
commands need CLI 1.7.0 or later. The examples below assume the everframe
command is on your path.
Create a token with the tickets:write scope (see Get a token)
and keep it outside your repository, in your shell profile, a password manager,
or CI secrets:
export EVERFRAME_API_TOKEN=evf_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
export EVERFRAME_PROJECT_ID=<project uuid> # optional if the token reaches one project
The CLI uses the same API token as the curl examples above; it just reads it
from EVERFRAME_API_TOKEN rather than $EVERFRAME_TOKEN. EVERFRAME_API_URL
overrides the API base URL. The project comes from --project first, then the
project uuid in a ticket link, then EVERFRAME_PROJECT_ID. A dashboard link
that names the project by its slug instead of its uuid needs --project, so a
link from one project can never edit a ticket in another.
# Create a ticket, optionally with screenshots
everframe ticket create --title "Checkout crashes on iOS 18" \
--description-file notes.md --attach shot1.png shot2.png
# Attach files to an existing ticket
everframe ticket attach 42 shot1.png shot2.png
# Comment, optionally with files
everframe ticket comment 42 --body "Reproduced on build 812" --attach trace.png
<ticket> is a ticket number, a ticket uuid, or a dashboard link such as
https://everframe.dev/admin/projects/<project>/boards/<board>?card=42. Text comes
from --description or --description-file (create) and --body or
--body-file (comment); pass only one of each pair. --attach takes one or more
files and can be repeated. ticket attach takes its files as plain arguments.
Example: attach a screenshot from your Mac to ticket 42. macOS saves screenshots to the Desktop by default:
everframe ticket attach 42 ~/Desktop/"Screenshot 2026-11-04 at 10.15.00.png"
To grab a region and attach it straight away:
screencapture -i /tmp/shot.png && everframe ticket attach 42 /tmp/shot.png
On success the command prints the ticket URL, or the full response with
--json. Files follow the limits below (at most 10 per command,
25 MB each, and the ticket’s own 10-attachment cap still applies); the server
decides which types it accepts. Every command sends an Idempotency-Key (a
random one unless you pass --idempotency-key, which takes 1 to 255 printable
ASCII characters), so retrying with the same key replays the original result
instead of writing twice. A rerun still uploads its files before the replay;
the replay ignores them, and unused uploads are deleted after 24 hours.
| Exit code | Meaning |
|---|---|
0 | Done. |
1 | The API or network failed (rate limits and server errors are retried first), or no token is set. The message names the failing step and the API error code. |
2 | Bad input: unknown flag, unreadable file, too many files, missing title or body, an invalid idempotency key, an unrecognised ticket reference, or a slug link without --project. Nothing was sent. |
Idempotency
A CI job that retries after a timeout must not file the same ticket twice. Send
an Idempotency-Key header derived from something already unique to the run:
-H "Idempotency-Key: ci-run-${GITHUB_RUN_ID}"
Reusing a key replays the original result rather than writing again. The
replay returns the same status, with the ticket or comment re-read in its
current state (for example, an attach replay returns the current
attachmentCount), so do not reuse one expecting it to have
expired. This applies to creating a ticket, attaching files, and commenting;
the three are independent, so the same key can be used once for each. The key is checked before the body is validated, so a retry whose body
was mangled in transit still replays rather than failing with 400. Keys are
scoped to the token that sent them.
The guarantee also holds for concurrent retries. The key is recorded in the same transaction as the write, under a lock, so two requests carrying the same key in flight at once produce one write: the second waits for the first and then replays its result.
Errors
| Status | Error | Meaning |
|---|---|---|
400 | invalid_input | Body failed validation, including an unrecognised field. |
400 | project_required | The token reaches more than one project; name one. |
400 | invalid_date_range | startsAt is after dueAt. |
400 | not_a_member | assigneeUserId is not a member of the organisation. |
400 | invalid_agent_repository | agentRepositoryId is not bound to the project. |
400 | invalid_idempotency_key | Empty, or longer than 255 characters. |
400 | invalid_attachments | An attachmentIds entry is unknown, already claimed, in another project, or uploaded by another token. Nothing is written. |
400 | too_many_attachments | The ticket would exceed 10 attachments. Nothing is written. |
401 | invalid_api_token | Missing, malformed, unknown, or revoked. |
403 | insufficient_scope | The token lacks tickets:write; the body names it in required. |
404 | ticket_not_found | No such ticket in the resolved project (attach and comment). |
404 | project_not_found | No such project, the token cannot reach it, or projectId was omitted and the token reaches none. |
404 | board_not_found | No such board, or it belongs to another project. |
404 | column_not_found | No such column, or it belongs to another board. |
429 | — | Over 60 requests/minute for this token. |
project_not_found deliberately covers both “does not exist” and “not yours” —
the same response either way, so a token cannot be used to map an organisation
it has no access to by comparing error codes.
Limits
| Limit | Value |
|---|---|
| Rate limit, per token | 60 requests / minute |
| Upload size | 25 MB per file |
| Attachments per ticket | 10 |
| Attachments per comment | 10 |
| Uploads, per token | 60 / hour |
Versioning
Within /api/v1, fields may be added to requests and responses without
notice; existing fields will not be removed or change meaning. Do not validate
responses against a strict shape that rejects unknown fields — a client that
does will break on a purely additive change.