Conventions
Base URL, authentication, the response envelope, errors, pagination and limits.
Base URL
https://api.postal.music/api/v1All public endpoints live under /api/v1. Paths on this site are written
relative to that prefix unless shown in full.
Authentication
Send your API key in the X-API-Key header on
every request. There are no cookies and no OAuth on the REST surface.
GET /api/v1/tracks/count HTTP/1.1
Host: api.postal.music
X-API-Key: YOUR_API_KEY| Status | messageCode | Meaning |
|---|---|---|
| 401 | missing_api_key | No header sent |
| 401 | invalid_key | Unknown, revoked or malformed key |
| 401 | expired_key | Key past its expiry date |
| 403 | insufficient_scope | Key lacks the endpoint's scope |
Requests from servers and CLIs carry no Origin header and are always
accepted. Browser requests are subject to a CORS allowlist that covers Postal's
own domains only, so call the API from a backend, not from a web page.
Response envelope
Every successful response is wrapped:
{
"status": 200,
"messageCode": 200,
"message": "Operation was successful",
"data": { }
}Your payload is always under data. The outer status is 200 on every
success, including creates. Response examples elsewhere on this site show
data only.
Some endpoints report a recoverable problem inside a 200 by returning
{ "ok": false, "error": "TRACK_NOT_FOUND", … } in data rather than an
HTTP error. Check ok where the endpoint documents it.
Errors
Errors use a different shape from successes:
{
"statusCode": 400,
"messageCode": "not_enough_credits",
"message": "Not enough credits",
"error": "Bad Request"
}Key off messageCode, a stable machine-readable string. message is for
humans and can change. Some errors add a data object with structured detail.
In production, validation failures return a generic body:
{ "statusCode": 400, "messageCode": "validation_failed", "message": "Invalid request", "error": "Bad Request" }Unhandled failures return 500 with messageCode: "internal_error".
Pagination
Three styles are in use. Each endpoint page says which applies.
Cursor (/tracks, /playlists, /composers). Pass limit and, for the
next page, cursor set to the previous response's paging.next_cursor.
next_cursor is null on the last page. limit is 1–200, default 50.
{ "data": [ … ], "paging": { "limit": 50, "next_cursor": "cm1w…" } }Offset (/tracks/duplicates). Pass limit and offset.
Page (/platform/*). Pass page (from 1) and pageSize. Oversized page
sizes are clamped silently.
Rate limits
| Limit | Applies to |
|---|---|
| 100 requests per 60 seconds per IP | Every endpoint |
Exceeding it returns 429. There is no separate per-key limit. Usage per key
is logged and visible in the app under Settings → Integrations.
Credits
POST /tracks/search is the only metered endpoint. It runs the AI ranker over
your library and costs one credit per call from the key owner's balance.
Failed calls are not charged. A caller without credits gets
400 not_enough_credits. Every other endpoint is free.
Request bodies
Send JSON with Content-Type: application/json, except
POST /tracks/upload, which is
multipart/form-data. Bodies are limited to 10 MB; audio goes through the
upload endpoints, not JSON.
Timeouts
Requests time out after 60 seconds with 503 request_timeout. Upload
endpoints are exempt.
IDs and dates
IDs are opaque strings. Dates are ISO 8601 in UTC. Large integers are serialised as strings.
Versioning
The /api/v1 prefix is the contract: fields and endpoints are only added
within v1, never removed or renamed. Every response carries
X-Postal-Api-Version, GET /api/v1/version describes the version without a
key, and routes scheduled for removal announce it with standard Deprecation
and Sunset headers. See Versioning.