API projects, keys and usage, v1

GET · POST · PATCH · DELETE https://api.trailsplits.com/v1

Key optional · 500 credits a day without one

Create projects, issue and rotate keys, and read usage. Sign-in uses your TrailSplits account; the API console is built on these calls.

Contract

Objects

Project

{
  "id": "prj_…",
  "name": "Hut finder",
  "status": "active",
  "plan": { "id": "free", "name": "Free", "monthly_credits": 30000, "burst_per_second": 5, "commercial_use": true },
  "created_at": "2026-10-02T12:00:00Z",
  "closed_at": null
}
  • status is active or closed. Closing revokes every key at once. A closed project stays listed until the end of the month, so its usage can still be read.
  • plan.commercial_use is true on every plan since A4 (2 Oct 2026, migration 50085; owner decisions D1 and D4: commercial use with attribution). The console shows it as it comes; it never computes it.
  • id is opaque. The console never builds one or parses one.

Key (listing)

{ "key_id": "key_…", "prefix": "ts_live_", "last4": "9fQx", "label": "server", "kind": "secret", "origins": [], "status": "active", "created_at": "…", "revoked_at": null, "last_used_on": "2026-10-02" }

After it is issued, a key's secret is never returned again: listings carry only prefix and last4. last_used_on is a UTC day, or null. It is never a timestamp, because a timestamp would let someone trace calls.

Issued key (returned once)

{ "key": "ts_live_<43 base64url chars>", "key_id": "key_…", "prefix": "ts_live_", "last4": "9fQx", "label": "server", "created_at": "…" }
  • Staging keys start with ts_test_.
  • The console keeps the secret in page memory only: never in storage, a URL or telemetry. It drops the secret when the user changes or signs out, or the page unloads.

Usage

{
  "project": "prj_…",
  "plan": { "id": "free", "name": "Free", "monthly_credits": 30000, "burst_per_second": 5 },
  "period": { "start": "2026-10-01T00:00:00Z", "end": "2026-11-01T00:00:00Z" },
  "meters": {
    "credits": { "used": 1234, "limit": 30000, "remaining": 28766 },
    "tiles": null
  },
  "by_operation": [ { "operation": "route", "calls": 410, "credits": 820, "refunded": 3 } ],
  "daily": [ { "date": "2026-10-02", "calls": 52, "credits": 97 } ],
  "as_of": "2026-10-02T12:34:56Z"
}
  • period.end is exclusive, in UTC. Nothing rolls over into the next period.
  • meters.tiles is null in phase 1, because tiles are not metered yet. From phase 2 it has the same shape as credits.
  • daily covers the last 30 UTC days, oldest first. Days without calls may be left out.
  • Usage never contains coordinates, IP addresses or request bodies.

Owner endpoints (JWT)

Method and pathBodyAnswer
GET /v1/projectsnone200 {projects: Project[], limits: {max_projects, max_keys_per_project}}
POST /v1/projects{name} (1–60 characters, trimmed)201 Project
GET /v1/projects/{id}none200 Project
PATCH /v1/projects/{id}{name}200 Project
POST /v1/projects/{id}/closenone200 Project (status: "closed"). Idempotent
GET /v1/projects/{id}/keysnone200 {keys: Key[]}
POST /v1/projects/{id}/keys/{key_id}/rotatenone201 IssuedKey. The old key is revoked: at once on the gateway that served the call, and everywhere within 60 s
DELETE /v1/projects/{id}/keys/{key_id}none200 Key (status: "revoked"). Idempotent; same 60 s rule
PATCH /v1/projects/{id}/keys/{key_id}{origins}200 Key. Publishable keys only (a secret key: 400). Every gateway applies the new list within 30 s (A10)
GET /v1/usage?project={id}none200 Usage (JWT or key, see point 3)
POST /v1/need-more{plan, message?, project?}: plan is starter, pro, business or custom; message up to 1,000 characters201 {received: true, plan, received_at}. JWT only (a key gets 403 scope_denied). At most 5 a day per account: 429 rate_limit with limit: "need_more_per_day". Added A9, 2 Oct: demand evidence without checkout. Support@ answers; the console says so
  • A POST body is JSON (Content-Type: application/json) of at most 1 KB. Unknown fields are refused with 400.
  • POST /v1/projects accepts an Idempotency-Key, so a double click creates only one project.
  • Isolation. A project that does not exist and a project owned by someone else both answer 404 not_found. A caller cannot tell whether a project id exists.

Publishable (browser) keys (A10, 2 Oct). kind: "publishable" keys (ts_pub_…) may sit in a web page. The gateway accepts one only when the request's Origin (or, for images, the Referer's origin) is in its origins; otherwise 403 origin_not_allowed. An Origin can be forged outside a browser, so each visitor address also gets at most 2 requests per second (burst 20) per project with a publishable key, on top of the plan rate: 429 rate_limit with limit: "publishable_visitor". Use a secret key on servers.

Errors

Every error body has this shape:

{ "error": "monthly_credits_exhausted", "status": 429, "message": "This project has used its 30,000 credits for October.", "limit": "monthly_credits", "retry_after": 1234567 }
  • message is safe to show to the user as it is.
  • limit and retry_after appear only when they apply. retry_after is in seconds and equals the Retry-After header.
StatuserrorWhen
400invalid_requestMalformed body, unknown field, bad name or label
401unauthorizedNo token or key, or an expired JWT. The console refreshes the session once, then asks the user to sign in
401credential_invalidUnknown, revoked or rotated API key
403scope_deniedAn API key used on an owner endpoint (keys cannot manage projects)
404not_foundUnknown project or key, or one owned by someone else
409project_limit, key_limitPlan limit reached; limit names it
409project_closedWriting to a closed project
429rate_limitBurst limit; Retry-After set
429monthly_credits_exhaustedAllowance used up; Retry-After = seconds to period.end

Not in v1

  • Browser (origin-bound) keys.
  • Team members.
  • Checkout and plan changes (A5).
  • Tile meters (A6).
  • Key expiry dates.