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
}
-
statusisactiveorclosed. 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_useistrueon 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. -
idis 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.endis exclusive, in UTC. Nothing rolls over into the next period. -
meters.tilesisnullin phase 1, because tiles are not metered yet. From phase 2 it has the same shape ascredits. -
dailycovers 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 path | Body | Answer |
|---|---|---|
GET /v1/projects | none | 200 {projects: Project[], limits: {max_projects, max_keys_per_project}} |
POST /v1/projects | {name} (1–60 characters, trimmed) | 201 Project |
GET /v1/projects/{id} | none | 200 Project |
PATCH /v1/projects/{id} | {name} | 200 Project |
POST /v1/projects/{id}/close | none | 200 Project (status: "closed"). Idempotent |
GET /v1/projects/{id}/keys | none | 200 {keys: Key[]} |
POST /v1/projects/{id}/keys/{key_id}/rotate | none | 201 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} | none | 200 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} | none | 200 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 characters | 201 {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/projectsaccepts anIdempotency-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 }
-
messageis safe to show to the user as it is. -
limitandretry_afterappear only when they apply.retry_afteris in seconds and equals theRetry-Afterheader.
| Status | error | When |
|---|---|---|
| 400 | invalid_request | Malformed body, unknown field, bad name or label |
| 401 | unauthorized | No token or key, or an expired JWT. The console refreshes the session once, then asks the user to sign in |
| 401 | credential_invalid | Unknown, revoked or rotated API key |
| 403 | scope_denied | An API key used on an owner endpoint (keys cannot manage projects) |
| 404 | not_found | Unknown project or key, or one owned by someone else |
| 409 | project_limit, key_limit | Plan limit reached; limit names it |
| 409 | project_closed | Writing to a closed project |
| 429 | rate_limit | Burst limit; Retry-After set |
| 429 | monthly_credits_exhausted | Allowance 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.