Skip to content

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/….

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:

Terminal window
curl https://<your-api-host>/health
# { "ok": true, "data": { "status": "ok", "service": "typillar-api" } }

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.

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.

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

Every response carries X-Request-Id. Send your own to correlate a call across your logs and Typillar’s; otherwise one is generated.

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.

Terminal window
curl -X POST https://<your-api-host>/api/v1/projects \
-H "Authorization: Bearer tyk_…" \
-H "Idempotency-Key: 3f2a-once" \
-d '{ "name": "Acme" }'

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=20

No 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.

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.

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:

Terminal window
# 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:

Terminal window
# One project, by id — you don't have to page GET /projects to find it
curl .../api/v1/projects/$PID -H "Authorization: Bearer tyk_…"
# One ticket, by id
curl .../api/v1/projects/$PID/tickets/$TID -H "Authorization: Bearer tyk_…"
# The source the agent last generated
curl .../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.

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 ONCE
DELETE /api/v1/keys/:id # revoke a key

Minting a key:

Terminal window
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.