API reference
Every endpoint, in one place.
Hand-curated from the worker source. When routes change in apps/worker/src/index.ts, update this file. The CLI and MCP server both sit on top of these endpoints — there is nothing here that is internal-only.
Base URL & authentication
Base URL: https://api.krexel.com. The marketing site is a separate Vercel deployment.
API key auth: most endpoints require a Krexel API key. Send it in the Authorization: Bearer krx_live_… header. Keys are minted via POST /api/v1/keys and the plaintext is returned exactly once.
Session auth: the dashboard uses Supabase session cookies, not API keys. From the browser you just need to be signed in — the site proxies the request to the worker with the right auth.
Errors: every error is { "error": "<code>", "message": "<human readable>" }. HTTP status carries the category: 4xx for client problems, 5xx for upstream failures. 401 = bad/expired key, 403 = wrong owner (or not admin), 404 = no such resource, 413 = deploy too large, 429 = rate limited, 503 = upstream not configured.
Rate limits: the free tier signs in at most 5 times per minute from the same network; the deploy and key endpoints are not currently throttled but will be when abuse starts. Auth failures increment a per-email counter that locks sign-in for 10 minutes after 10 failures.
Health
Liveness and readiness probes. Public — no auth.
- GET
/healthzPublicMinimal liveness check. Returns 200 with { ok, version } as long as the worker is running. No upstream checks.
- GET
/readyzPublicFull readiness probe — exercises KV, CF Pages API. Returns 200 with per-check status if all healthy, 503 if anything is broken. Powers the public /status page.
Auth & session
Signup and the calling customer. /me is the source of truth for the dashboard chrome.
- POST
/api/v1/signupPublicCreate an account. Body: { email, password?, plan? }. Returns { email, plan }. On the free tier no payment is required.
Show request & response
Request
{ "email": "you@example.com", "password": "correct-horse-battery-staple", "plan": "builder" }Response (200)
{ "email": "you@example.com", "plan": "builder", "created_at": "2026-07-14T10:00:00.000Z", "api_key_id": "krx_k_8f4a2b9d..." } - GET
/api/v1/meAPI keyReturns the calling customer: { email, plan, isAdmin, deployCount, tourDismissed, … }. Used by the dashboard to render the chrome.
- PATCH
/api/v1/meAPI keyUpdate notification prefs, dismiss the onboarding tour, or rename the account. Body: any subset of the writable fields.
- DELETE
/api/v1/meAPI keyPermanently delete the account and every associated deploy, domain, key, and team membership. Idempotent on a non-existent customer.
API keys
Mint, list, and revoke API keys. Plaintext is returned exactly once on POST.
- POST
/api/v1/keysAPI keyMint a new API key. Body: { name }. Returns the plaintext key exactly once — store it. The server retains only a bcrypt hash.
Show request & response
Request
{ "name": "Claude Code (macbook)" }Response (200)
{ "id": "krx_k_8f4a2b9d1c6e7f02", "name": "Claude Code (macbook)", "key": "krx_sk_live_3aF8sL2bQ9kM0vW5zE7uX1c4dR8tY6pH4jK9mN2qT3sV8wA5zD2hF7g", "created_at": "2026-07-14T10:00:00.000Z" } - GET
/api/v1/keysAPI keyList every key the caller owns. Returns id, name, created_at, last_used_at — never the plaintext or the hash.
- DELETE
/api/v1/keys/:idAPI keyRevoke a key. The deleted key fails on the next request with 401. Idempotent on an already-revoked id.
Deploys
Ship, patch, inspect, rollback. Patches weigh 0.1 against the quota; full deploys weigh 1.0.
- POST
/api/v1/deployAPI keyShip a new site. Multipart: `manifest` (JSON) + `file` (tar of the site) OR multiple `files` parts with paths. Quota-checked before parsing. Returns 201 with { deploy_id, preview_url, status: 'queued' }. The actual build runs asynchronously — poll GET /api/v1/deploys/:id or stream /api/v1/deploys/:id/stream.
- POST
/api/v1/deploy/patchAPI keyApply a list of patch ops to an existing site: {create | replace | replace_all | delete}. Body: { parent_deploy_id, ops: [{ op, path, content_base64? }] }. Patches weigh 0.1 against the monthly quota; full deploys weigh 1.0.
- GET
/api/v1/deploysAPI keyList every deploy owned by the caller, newest first. Returns DeployRecord[] (see GET /:id for shape).
- GET
/api/v1/deploys/:idAPI keyRead a single deploy record. 404 if no such id, 403 if it belongs to a different customer. The response is the full DeployRecord: status, preview_url, logs, cf_deployment_id, custom_domain, parent_deploy_id, …
Show response body
Response (200)
{ "id": "krx_d_a3f1b8c92e4d", "email": "you@example.com", "domain": "my-site.pages.dev", "project_name": "my-site", "status": "ready", "framework": "next", "preview_url": "https://my-site.pages.dev", "cf_deployment_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "custom_domain": null, "parent_deploy_id": null, "created_at": "2026-07-14T10:00:00.000Z", "ready_at": "2026-07-14T10:00:34.000Z", "duration_ms": 34000, "logs": [] } - GET
/api/v1/deploys/:id/streamAPI keySSE stream of deploy status transitions. The server polls the KV record every ~1s and emits an `event: status` with the latest snapshot. Closes on terminal status (ready | error). Useful for CLI live tail.
- GET
/api/v1/deploys/:id/filesAPI keyReturn the full file tree + per-file contents for a deploy. Used by `krexel rollback` to materialize the previous version. Body is large for non-trivial sites — prefer GET /:id/versions + targeted reads.
- GET
/api/v1/deploys/:id/logsAPI keyReturn the persisted `logs` field for a deploy. Idempotent — the build log never changes after a deploy reaches a terminal status.
- GET
/api/v1/deploys/:id/versionsAPI keyWalk the parent_deploy_id chain for a file, returning the list of deploys that touched it. Used by `krexel history <path>`.
- POST
/api/v1/deploys/:id/rollbackAPI keyCreate a NEW deploy that ships the same file tree as the target. The new deploy gets a fresh deploy_id and a `parent_deploy_id` pointing at the source. Atomic — the original deploy is never modified.
- POST
/api/v1/deploys/:id/domainAPI keyAttach a custom domain to a specific deploy. Body: { domain }. Returns the domain record. The customer's registrar must point a CNAME at the platform Pages project before SSL will provision.
- POST
/api/v1/deploys/:id/domain/refreshAPI keyRe-poll Cloudflare for the SSL status of the domain attached to this deploy. Triggers the 'your domain is live' email exactly once when the status transitions pending → active.
Usage
Per-customer quota summary for the current calendar month.
- GET
/api/v1/usageAPI keyPer-customer usage breakdown for the current calendar month: { deploys_this_month, deploys_patch, deploy_quota, over_deploy_quota }. Patch deploys weigh 0.1; full deploys weigh 1.0.
Custom domains
Attach a custom domain to a deploy or to the platform Pages project. SSL is automatic.
- POST
/api/v1/deploys/:id/domainAPI keyAttach a custom domain to a specific deploy. Body: { domain }. Returns the domain record. The customer's registrar must point a CNAME at the platform Pages project before SSL will provision.
- POST
/api/v1/deploys/:id/domain/refreshAPI keyRe-poll Cloudflare for the SSL status of the domain attached to this deploy. Triggers the 'your domain is live' email exactly once when the status transitions pending → active.
- GET
/api/v1/domainsAPI keyList every custom domain attached to the platform Pages project for the calling customer. Includes live status (pending | active | error).
- POST
/api/v1/domains/attachAPI keyAttach a domain without binding it to a specific deploy (the platform decides routing). Body: { domain }. Useful when the same domain should follow the customer's latest deploy automatically.
Show request & response
Request
{ "domain": "alex.dev" }Response (200)
{ "domain": "alex.dev", "status": "pending", "cname_target": "krexel-prod.pages.dev", "verification_token": "krexel-verify-a3f1b8c92e4d", "attached_at": "2026-07-14T10:00:00.000Z" } - POST
/api/v1/domains/:domain/refreshAPI keyRe-poll Cloudflare for the SSL status of a platform-attached domain. Same as /deploys/:id/domain/refresh but addressed by the domain itself.
- DELETE
/api/v1/domains/:domainAPI keyDetach a domain from the platform Pages project. The customer's CNAME record is not touched — they decide whether to remove it.
Billing
Stripe Checkout, plan changes, webhooks, and the usage meter.
- POST
/api/v1/billing/checkoutAPI keyStart a Stripe Checkout flow for a plan upgrade. Body: { plan: 'builder' | 'pro' | 'studio' }. Returns { url } — the customer is redirected there.
- POST
/api/v1/billing/webhookStripe webhookStripe webhook receiver. Authenticated via the Stripe-Signature header, not a Krexel API key. Verifies signature, then updates the customer's plan in KV.
- POST
/api/v1/billing/change-planAPI keyChange plan without going through Checkout (for in-app switches between paid tiers). Body: { plan }. Rejects paid plans unless BILLING_ENABLED is set on the worker.
- GET
/api/v1/billing/usageAPI keyPer-customer usage + quota summary for the current calendar month. The dashboard's 'X / Y deploys used' meter reads from this.
Team
Workspace members and pending invites. Studio plan only.
- GET
/api/v1/teamAPI keyList active members + pending invites for the caller's workspace. Returns { members: [...], invites: [...] }. Only available on the Studio plan.
- POST
/api/v1/team/inviteAPI keyCreate a pending invite + send a one-time accept email. Body: { email, role: 'admin' | 'member' }. Returns the invite record. Expires in 7 days.
- POST
/api/v1/team/acceptPublicAccept a workspace invite. Body: { token }. The token is single-use; the invitee must already have a Krexel account (or signup happens in the same flow).
- DELETE
/api/v1/team/invites/:idAPI keyRevoke a pending invite. The accept URL becomes invalid. Idempotent on an already-revoked id.
- DELETE
/api/v1/team/members/:idAPI keyRemove a member from the workspace. The removed customer keeps their own account but loses access to the shared deploys/domains. The owner cannot be removed.
Webhooks
Customer-owned subscriptions. The worker POSTs to a registered URL on every deploy status transition, signed with HMAC-SHA256.
- GET
/api/v1/webhooksAPI keyList every webhook subscription owned by the caller. The full secret is never returned — only a `secret_hint` with the last 4 characters. Plan-gated quota (see Webhooks section below).
- POST
/api/v1/webhooksAPI keyCreate a subscription. Body: { url, events? } — `url` must be HTTPS and pass the SSRF guard; `events` defaults to ["deploy.*"], the only pattern supported today (plus the catch-all "*"). Returns 201 with the full WebhookRecord including the plaintext `secret` exactly once — store it, it cannot be retrieved later. 402 when the plan's quota is exhausted, 400 when the URL is invalid or in a reserved range.
Show request & response
Request
{ "url": "https://hooks.example.com/krexel", "events": ["deploy.ready", "deploy.error"] }Response (200)
{ "id": "krx_wh_b7c1e9d4f8a2", "email": "you@example.com", "url": "https://hooks.example.com/krexel", "events": ["deploy.ready", "deploy.error"], "secret": "whsec_3aF8sL2bQ9kM0vW5zE7uX1c4dR8tY6pH4", "secret_hint": "...6pH4", "created_at": "2026-07-14T10:00:00.000Z", "consecutive_failures": 0, "disabled": false } - DELETE
/api/v1/webhooks/:idAPI keyRemove a subscription and stop all future deliveries to it. Idempotent on an already-deleted id. Does NOT revoke in-flight retries that are already queued.
- POST
/api/v1/webhooks/:id/rotate-secretAPI keyGenerate a new signing secret for the subscription. Returns the full new secret exactly once. The old secret stops working immediately. Also resets `consecutive_failures` to 0 — a common recovery path after the old secret leaked.
Show response body
Response (200)
{ "id": "krx_wh_b7c1e9d4f8a2", "email": "you@example.com", "url": "https://hooks.example.com/krexel", "events": ["deploy.ready", "deploy.error"], "secret": "whsec_k1E9sT3bV7qM0wX4zA2hC6dF8tY5pJ2r", "secret_hint": "...5pJ2r", "created_at": "2026-07-14T10:00:00.000Z", "rotated_at": "2026-07-14T11:30:00.000Z", "consecutive_failures": 0, "disabled": false } - GET
/api/v1/webhooks/:id/deliveriesAPI keyRead the last 50 delivery attempts for a subscription. Each entry has { attempted_at, event, deploy_id, response_status, duration_ms, error?, attempt }. Records expire after 7 days. The Test endpoint does NOT write here — it returns its result inline.
- POST
/api/v1/webhooks/:id/testAPI keySend a synthetic `deploy.test` event to the subscription's URL with the same HMAC-SHA256 signature format as a real delivery. Single attempt, 5s timeout, no retry, does NOT increment the failure counter. Returns { ok, status, duration_ms, event: "deploy.test", timestamp } so the dashboard can show "✓ 200 in 342ms" inline. 409 if the subscription is disabled.
Admin
Internal — requires is_admin: true on the customer record. Every action writes to the audit log.
- GET
/api/v1/admin/meAdmin onlyConfirm admin status + list the admin's permissions. Safe to call from the dashboard sidebar on every page to show/hide the /admin nav. Requires is_admin: true on the customer record.
- GET
/api/v1/admin/customers/:emailAdmin onlyRead-only view of any customer record. Returns plan, created_at, notification_prefs, team member count, is_admin, etc. Never returns the API key hash or the encrypted CF token.
- GET
/api/v1/admin/deploys/:emailAdmin onlyList every deploy for any customer. Used when a customer says 'I lost my site' and you need to find the last good deploy to rollback.
- GET
/api/v1/admin/audit-logAdmin onlyRead the audit log (last 50 admin actions, newest first). Every admin action writes a log entry: { ts, admin_email, permission, target, details? }. Entries are kept for 1 year in KV.
- POST
/api/v1/admin/audit-logAdmin onlyRecord a manual admin action (e.g. 'refunded $25 via Stripe dashboard'). Body: { permission, target, note }. The entry appears in the next GET. Useful for things that don't have a structured endpoint.
Cron
Internal jobs. Auth via the KREXEL_CRON_SECRET worker secret.
- POST
/api/v1/cron/retentionCron secretDaily retention sweep. Deletes R2 file trees older than the configured threshold (default 90d R2 Standard + 90d-1yr IA → 1yr+ deleted). Auth via KREXEL_CRON_SECRET. Returns { deleted, kept }.
Webhook events
Subscribe to deploy.* events today — deploy.ready when a deploy finishes successfully and deploy.failed when it hits a terminal error. The deploy.test event is fired only by the Test endpoint, never by a real deploy.
Signed payload
Every delivery carries a Stripe-compatible HMAC-SHA256 signature:
Krexel-Signature: t=<unix_seconds>,v1=<hex_hmac_sha256>
The signed string is <unix>.<rawBody> — same format Stripe uses. To verify on the customer side:
- Split the header on
,and readt=andv1=. - Compute HMAC-SHA256(secret,
${t}.${rawBody}) and constant-time-compare tov1. - Reject if
now - t > 5 minutes— that is the 5-minute replay window.
The Test endpoint signs the synthetic payload with the same key, so your verifier code is identical for real deliveries and for tests.
Retry, timeout, and auto-disable
- Real deliveries make up to 3 attempts: immediate, +5s, +30s. Each attempt has a 5s timeout. Any 2xx response (on any attempt) is treated as success and resets the failure counter.
- Test endpoint is single-attempt. No retry chain, no exponential backoff, and it does NOT increment the failure counter — a failed test cannot disable a healthy webhook.
- Auto-disable: 10 consecutive failed real deliveries flip
disabled: trueon the subscription and the worker emails the customer (idempotent viadisabled_notified_at). Rotate the secret and re-enable from the dashboard to recover.
SSRF guard
Subscription URLs are validated on create/update. The guard rejects:
- Non-HTTPS URLs.
- Hostnames:
localhost,*.localhost,*.local,*.internal. - IPv4 RFC1918 (10/8, 172.16/12, 192.168/16), loopback (127/8), link-local / AWS metadata (169.254/16), 0/8.
- IPv6 loopback (::1), ULA (fc00::/7), link-local (fe80::/10), and IPv4-mapped (::ffff:0:0/96) — the last blocks an IPv6 bypass of the IPv4 check.
Plan quota
The number of subscriptions a customer can own is hardcoded in worker/src/lib/plans.ts:
- Free: 0 (no webhooks — upgrade to subscribe).
- Starter: 1.
- Builder: 3.
- Pro: 5.
- Studio: unlimited (-1).
Exceeding the quota returns 402 with { error: "quota_exceeded", limit, current }.