API
Everything the console does to a project, it does over a plain HTTP API — and so can you. Agents, CI jobs, and scripts drive projects through the same endpoints the browser uses. This is the reference for that API.
Two things sit outside it, on purpose: a handful of session-only
surfaces, and signing in and organization membership, which Better
Auth serves at /api/auth/….
Base URL & versioning
Section titled “Base URL & versioning”https://<your-api-host>/api/v1/api/v1 is the stable contract. The older unversioned /api/… prefix still
resolves as a back-compat alias, but new integrations should use /api/v1.
The health check lives outside the versioned API, and needs no auth:
curl https://<your-api-host>/health# { "ok": true, "data": { "status": "ok", "service": "typillar-api" } }Authentication
Section titled “Authentication”Requests authenticate one of two ways:
-
Session cookie — the browser console. Signed in via Better Auth; carries every scope implicitly.
-
API key — the programmatic path. Send it as a bearer token:
Terminal window curl https://<your-api-host>/api/v1/projects \-H "Authorization: Bearer tyk_xxxxxxxxxxxxxxxxxxxx"
Keys are tyk_-prefixed, shown once at creation, and stored only as a
SHA-256 hash — Typillar can’t recover a lost key, only replace it.
Scopes
Section titled “Scopes”A key carries a set of scopes; the request is rejected (403 forbidden_scope)
if it lacks the one an endpoint needs.
| Scope | Grants |
|---|---|
projects:read |
read projects, tickets, generated source and file tree, history, deployments, analytics, errors, activity and traffic; read capability settings and their live data — records, app users, teams, orders, entitlements, spend; list webhook endpoints and ingest sources |
projects:write |
create projects and change their settings; turn capabilities on and configure them; write the app’s live data — records, app-user roles, assets, Stripe products and discount codes; run an AI job against its budget; delete a webhook endpoint or ingest source |
tickets:write |
create/edit tickets; post to the plan thread |
build:write |
run builds and previews; revert; back up |
deploy:write |
opt-in — ship, and anything that provisions or destroys real infrastructure: deploy, roll back, reconcile, archive or delete a project, attach a domain, onboard email |
A new key gets every scope except deploy:write by default. deploy:write
is opt-in for a reason: deploying provisions real infrastructure in your own
Cloudflare account and spends your money, and the same scope tears that
infrastructure down again. Creating a project, by contrast, provisions nothing
(just a control row) — so it needs only projects:write.
Read that boundary as destruction and going live, not first touch. projects:write
is in the default set and still reaches things that outlive a mistake — including
creating infrastructure: turning a capability on makes its database, bucket or index
in your Cloudflare account immediately, so that the agent finds out at once if your
account cannot make it. Nothing under projects:write deletes one; that, and going
live, is what deploy:write guards. It also edits and
deletes rows in your app’s live database, changes an app user’s role, creates
products and discount codes in your own Stripe account, and runs AI jobs against
the monthly budget you set. For anything that only needs to look, mint a
projects:read key.
Some surfaces are session-only, and no set of scopes reaches them: API keys
(/keys), granting a waiting CLI sign-in (/device), connections
(/connections), billing (/billing), support threads (/help), single sign-on
(/sso), the sign-in policy (/security), and deleting the account itself
(/account). So are the two routes that mint a signing secret — POST /webhooks
and POST /ingest.
The rule is that a key can never mint another credential, whatever its scopes.
A tyk_ key and a whsec_ signing secret are both credentials; a key that could
create one could escalate itself out of its own scopes. Do those from the console,
or with a session-cookie request.
Everything else on those two surfaces is reachable with a key. Listing webhook
endpoints or ingest sources needs projects:read and never returns the secret;
deleting one needs projects:write.
Signing in, password resets, and organization membership — invite, remove,
accept — are not part of this API at all. Better Auth serves them at
/api/auth/…, which takes a session cookie and never a tyk_ key.
Response envelope
Section titled “Response envelope”Two endpoints answer a file rather than the envelope, so the result can be saved
or piped straight into a pipeline: GET /activity/export answers text/csv, and
GET /account/export answers application/x-ndjson. Everything else on the API
answers the envelope, including its errors.
Every response is one of:
{ "ok": true, "data": { … } }{ "ok": false, "error": { "code": "…", "message": "…" } }Branch on error.code (stable, machine-readable); show error.message to
humans. The codes and their HTTP statuses:
code |
HTTP | Meaning |
|---|---|---|
invalid_request |
400 | bad or missing input |
unauthorized |
401 | no/invalid credentials |
forbidden |
403 | authenticated but not allowed (e.g. session-only route) |
forbidden_scope |
403 | API key lacks the required scope |
not_found |
404 | no such resource |
not_enabled |
404 | a capability isn’t turned on / deployed yet |
payment_required |
402 | your plan doesn’t allow this — upgrade |
conflict |
409 | state conflict (e.g. a build is already running) |
model_required |
412 | no model is connected; the agent can’t plan or build without one |
two_factor_required |
403 | your organization requires two-factor and this account has not set it up; nothing but the enrolment routes opens until it does |
ip_not_allowed |
403 | your organization only accepts requests from an allowed address, and this one is not on the list; it applies to API keys as well as browser sessions |
attachments_unsupported |
412 | the message carries an attachment the selected model can’t read — the reply names the modality (images, or PDFs); pick a model marked for it. |
rate_limited |
429 | too many requests |
model_error |
502 | your model failed — retryable |
upstream_error |
502 | a call into your Cloudflare account failed |
unavailable |
503 | temporarily paused (e.g. builds are disabled) — retryable |
server_error |
500 | unexpected server error |
Request IDs
Section titled “Request IDs”Every response carries X-Request-Id. Send your own to correlate a call across
your logs and Typillar’s; otherwise one is generated.
Idempotency
Section titled “Idempotency”POST /projects and POST /projects/:id/tickets/capture accept an Idempotency-Key
header. A retry with the same key returns the original result (and
Idempotency-Replayed: true) instead of creating a duplicate. Keys are
remembered for 24 hours, per user. Only successful (2xx) responses are
replayed — a retry after a failure runs for real.
A key may be at most 255 bytes; a longer one is a 400 invalid_request. A UUID
is the usual choice.
Other POST creates don’t take the header yet; retrying them creates a
duplicate.
curl -X POST https://<your-api-host>/api/v1/projects \ -H "Authorization: Bearer tyk_…" \ -H "Idempotency-Key: 3f2a-once" \ -d '{ "name": "Acme" }'Pagination
Section titled “Pagination”GET /api/v1/projects is keyset-paginated:
GET /api/v1/projects?limit=50&cursor=<nextCursor>The response wraps the page:
{ "ok": true, "data": { "items": [ … ], "nextCursor": "1719800000000:prj_01_a1b2" } }Pass nextCursor back as cursor for the next page; null means the last page.
Treat it as opaque and send it back unchanged. It carries a timestamp and the
id of the last row, because a timestamp alone cannot separate two rows written in
the same millisecond — and an audit log writes several at once routinely.
limit is 1–100 (default 50), and a value outside that range is a
400 invalid_request rather than a silent clamp. Keep following nextCursor
until it comes back null — a single request is a page, not the whole
collection.
It also takes q, a case-insensitive substring match on the project name. %
and _ are matched literally.
GET /api/v1/projects?q=checkout&limit=20No other endpoint is paginated — each returns its whole collection in one
response, with no items/nextCursor wrapper. Most put a bare array in data:
{ "ok": true, "data": [ … ] }GET /api/v1/projects/:id/deployments is the exception in shape: data is an
object whose managed array holds the project’s resources.
Rate limits
Section titled “Rate limits”Both auth modes are limited: API keys at a default 300 requests/minute per
key, browser sessions at 600 requests/minute per user. A limited request
answers 429 rate_limited and carries Retry-After (seconds until you may
retry). That is the only rate-limit header — a successful response tells you
nothing about your remaining budget, so pace yourself rather than reading it
back.
There is also a ceiling on the calling address, well above the per-key limit and applied before we know who you are. One key inside its own budget will never reach it; several keys hammering from one host can. If you are running work in parallel from a single machine, spread it over time rather than over keys.
The two export endpoints are capped separately and much harder: about 3 a
minute per organization, shared between GET /account/export and
GET /activity/export. One of those calls walks every page of every collection
in the account, so it can cost a thousand times what an ordinary read costs, and
the per-user limit above was never sized for that. A scheduled job pulling the
audit log hourly never approaches it; a loop does. Past the cap both answer
429 rate_limited with Retry-After.
Read that about literally. This cap is a counter held in the colo you reached,
and it is permissive by design — a fourth call inside the minute often lands, and
two callers in two regions do not share a count. It is sized to stop a runaway
loop, not to meter your usage. Do not build a client that depends on the exact
number; follow Retry-After instead.
Building and previewing are free of charge, but each build spends real compute,
so builds are additionally capped per organization by plan — Free 100/hour,
Starter 300, Pro 1000, Enterprise unlimited. Past the cap, POST …/build and
…/build/stream return 429 rate_limited, the message carrying the retry
window.
Core endpoints
Section titled “Core endpoints”This page covers the contract. Endpoints lists every route the API serves with the scope it needs — a test keeps that page and the router in step.
The build loop — idea → ticket → build → ship:
# Create a project (provisions nothing; projects:write)curl -X POST .../api/v1/projects -H "Authorization: Bearer tyk_…" \ -d '{ "name": "Acme" }'
# Capture a ticket from a plain sentence (tickets:write)curl -X POST .../api/v1/projects/$PID/tickets/capture -H "Authorization: Bearer tyk_…" \ -d '{ "text": "add recurring invoices" }'
# List tickets (projects:read)curl .../api/v1/projects/$PID/tickets -H "Authorization: Bearer tyk_…"
# Build it — the agent runs on your model (build:write)curl -X POST .../api/v1/projects/$PID/tickets/$TID/build -H "Authorization: Bearer tyk_…"
# Ship it — provisions into YOUR Cloudflare account (deploy:write, opt-in)curl -X POST .../api/v1/projects/$PID/deploy -H "Authorization: Bearer tyk_…"Ship is project-level: it takes no ticket. It ships the project’s built tree
as it stands, whatever the ticket that last touched it. Build a ticket first —
shipping with nothing built answers 400.
Reading back is a first-class path, not something you reconstruct from the list
endpoints. All three need only projects:read:
# One project, by id — you don't have to page GET /projects to find itcurl .../api/v1/projects/$PID -H "Authorization: Bearer tyk_…"
# One ticket, by idcurl .../api/v1/projects/$PID/tickets/$TID -H "Authorization: Bearer tyk_…"
# The source the agent last generatedcurl .../api/v1/projects/$PID/source -H "Authorization: Bearer tyk_…"# → { "ok": true, "data": { "files": { "src/App.svelte": "…" }, "generatedBy": "…" } }data is null on /source when nothing has been built yet — that is a 200,
not a 404. Reading the code never needs build:write.
Managing API keys
Section titled “Managing API keys”Key management is session-only (do it from the console, or a session-cookie request):
GET /api/v1/keys # list the organization's keys (redacted — never the secret)POST /api/v1/keys # mint a key → returns the plaintext ONCEDELETE /api/v1/keys/:id # revoke a keyMinting a key:
curl -X POST .../api/v1/keys \ --cookie "…session…" \ -d '{ "name": "ci-bot", "scopes": ["projects:read", "projects:write"], "expiresInDays": 90 }'# → { "ok": true, "data": { "id": "key_…", "prefix": "tyk_00_a1b2",# "key": "tyk_…full…", "scopes": [ … ] } }Omit scopes to get the safe default (everything except deploy:write). Store
the key immediately — it is never shown again.