# Changelog (/changelog) The two surfaces are versioned differently: the REST API by its URL prefix (see [API versioning](/api/versioning)), the MCP server by a semantic version (see [MCP versioning](/mcp/versioning)). Both are listed here, newest first. Deprecations are announced here before the headers start appearing. ## REST API v1 [#rest-api-v1] The base URL is `https://api.postal.music/api/v1`. No route in v1 is currently deprecated; `GET /api/v1/version` is the live list. ### 2026-09-07 [#2026-09-07] * New [track versions](/api/track-versions) routes under `/tracks/{trackId}/versions`: list, get, add from file or URL, activate, rename/relabel/hide, delete. `GET /tracks/single` gains `activeVersionId` and a `versions` summary. * Every `/api/v1` response now carries `X-Postal-Api-Version`. * New `GET /version` (no key required) reporting the version, the newest version available, the build and any deprecated routes. * Deprecated routes, when there are any, answer with `Deprecation`, `Sunset` and `Link` headers per RFC 9745 and RFC 8594. * API key management moved in the app from Tools to **Settings → Integrations**. ### 2026-08-21 [#2026-08-21] * New `POST /tracks/search`: natural-language search with constraints, seed and exclude lists. Uses the existing `read:tracks` scope; costs one credit per call. ### 2026-08-13 [#2026-08-13] * New Platform API under `/platform` for partner reporting on file requests and sign-ups. Platform keys are issued by Postal, not self-service. ### 2026-06-12 [#2026-06-12] * New `/analytics/dashboard`, `/analytics/stats` and `/analytics/top` (scope `read:analytics`). * New `/briefs` and `/briefs/:id/submissions` (scope `read:briefs`). * New `/tracks/duplicates` and `/tracks/submissions` (scope `read:tracks`). * Keys created with an explicit scope list need the two new scopes added to use the new routes; keys created without a scope list have them already. ### 2026-03 [#2026-03] * v1 launched: tracks, tagging, playlists, composers, taxonomy, cover art, scoped API keys and signed webhooks. ## MCP server [#mcp-server] Deployed at `https://mcp.postal.music/mcp`; the running version is at [`/health`](https://mcp.postal.music/health). ### 1.6.0 — 2026-09-07 [#160--2026-09-07] * Five track-version tools: `list_track_versions`, `upload_track_version`, `activate_track_version`, `update_track_version`, `delete_track_version`. 39 tools. ### 1.5.0 — 2026-09-07 [#150--2026-09-07] * `/health` and `/tools` report `apiVersion`; `/tools` also carries `version`. * The server sends `instructions` on `initialize` naming its version and the stability guarantees. * Deprecated-tool convention introduced (a `[Deprecated — removed in …]` description prefix). No tool is deprecated today. * 34 tools, unchanged from 1.4.1. ### 1.4.1 — 2026-09-02 [#141--2026-09-02] * Moved to Cloudflare Workers with stateless Streamable HTTP. * Compact track objects include the `era`, `character` and `movement` tag groups. ### 1.4.0 — 2026-09-01 [#140--2026-09-01] * Production release of the 1.2.0 and 1.3.0 changes below. * Six read tools accept `detail: "full"`; mutations return `{ ok: true }` confirmations. ### 1.3.0 — 2026-08-21 [#130--2026-08-21] * New `search_tracks`. ### 1.2.0 — 2026-06-12 [#120--2026-06-12] * Compact responses by default with a `detail` parameter. * New `get_dashboard`, `get_entity_stats`, `get_top_tracks`, `find_duplicate_tracks`, `get_track_submissions`, `list_briefs` and `get_brief_submissions`. ### 1.1.0 — 2026-06-10 [#110--2026-06-10] * Public `GET /tools` catalog. * Direct `X-API-Key` authentication alongside OAuth. ### 1.0.0 — 2026-03-30 [#100--2026-03-30] * First release: tracks, tagging, playlists, taxonomy, artwork and composer tools over Streamable HTTP with OAuth 2.1. Changes to either surface are also announced on [postal.music/release-notes](https://postal.music/release-notes). # Overview (/) Postal is where artists, producers, publishers and labels keep their unreleased music: organised, tagged, shared and pitched. These docs cover the two ways to work with a Postal library from outside the app. JSON over HTTPS at `api.postal.music`, authenticated with an API key. Tracks, tags, playlists, composers, analytics, briefs and webhooks. A hosted Model Context Protocol server at `mcp.postal.music`. Connect Claude, Cursor or any MCP client to your library in two minutes. ## Pick a path [#pick-a-path] | You want to… | Start with | | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | Ask an assistant about your catalogue, tag tracks, build playlists in conversation | [Connect the MCP server](/mcp/connect) | | Sync tracks and metadata between Postal and your own system | [Quickstart](/getting-started/quickstart), then [Tracks](/api/tracks) | | Find music for a brief from code | [Natural-language search](/api/search) | | React to changes in a library | [Webhooks](/api/webhooks) | | Report on a partner platform's file requests and sign-ups | [Platform API](/api/platform) | ## Base URLs [#base-urls] | Surface | URL | | -------------------------------- | -------------------------------------------- | | REST API | `https://api.postal.music/api/v1` | | MCP server | `https://mcp.postal.music/mcp` | | App (API keys, scopes, webhooks) | `https://postal.music/settings/integrations` | ## One key, both surfaces [#one-key-both-surfaces] An API key created in the Postal app works for the REST API directly and is the credential you connect the MCP server with. Scopes on the key bound what either surface can do. See [API keys](/getting-started/api-keys). ## For AI agents reading this site [#for-ai-agents-reading-this-site] Every page has a Markdown version behind the copy button at the top, and the whole site is available as plain text at [`/llms.txt`](/llms.txt) and [`/llms-full.txt`](/llms-full.txt). The MCP tool catalog with JSON schemas is at [`https://mcp.postal.music/tools`](https://mcp.postal.music/tools). # Analytics (/api/analytics) Engagement is aggregated from the activity log: plays, views, downloads, saves, shares and comments on your tracks and playlists, from share pages, storefronts and the app. All endpoints need scope `read:analytics`. ## Dashboard [#dashboard] ```http GET /api/v1/analytics/dashboard ``` This week's summary. The same numbers the app's home page shows. ```json title="data" { "thisWeek": { "rangeLabel": "1–7 Sep", "headline": "A busy week", "blurb": "12 uploads, 340 views and 58 downloads.", "uploaded": 12, "views": 340, "downloads": 58 }, "recentActivity": { "totalThisWeek": 61, "items": [ { "id": "…", "title": "Downloaded Night Drive", "entityKey": "track", "entityId": "cm1x…", "sentAt": "…", "actor": { … } } ] }, "tracks": { "total": 1284, "uploadedThisWeek": 12 }, "yourSound": { "genre": "Downtempo", "mood": "Warm", "totalDurationLabel": "71 h 12 m" }, "playlists": { "total": 37, "lastUpdatedAt": "…" } } ``` ## Entity stats [#entity-stats] ```http GET /api/v1/analytics/stats?entity=track&ids=cm1x…,cm1y…&since=2026-08-01&until=2026-09-01 ``` | Query | Type | Notes | | ---------------- | --------------------- | -------------- | | `entity` | `track` \| `playlist` | Required | | `ids` | CSV, 1–50 | Required | | `since`, `until` | ISO 8601 | Optional range | ```json title="data" { "entity": "track", "range": { "since": "2026-08-01", "until": "2026-09-01" }, "items": [ { "id": "cm1x…", "name": "Night Drive", "artist": "…", "stats": { "plays": 120, "views": 210, "downloads": 18, "saved": 4, "shares": 6, "comments": 2 }, "total": 360 } ], "notFound": [] } ``` Ids that are deleted or not yours appear in `notFound`. ## Top entities [#top-entities] ```http GET /api/v1/analytics/top?metric=plays&entity=track&limit=10&since=2026-08-01 ``` | Query | Type | Notes | | ---------------- | ---------------------------------------------------------------------- | --------------- | | `metric` | `plays` \| `views` \| `downloads` \| `saved` \| `shares` \| `comments` | Required | | `entity` | `track` \| `playlist` | Default `track` | | `limit` | 1–50 | Default 10 | | `since`, `until` | ISO 8601 | | ```json title="data" { "entity": "track", "metric": "plays", "range": { "since": "2026-08-01", "until": null }, "items": [ { "id": "cm1x…", "name": "Night Drive", "artist": "…", "count": 120 } ] } ``` # Briefs (/api/briefs) A brief is a call for submissions: an opportunity you publish, invite people to, and receive tracks against. Both endpoints need scope `read:briefs`. For the other direction, where a track of yours was submitted, see [Track submissions](/api/tracks#track-submissions). ## List briefs [#list-briefs] ```http GET /api/v1/briefs ``` Up to 200 briefs you own, newest first. ```json title="data" { "data": [ { "id": "cm4a…", "name": "Indie ad campaign Q4", "status": "open", "deadline": "2026-10-01T00:00:00.000Z", "active": true, "priority": "high", "tags": ["indie", "advertising"], "contactEmail": "supervisor@agency.example", "createdAt": "…", "submissionCount": 34, "requestCount": 12 } ] } ``` `requestCount` is the number of email invites sent; `submissionCount` is the number of tracks or links received. ## Brief submissions [#brief-submissions] ```http GET /api/v1/briefs/{briefId}/submissions ``` ```json title="data" { "brief": { "id": "cm4a…", "name": "Indie ad campaign Q4", "status": "open", "deadline": "…" }, "submissions": [ { "id": "…", "type": "track", "message": "Instrumental mix attached", "link": null, "createdAt": "…", "track": { "id": "cm1x…", "name": "Night Drive", "artist": "…" }, "viaRequestEmail": "producer@example.com" }, { "id": "…", "type": "link", "message": "Private SoundCloud", "link": "https://soundcloud.com/…", "createdAt": "…", "track": null, "viaRequestEmail": null } ] } ``` Returns `{ "ok": false, "error": "BRIEF_NOT_FOUND" }` for a brief you do not own. # Composers (/api/composers) A composer (also called a shareholder in the app) is a person or entity that holds a share of a track: writer, producer, publisher. Records carry the identifiers a PRO or distributor needs. ## List composers [#list-composers] ```http GET /api/v1/composers?cursor=… ``` Scope `read:composers`. Returns up to 100 per page, newest first, each with its linked tracks. Pass the last item's `id` as `cursor` for the next page. ```json title="data" [ { "id": "cm3a…", "userId": "…", "fullName": "Jane Doe", "artistName": "Janie", "email": "jane@example.com", "publisher": "Doe Music Publishing", "pro": "ASCAP", "ipi": "00012345678", "createdAt": "…", "tracks": [ { "share": 50, "role": "writer", "track": { "id": "cm1x…", "name": "Night Drive", "createdAt": "…" } } ] } ] ``` ## Count composers [#count-composers] ```http GET /api/v1/composers/count ``` Scope `read:composers`. Returns `{ "count": 42 }`. ## Get a composer [#get-a-composer] ```http GET /api/v1/composers/single?id={composerId} ``` Scope `read:composer` (singular). Returns `{ "composer": { … }, "id", "userId" }`. `composer` is `null` if not found. ## Create a composer [#create-a-composer] ```http POST /api/v1/composers ``` Scope `create:composer`. ```json title="Request" { "fullName": "Jane Doe", "email": "jane@example.com", "artistName": "Janie", "publisher": "Doe Music Publishing", "pro": "ASCAP", "ipi": "00012345678" } ``` `fullName` and `email` are required. Email is unique per account; a duplicate returns `400`. ## Update a composer [#update-a-composer] ```http PATCH /api/v1/composers ``` Scope `update:composer`. Body is `{ "id": "…" }` plus any fields to change. Returns `{ "success": true, "composer": { …fields you sent } }`. ## Delete a composer [#delete-a-composer] ```http DELETE /api/v1/composers?id={composerId} ``` Scope `delete:composer`. Permanent. Returns `{ "success": true }`. ## Webhooks [#webhooks] Composer changes emit `composer.created` and `composer.updated`; linking to tracks emits the `shareholder.*` and `publisher.*` events. See [Webhooks](/api/webhooks). # Conventions (/api) ## Base URL [#base-url] ```text https://api.postal.music/api/v1 ``` All public endpoints live under `/api/v1`. Paths on this site are written relative to that prefix unless shown in full. ## Authentication [#authentication] Send your [API key](/getting-started/api-keys) in the `X-API-Key` header on every request. There are no cookies and no OAuth on the REST surface. ```http 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 [#response-envelope] Every successful response is wrapped: ```json { "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] Errors use a different shape from successes: ```json { "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: ```json { "statusCode": 400, "messageCode": "validation_failed", "message": "Invalid request", "error": "Bad Request" } ``` Unhandled failures return `500` with `messageCode: "internal_error"`. ## Pagination [#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. ```json { "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 [#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 [#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 [#request-bodies] Send JSON with `Content-Type: application/json`, except [`POST /tracks/upload`](/api/tracks#upload-files), which is `multipart/form-data`. Bodies are limited to 10 MB; audio goes through the upload endpoints, not JSON. ## Timeouts [#timeouts] Requests time out after 60 seconds with `503 request_timeout`. Upload endpoints are exempt. ## IDs and dates [#ids-and-dates] IDs are opaque strings. Dates are ISO 8601 in UTC. Large integers are serialised as strings. ## Versioning [#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](/api/versioning). # Key management (/api/key-management) These endpoints manage API keys. They are authenticated with the **web app's session token**, not with an API key, because a key must never be able to mint or widen another key. Most integrations never call them; the app's **Settings → Integrations** and **Tools → Integration** screens cover the same operations. Base path: `https://api.postal.music/integration`. Send `Authorization: Bearer `. ## Create a key [#create-a-key] ```http POST /integration/api-keys ``` ```json title="Request" { "name": "Catalogue sync", "scopes": ["read:tracks", "create:tracks"], "days": 365 } ``` | Field | Type | Notes | | -------- | ----------- | -------------------------------------------------------------------------------------- | | `name` | string | Required | | `scopes` | string\[] | Any of the [assignable scopes](/getting-started/api-keys#scopes). Omit for all of them | | `days` | integer ≥ 1 | Expiry. Omit for no expiry | Returns `{ "key": "k7Qw2ZpL.Xj9c…", "id": "k7Qw2ZpL" }`. The key is shown only here. ## Current key [#current-key] ```http GET /integration/api-keys ``` Returns the newest non-revoked key: `id`, `name`, `scopes`, `webhookUrl`, `webhookActive`, `createdAt`, `lastUsedAt`, `expiresAt`, and the raw `key`. ## Scope catalog [#scope-catalog] ```http GET /integration/api-keys/scopes ``` Returns `{ "scopes": [ … ] }`, the assignable list. ## Replace scopes [#replace-scopes] ```http POST /integration/api-keys/{id}/scopes ``` Body `{ "scopes": [ … ] }`. Unknown scopes return `400 invalid_scope`. Keys carrying `act-as-user` or a `platform:*` scope cannot be edited. ## Set webhook [#set-webhook] ```http POST /integration/api-keys/{id}/webhook ``` Body `{ "url": "https://…", "active": true }`. See [Webhooks](/api/webhooks). ## Rotate [#rotate] ```http POST /integration/api-keys/{id}/rotate ``` Issues a new id and secret and invalidates the old ones. Returns `{ "id", "createdAt", "key" }`. Scopes and webhook settings carry over. ## Revoke [#revoke] ```http POST /integration/api-keys/{id}/revoke ``` Permanent. Returns `{ "ok": true }`. ## Usage [#usage] ```http GET /integration/usage?limit=50&cursor=…&from=2026-09-01&to=2026-09-07 ``` Every request made with your keys: route, method, status, duration, IP and timestamp. Cursor-paginated with `items` and `nextCursor`. # Platform API (/api/platform) The platform API is for partners and internal tooling that need aggregate reporting across accounts: how many submissions a file request received, and which accounts were acquired through it. It is strictly read-only and uses a separate kind of key. You cannot create a platform key yourself. Ask your Postal contact. Platform keys always expire, within 1–365 days, and every issue and revocation is audited. Authentication is the same `X-API-Key` header. Scopes are `platform:read:file-requests` and `platform:read:accounts`. The legacy `all` wildcard never satisfies a platform scope. Pagination is page-based: `page` from 1, `pageSize` default 50. ## File requests [#file-requests] ```http GET /api/v1/platform/file-requests?page=1&pageSize=50&active=true ``` Scope `platform:read:file-requests`. `pageSize` max 100. Sorted by latest submission. ```json title="data" { "rows": [ { "id": "…", "title": "Remix competition", "createdAt": "…", "active": true, "ownerId": "…", "ownerName": "Label X", "ownerEmail": "ar@labelx.example", "freeSubmissions": 412, "paidSubmissions": 38, "submissionsTotal": 450, "latestSubmissionAt": "…" } ], "page": 1, "pageSize": 50, "totalCount": 3, "totalPages": 1 } ``` ## Submissions to a file request [#submissions-to-a-file-request] ```http GET /api/v1/platform/file-requests/{id}/submissions?page=1&pageSize=50 ``` Scope `platform:read:file-requests`. `pageSize` max 200. Newest first. ```json title="data" { "requestId": "…", "active": true, "rows": [ { "kind": "paid", "submittedAt": "…", "itemCount": 3, "submitterId": "sub_9f2c1ab34d5e6f70" } ], "page": 1, "pageSize": 50, "totalCount": 450, "totalPages": 9 } ``` `submitterId` is a keyed hash of the submitter's identity. It is stable, so repeat submitters can be counted across requests, but it cannot be reversed to an email address. It is `null` when the upload captured no identity. ## Accounts [#accounts] ```http GET /api/v1/platform/accounts?page=1&pageSize=50&createdAfter=2026-08-01 ``` Scope `platform:read:accounts`. `pageSize` max 200. Newest first. An invalid `createdAfter` is ignored. ```json title="data" { "rows": [ { "id": "…", "name": "…", "email": "…", "username": "…", "createdAt": "…", "active": true, "acquisitionSource": "file_request", "acquisitionFileRequestId": "…", "acquisitionFileRequestTitle": "Remix competition", "acquisitionFileRequestOwnerId": "…" } ], "page": 1, "pageSize": 50, "totalCount": 1280, "totalPages": 26 } ``` Owner and account emails are returned as-is; only submitter identity is hashed. Treat responses as personal data. # Playlists (/api/playlists) A playlist is an ordered set of tracks with a name, description and a public share link. Playlists created through the API appear in the app immediately. ## List playlists [#list-playlists] ```http GET /api/v1/playlists?limit=50&cursor=… ``` Scope `read:playlists`. Cursor-paginated, newest first. ```json title="data" { "data": [ { "id": "cm2a…", "name": "A&R Shortlist - Week 36", "description": "Tracks to review with the team.", "image": null, "collaborative": false, "isPinned": true, "trackCount": 12, "createdAt": "…", "updatedAt": "…" } ], "paging": { "limit": 50, "next_cursor": null } } ``` ## Get a playlist [#get-a-playlist] ```http GET /api/v1/playlists/single/{playlistId} ``` Scope `read:playlists`. Returns the playlist with its tracks in order, each with its audio profile and download link, plus a `modes` array. The entry with `mode: "SHARE_VIEW"` holds the public link token: `https://postal.music/playlist/{link}`. Returns `{ "ok": false, "error": "PLAYLIST_NOT_FOUND" }` for a playlist you do not own. ## Create a playlist [#create-a-playlist] ```http POST /api/v1/playlists ``` Scope `create:playlists`. ```json title="Request" { "name": "A&R Shortlist - Week 36", "description": "Tracks to review with the team.", "trackIds": ["cm1x…", "cm1y…"], "dedupe": true } ``` | Field | Type | Notes | | ------------- | ----------------------- | ------------------------------------------ | | `name` | string ≤ 120 | Required | | `description` | string ≤ 2000 | | | `trackIds` | string\[] or CSV string | Required, at least one. Order is preserved | | `dedupe` | boolean | Drop repeated ids | ```json title="data" { "ok": true, "playlist": { "id": "cm2a…", "privateLink": "https://postal.music/playlists/cm2a…", "publicLink": "https://postal.music/playlist/9k2f…", "name": "A&R Shortlist - Week 36", "description": "Tracks to review with the team." }, "tracks": { "count": 2, "ids": ["cm1x…", "cm1y…"] } } ``` If any id is not yours the playlist is not created: ```json { "ok": false, "error": "TRACKS_NOT_FOUND", "message": "…", "missing": ["cm1z…"] } ``` ## Update a playlist [#update-a-playlist] ```http PATCH /api/v1/playlists/{playlistId} ``` Scope `update:playlists`. Body `{ "name"?: string, "description"?: string }`. ## Add tracks [#add-tracks] ```http POST /api/v1/playlists/{playlistId}/tracks ``` Scope `create:playlists`. Body `{ "trackIds": ["…"] }`. Appends in the given order. ## Remove a track [#remove-a-track] ```http DELETE /api/v1/playlists/{playlistId}/tracks/{trackId} ``` Scope `update:playlists`. ## Reorder [#reorder] ```http PATCH /api/v1/playlists/{playlistId}/order ``` Scope `update:playlists`. Body `{ "trackIds": ["…"] }` in the desired order. # Natural-language search (/api/search) ```http POST /api/v1/tracks/search ``` Scope `read:tracks`. **Costs one credit per call.** Failed calls are not charged. Describe the music in prose. Postal parses the description into musical targets (genre, mood, energy, tempo, vocals, reference artist, use) and ranks every analysed track in your library against them. The same engine powers the Co-Work panel in the app, so an integration and a person searching the same library get the same ordering. ## Request [#request] ```json { "query": "dark cinematic trap for a chase scene, no vocals, around 140", "limit": 20, "constraints": { "bpmRange": [135, 145], "durationRange": [60, 180], "hasVocals": false, "key": "Fm", "language": "en" }, "seedTrackIds": ["cm1x…"], "excludeTrackIds": ["cm1a…", "cm1b…"] } ``` | Field | Type | Notes | | --------------------------- | ------------------ | ------------------------------------ | | `query` | string, 1–2000 | Required | | `limit` | 1–50 | Default 20 | | `constraints.bpmRange` | `[min, max]` | Hard filter | | `constraints.durationRange` | `[minSec, maxSec]` | Hard filter | | `constraints.hasVocals` | boolean | Hard filter | | `constraints.key` | string | Hard filter | | `constraints.language` | string | Hard filter, lyric language | | `seedTrackIds` | string\[] ≤ 50 | Steer toward these tracks | | `excludeTrackIds` | string\[] ≤ 200 | Keep out, e.g. an existing shortlist | The ranker is good at "feels like" and poor at negation. "No vocals" in the prose is a hint; `hasVocals: false` is a guarantee. Anything that must be strictly true belongs in `constraints`. ## Response [#response] ```json title="data" { "query": "dark cinematic trap for a chase scene, no vocals, around 140", "count": 20, "results": [ { "id": "cm1x…", "title": "Pursuit", "artists": ["…"], "durationSec": 142, "bpm": 140, "key": "Fm", "genres": ["Trap", "Cinematic"], "moods": ["Dark", "Tense"], "score": 0.91, "reason": ["Dark, tense trap at 140 BPM", "Instrumental"] } ], "interpretation": { "briefParsed": true, "summary": "Dark, tense cinematic trap around 140 BPM, instrumental", "referenceArtist": null, "genres": ["Trap", "Cinematic"], "moods": ["Dark", "Tense"], "bpmRange": [135, 145], "keyMode": "minor", "vocalPreference": "instrumental", "energy": 0.85, "valence": 0.2, "lyricThemes": [], "appliedConstraints": { "bpmRange": [135, 145], "hasVocals": false }, "appliedTuning": { "energy": 0.85, "valence": 0.2 } }, "suggestedName": "Chase Scene Shortlist", "searchId": "…" } ``` | Field | Meaning | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `results[].score` | 0–1 relevance, higher is better | | `results[].reason` | Short human explanations of the match | | `interpretation` | How the query was read. Use it to refine: if `genres` is wrong, name the genre explicitly; if `bpmRange` is missing, add a constraint | | `interpretation.briefParsed` | `false` means the parser was unavailable and the prose went to the ranker unstructured. Results are real but less precisely constrained | | `suggestedName` | A playlist title for this set, ready for [Create playlist](/api/playlists#create-a-playlist) | | `searchId` | The Co-Work history entry, visible to the library owner in the app | ## Refine loop [#refine-loop] 1. Search with prose only. Read `interpretation`. 2. Move anything that must hold into `constraints`. 3. Put the tracks you have already shortlisted into `excludeTrackIds` and the ones you liked into `seedTrackIds`, then search again. ## When not to use it [#when-not-to-use-it] Use [`GET /tracks`](/api/tracks#list-tracks) when you know the name, the tag or the date. It is free and returns immediately. Search is for "find me something that feels like…". # Tags and auto-tagging (/api/tagging) Tracks carry tags in ten categories: genres, moods, audio types, vocal types, instruments, lyric themes, tempos, eras, characters and movements, plus free additional tags. Postal's tagging model proposes tags after every upload; you can accept them, override them, or write tags directly. ## Taxonomy [#taxonomy] ```http GET /api/v1/taxonomy ``` Scope `read:tracks`. Every valid tag value, grouped by category and in display order. Fetch it once and cache it; tag writes must use these names. ```json title="data (trimmed)" { "genres": [{ "id": "…", "name": "Downtempo", "code": "downtempo", "order": 12 }], "moods": [ … ], "instruments": [ … ], "vocalTypes": [ … ], "lyricThemes": [ … ], "audioTypes": [ … ], "tempos": [ … ], "eras": [ … ], "characters": [ … ], "movements": [ … ] } ``` ## Update tags [#update-tags] ```http PATCH /api/v1/tracks/tags?id={trackId} ``` Scope `update:tracks`. Each category you send **replaces** that category on the track. Categories you omit are untouched. ```json title="Request" { "genres": ["Downtempo", "Chillhop"], "moods": ["Warm", "Relaxed"], "types": ["Instrumental"], "vocals": [], "instruments": ["Rhodes", "Upright bass"], "lyricThemes": [], "eras": ["2020s"], "characters": [], "movements": [], "additionalTags": ["coffee-ad", "sync-ready"] } ``` Tag changes made through the API are recorded as feedback for the tagging model, the same way a person's corrections in the app are. Only write tags you are confident about. ## Trigger auto-tag [#trigger-auto-tag] ```http POST /api/v1/tracks/auto-tag ``` Scope `update:tracks`. Body `{ "trackId": "…" }`. Starts analysis for one track. Asynchronous: returns immediately. ## Batch auto-tag [#batch-auto-tag] ```http POST /api/v1/tracks/auto-tag/batch ``` Scope `update:tracks`. Body `{ "trackIds": ["…", "…"] }`. Queues every track. ## Get suggested tags [#get-suggested-tags] ```http GET /api/v1/tracks/suggested-tags?id={trackId} ``` Scope `read:tracks`. Poll this until `analysisStatus` is `completed` or `failed`. ```json title="data" { "trackId": "cm1x…", "suggestedTags": { "genres": ["Downtempo"], "moods": ["Warm", "Relaxed"], "instruments": ["Rhodes"] }, "needsRetagging": false, "analysisStatus": "completed", "intelligence": { "genreCodes": ["downtempo"], "moodCodes": ["warm", "relaxed"], "typeCodes": ["instrumental"], "instrumentCodes": ["rhodes"], "useCaseCodes": ["advertising"], "status": "ready", "ready": true } } ``` Returns `{ "ok": false, "error": "TRACK_NOT_FOUND" }` for an id you do not own. ## Apply suggested tags [#apply-suggested-tags] ```http POST /api/v1/tracks/apply-suggested-tags ``` Scope `update:tracks`. Body `{ "trackId": "…" }`. Overwrites the track's tags with the stored suggestions. Returns `{ "trackId", "applied", "requeued" }`. `requeued: true` means no suggestion existed yet and analysis was started. ## Typical pipeline [#typical-pipeline] 1. Upload, or pick tracks with `GET /tracks?createdAfter=…`. 2. `POST /tracks/auto-tag/batch`. 3. Wait for `track.audio_profile_completed` webhooks, or poll `GET /tracks/suggested-tags` every few seconds. 4. Review. Apply as-is with `POST /tracks/apply-suggested-tags`, or send a corrected set with `PATCH /tracks/tags`. The [MCP server](/mcp/tools#tagging) wraps steps 2–4 in a single `wait_for_auto_tag` tool. # Track versions (/api/track-versions) A track keeps every audio file that was ever uploaded to it as a **version**. Exactly one version is **active**: the file the track plays, analyses, shares and sells. Uploading a new version is how you replace a track's audio without losing its ID, tags, playlists, share links or analytics. All routes live under `/tracks/{trackId}/versions` and only work for tracks the key owner owns. Reading needs `read:tracks`; every change needs `update:tracks`. ## The version object [#the-version-object] ```json { "id": "cm2v…", "trackId": "cm1x…", "label": "v2", "name": "alpha_master.wav", "order": 2, "active": true, "visible": true, "type": "audio/wav", "size": 48213904, "cid": "bafy…", "createdAt": "2026-09-07T10:12:00.000Z", "downloadUrl": "https://…" } ``` | Field | Meaning | | ------------- | ------------------------------------------------------------------------------------------------------------------- | | `label` | Short label shown in the app, `v2`, `v3`, … by default. `null` on the original upload | | `name` | Display name, the uploaded filename unless renamed | | `order` | Position in the history; the original upload is `0` | | `active` | `true` for the file the track currently uses. Derived, not stored: it is the version whose file the track points at | | `visible` | Hidden versions stay in the history but are not shown in the app | | `cid` | Content hash of the audio bytes, when the file has been analysed | | `downloadUrl` | Pre-signed link to the original file, valid for one hour. Fetch it fresh rather than storing it | ## List versions [#list-versions] ```http GET /api/v1/tracks/{trackId}/versions ``` Scope `read:tracks`. Oldest first. ```json title="data" { "trackId": "cm1x…", "activeVersionId": "cm2v…", "count": 2, "versions": [ { "id": "cm1v…", "label": null, "active": false, … }, { "id": "cm2v…", "label": "v2", "active": true, … } ] } ``` [Get a track](/api/tracks#get-a-track) also returns a lighter `versions` summary (id, label, name, active, visible, createdAt) and `activeVersionId` on the track itself, so a sync job can spot a changed track without a second call. ## Get one version [#get-one-version] ```http GET /api/v1/tracks/{trackId}/versions/{versionId} ``` Scope `read:tracks`. Returns one version object. `404 version_not_found` if the version does not belong to that track. ## Add a version from a file [#add-a-version-from-a-file] ```http POST /api/v1/tracks/{trackId}/versions Content-Type: multipart/form-data ``` Scope `update:tracks`. Fields: | Field | Type | Notes | | ---------- | ------- | ----------------------------------------------------------- | | `file` | binary | required; `audio/*`, up to 1 GB | | `name` | string | display name, defaults to the filename | | `label` | string | defaults to `v` where `n` is the position in the history | | `activate` | boolean | default `true` | ```bash curl -X POST "https://api.postal.music/api/v1/tracks/cm1x…/versions" \ -H "X-API-Key: $POSTAL_API_KEY" \ -F "file=@alpha_master.wav" \ -F "label=master" \ -F "activate=true" ``` Returns the new version object. ## Add a version from a URL [#add-a-version-from-a-url] ```http POST /api/v1/tracks/{trackId}/versions/url ``` Scope `update:tracks`. Same rules as [Upload by URL](/api/tracks#upload-by-url): the URL must be public, `http` or `https`, and serve `audio/*` or `application/octet-stream`. ```json title="Request" { "fileUrl": "https://cdn.example.com/audio/alpha_master.wav", "label": "master", "activate": true } ``` Returns the new version object. ## What activating does [#what-activating-does] Whether through `activate: true` on upload or the endpoint below, making a version active re-points the track at that file. On upload this also resets and re-runs analysis (waveform, tags, audio profile) for the new audio, and the app notifies the owner that the audio was replaced. Switching to an existing version only swaps the file and, if needed, transcodes a playback rendition; analysis results stay as they were. Anything that references the track keeps working: playlists, share links, storefront listings and analytics all follow the track ID, not the file. ## Activate a version [#activate-a-version] ```http POST /api/v1/tracks/{trackId}/versions/{versionId}/activate ``` Scope `update:tracks`. ```json title="data" { "ok": true, "trackId": "cm1x…", "activeVersionId": "cm1v…" } ``` ## Rename, relabel or hide [#rename-relabel-or-hide] ```http PATCH /api/v1/tracks/{trackId}/versions/{versionId} ``` Scope `update:tracks`. All fields optional; only the fields you send change. | Field | Type | | --------- | ---------------------------- | | `name` | string, up to 200 characters | | `label` | string, up to 80 characters | | `visible` | boolean | Returns the updated version object. ## Delete a version [#delete-a-version] ```http DELETE /api/v1/tracks/{trackId}/versions/{versionId} ``` Scope `update:tracks`. Removes the version and its audio file permanently. ```json title="data" { "ok": true, "trackId": "cm1x…", "deletedVersionId": "cm1v…" } ``` The active version cannot be deleted: the request fails with `409 active_version`. Activate another version first, then delete. ## Errors [#errors] | Status | `messageCode` | Meaning | | ------ | ------------------- | ------------------------------------- | | 404 | `track_not_found` | No such track for this key's owner | | 404 | `version_not_found` | The version is not on this track | | 409 | `active_version` | Tried to delete the active version | | 400 | `validation_failed` | Bad body, or a URL without a protocol | # Tracks (/api/tracks) A track is one audio file with an audio profile (title, artist, BPM, key, tags…), optional versions, and a share link. ## List tracks [#list-tracks] ```http GET /api/v1/tracks ``` Scope `read:tracks`. Cursor-paginated. | Query | Type | Default | Notes | | --------------- | -------------------------------------------- | ------------- | ------------------------------------------------------------- | | `limit` | 1–200 | 50 | | | `cursor` | string | | `paging.next_cursor` from the previous page | | `sort` | `created_at`, `-created_at`, `name`, `-name` | `-created_at` | | | `q` | string | | Name search | | `tags` | CSV | | Tag names, e.g. `tags=Rock,Blues` | | `tagMode` | `any` \| `all` | `any` | How multiple tags combine | | `createdAfter` | `YYYY-MM-DD` | | Inclusive | | `createdBefore` | `YYYY-MM-DD` | | Inclusive | | `fields` | CSV | all | Sparse field set, e.g. `id,name,createdAt,audio_profile,link` | Field names you can request with `fields`: `id`, `name`, `createdAt`, `audio_profile`, `shareholders`, `link`, `share_link`, `cid`. When `link` is included (the default) each item carries a `link` to its public player page or `null` if no share link exists. ```bash curl "https://api.postal.music/api/v1/tracks?limit=50&tags=Cinematic&tagMode=all&createdAfter=2026-08-01" \ -H "X-API-Key: $POSTAL_API_KEY" ``` ```json title="data" { "data": [ { "id": "cm1x…", "name": "Night Drive v3.wav", "createdAt": "2026-09-01T10:12:44.000Z", "cid": "bafk…", "audio_profile": { "title": "Night Drive", "artist": "…", "bpm": 92, "key": "Am", "durationSec": 214, "genres": [{ "genre": { "name": "Downtempo" } }], "moods": [{ "mood": { "name": "Warm" } }] }, "link": "https://postal.music/song/8f2k…" } ], "paging": { "limit": 50, "next_cursor": "cm1w…" } } ``` ## Count tracks [#count-tracks] ```http GET /api/v1/tracks/count ``` Scope `read:tracks`. Returns `{ "count": 1284 }`. ## Get a track [#get-a-track] ```http GET /api/v1/tracks/single?id={trackId} ``` Scope `read:tracks`. Returns the full track with every tag relation expanded and its shareholders: ```json title="data" { "track": { "id": "cm1x…", "name": "Night Drive v3.wav", "audio_profile": { "title": "Night Drive", "bpm": 92, "key": "Am", "genres": [{ "genre": { "id": "…", "name": "Downtempo" } }], "moods": [ … ], "types": [ … ], "instruments": [ … ], "vocals": [ … ], "lyricThemes": [ … ], "eras": [ … ], "characters": [ … ], "movements": [ … ], "tags": [ … ] }, "shareholderTracks": [ { "share": 50, "role": "…", "shareholder": { "id": "…", "fullName": "…", "pro": "BUMA" } } ] }, "id": "cm1x…", "userId": "…" } ``` `track` is `null` when the id does not exist or belongs to another account. ### Versions [#versions] The response also carries `activeVersionId` and a `versions` array (id, label, name, active, visible, createdAt) summarising the track's audio history. To replace the audio, switch versions or get download links, see [Track versions](/api/track-versions). ## Upload files [#upload-files] ```http POST /api/v1/tracks/upload Content-Type: multipart/form-data ``` Scope `create:tracks`. Up to 25 audio files per request, 1 GB each. | Field | Type | Notes | | --------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------- | | `files` | file\[] | Required. `audio/*` or `application/octet-stream` | | `visible` | `true` \| `false` | Default `false`. Whether the track shows in the library list | | `audioProfiles` | JSON string | Optional per-file metadata, matched by filename: `[{ "name": "song.wav", "audioProfile": { "bpm": 120, "key": "C#m" } }]` | ```bash curl -X POST https://api.postal.music/api/v1/tracks/upload \ -H "X-API-Key: $POSTAL_API_KEY" \ -F "files=@night-drive.wav" \ -F "files=@daylight.mp3" \ -F "visible=true" \ -F 'audioProfiles=[{"name":"night-drive.wav","audioProfile":{"bpm":92,"key":"Am"}}]' ``` ```json title="data" { "uploaded": [ { "id": "cm1x…", "name": "night-drive.wav", "visible": true }, { "id": "cm1y…", "name": "daylight.mp3", "visible": true } ] } ``` Audio analysis (tempo, key, tags, content hash) runs asynchronously after upload. Poll [suggested tags](/api/tagging#get-suggested-tags) or subscribe to the `track.audio_profile_completed` [webhook](/api/webhooks). ## Upload by URL [#upload-by-url] ```http POST /api/v1/tracks/upload/url ``` Scope `create:tracks`. Postal fetches each file server-side. URLs must be publicly reachable, use `http` or `https`, and serve `audio/*` or `application/octet-stream`. Private network addresses are rejected. ```json title="Request" { "items": [ { "fileUrl": "https://cdn.example.com/audio/alpha.wav", "visible": true, "audioProfile": { "bpm": 120, "key": "Am" }, "details": { "title": "Alpha", "artist": "The Keys", "isrc": "NLA1G2600001" } }, { "fileUrl": "https://cdn.example.com/audio/beta.mp3" } ] } ``` `items` holds 1–25 entries. `details` accepts the same fields as [Update details](#update-details) and is applied after the upload. ```json title="data" { "uploaded": [{ "id": "cm1x…", "name": "alpha.wav", "visible": true }], "failed": [{ "url": "https://cdn.example.com/audio/beta.mp3", "error": "HTTP 404 when fetching …" }] } ``` Per-item failures never fail the whole request. ## Update details [#update-details] ```http PATCH /api/v1/tracks/details?id={trackId} ``` Scope `update:tracks`. All fields optional; only the fields you send change. | Field | Type | | ------------------------------------------------------------------------ | ------------------------------------------------- | | `title`, `artist`, `album`, `album_artist`, `composer`, `album_composer` | string | | `bpm` | number | | `key` | string, e.g. `Am`, `F#` | | `year`, `album_year` | string | | `isrc` | string | | `trackGenre` | string (free text, distinct from taxonomy genres) | | `track_number`, `disc_number`, `total_tracks_album`, `total_discs_album` | string | | `name` | string (file display name) | | `lyrics`, `notes` | string | Emits a `track.updated` webhook. Returns the updated track. ## Find duplicates [#find-duplicates] ```http GET /api/v1/tracks/duplicates?limit=20&offset=0 ``` Scope `read:tracks`. Groups tracks by content hash so the same bytes uploaded twice are caught, however they were renamed. Re-encoded files have a different hash and are not matched. ```json title="data" { "groups": [ { "cid": "bafk…", "count": 2, "tracks": [ { "id": "cm1x…", "name": "Night Drive v3.wav", "createdAt": "…" }, { "id": "cm1z…", "name": "Night Drive FINAL.wav", "createdAt": "…" } ] } ], "totalGroups": 7, "duplicateTrackCount": 15, "missingCid": 12, "scannedTracks": 1284, "paging": { "limit": 20, "offset": 0 } } ``` `missingCid` counts tracks that have not been analysed yet and could not be checked. Triggering [auto-tag](/api/tagging#trigger-auto-tag) populates the hash. ## Track submissions [#track-submissions] ```http GET /api/v1/tracks/submissions?id={trackId} ``` Scope `read:tracks`. Every brief the track was submitted to, newest first, including briefs owned by other accounts. ```json title="data" { "trackId": "cm1x…", "trackName": "Night Drive", "submissions": [ { "id": "…", "briefId": "…", "briefName": "Indie ad campaign Q4", "briefStatus": "open", "briefDeadline": "2026-10-01T00:00:00.000Z", "message": "Fits the warm brief, instrumental mix attached", "createdAt": "2026-09-02T09:30:00.000Z", "viaRequestEmail": "supervisor@agency.example" } ] } ``` Returns `{ "ok": false, "error": "TRACK_NOT_FOUND" }` for an id you do not own. ## Cover art [#cover-art] ```http POST /api/v1/tracks/cover-art ``` Scope `update:tracks`. Provide `trackId` and one of `url` (public image URL) or `base64` (raw base64 or a `data:image/…;base64,` URI). Optional `filename`, default `cover.jpg`. ```json title="data" { "url": "https://…/cover-art/…/cover.jpg", "key": "…", "trackId": "cm1x…" } ``` Errors: `400 missing_image`, `400 invalid_image_url`, `400 invalid_image_type`, `400 image_download_failed`, `404 track_not_found`. # Versioning (/api/versioning) ## The version is in the URL [#the-version-is-in-the-url] ```text https://api.postal.music/api/v1 ``` The `v1` segment is the whole contract. There is no version header to send and nothing to pin in your client beyond the base URL: a request to `/api/v1` today gets the same shapes it will get in a year. ## What is stable within a version [#what-is-stable-within-a-version] | Never changes within v1 | May change at any time (additive only) | | ----------------------------------------------------------- | -------------------------------------------------------- | | Paths and HTTP methods | New endpoints | | Required inputs and their types | New optional parameters and body fields | | Names and types of fields under `data` | New fields under `data` | | `messageCode` strings on errors | New `messageCode`s for new failure modes | | Scope names | New scopes for new endpoints | | Pagination shapes (`paging.next_cursor`, `page`/`pageSize`) | Default and maximum page sizes | | Webhook event names and payload fields | New webhook events and fields | | | Tag categories, enum values, `message` text, field order | Anything in the left column only changes under a new prefix. Anything in the right column can appear in a release without notice, so a client should ignore fields it does not know and treat enumerations as open. ## Read the version you are on [#read-the-version-you-are-on] Every response from `/api/v1` carries the version that served it: ```http HTTP/1.1 200 OK X-Postal-Api-Version: 1 ``` `GET /api/v1/version` describes the version without an API key: ```bash curl https://api.postal.music/api/v1/version ``` ```json { "version": "1", "prefix": "/api/v1", "latest": "1", "status": "current", "sunset": null, "build": "a1b2c3d", "deprecatedRoutes": [] } ``` | Field | Meaning | | ------------------ | --------------------------------------------------------------------- | | `version` | The version in the URL you called | | `latest` | The newest version available; differs from `version` once a v2 exists | | `status` | `current`, or `deprecated` once a newer version exists | | `sunset` | ISO date the whole version stops being served, or `null` | | `build` | Opaque build identifier, useful in a support ticket; may be `null` | | `deprecatedRoutes` | Routes inside this version scheduled for removal, see below | ## Deprecations [#deprecations] Occasionally a single route has to go before a new version is warranted, usually because a better endpoint replaced it. When that happens: 1. The change is announced in the [changelog](/changelog). 2. The route keeps working unchanged for at least 90 days from the announcement. 3. Every response from the route carries deprecation headers. 4. The route is listed under `deprecatedRoutes` on `GET /api/v1/version`. 5. On the sunset date the route is removed and returns `404`. The headers follow the IETF standards for this, so generic HTTP tooling can pick them up: ```http HTTP/1.1 200 OK X-Postal-Api-Version: 1 Deprecation: @1757203200 Sunset: Thu, 07 Jan 2027 00:00:00 GMT Link: ; rel="deprecation", ; rel="successor-version" ``` | Header | Standard | Value | | ------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | `Deprecation` | [RFC 9745](https://www.rfc-editor.org/rfc/rfc9745) | `@` followed by the Unix time the route was deprecated | | `Sunset` | [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594) | HTTP date after which the route may be removed; absent while undecided | | `Link` | [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288) | `rel="deprecation"` points here; `rel="successor-version"` points at the replacement when there is one | The same routes appear on the version endpoint with a human note: ```json { "deprecatedRoutes": [ { "method": "POST", "path": "/api/v1/tracks/legacy-search", "since": "2026-09-07", "sunset": "2027-01-07", "successor": "https://api.postal.music/api/v1/tracks/search", "note": "Use POST /tracks/search; results are identical." } ] } ``` Log the `Deprecation` header when you see it. A client that does so finds out about a removal the first time it calls the route, not when it breaks. ## When a new version ships [#when-a-new-version-ships] A v2 arrives only for changes the table above forbids. When it does, v1 keeps running alongside it: `GET /api/v1/version` reports `latest: "2"`, `status: "deprecated"` and a `sunset` date, and every v1 response gains the `Deprecation` and `Sunset` headers. The overlap is announced in the changelog and is never shorter than the 90-day route window. Migrating is a base URL change plus whatever the v2 changelog entry lists. ## Writing a client that survives releases [#writing-a-client-that-survives-releases] * **Ignore unknown fields** in responses and webhook payloads. * **Key off `messageCode`**, never off `message` text or the `error` label. * **Treat enumerations as open**: new tag categories, sort keys and event names appear without a version bump. * **Do not depend on field order** in JSON objects. * **Page with `next_cursor`** rather than assuming a page size. * **Watch the `Deprecation` header** and surface it in your logs. ## MCP server [#mcp-server] The [MCP server](/mcp) is versioned separately with semantic versions, because its surface (tool names and schemas) evolves faster than the REST contract it sits on. See [MCP versioning](/mcp/versioning). # Webhooks (/api/webhooks) Attach a URL to an API key and Postal will POST a signed JSON event to it whenever something the key can see changes. One webhook per key. ## Set up [#set-up] In the app, open **Tools → Integration**, enter the endpoint URL and switch it on. The same setting is available to dashboard sessions at [`POST /integration/api-keys/{id}/webhook`](/api/key-management#set-webhook). Your endpoint must accept `POST`, respond with any 2xx within a few seconds, and be reachable from the public internet over HTTPS. ## Events [#events] | Event | Fires when | | --------------------------------- | ------------------------------------------------------------------ | | `track.created` | A track is uploaded | | `track.updated` | Details or tags change | | `track.audio_profile_completed` | Analysis finished: tempo, key, tags and content hash are available | | `composer.created` | | | `composer.updated` | | | `shareholder.created` | | | `shareholder.updated` | | | `shareholder.linked_to_track` | A composer gets a share on a track | | `shareholder.unlinked_from_track` | | | `shareholder.track_updated` | A share percentage or role changes | | `publisher.created` | | | `publisher.updated` | | | `publisher.linked_to_track` | | | `publisher.unlinked_from_track` | | | `publisher_share.updated` | | | `publisher_share.deleted` | | ## Payload [#payload] ```http POST /your/endpoint HTTP/1.1 Content-Type: application/json User-Agent: PostalWebhook/1.0 X-Postal-Event: track.audio_profile_completed X-Postal-Event-Id: 5f0c1e3a-… X-Postal-Signature: t=1757232000, v1=3b2d… ``` ```json { "id": "5f0c1e3a-…", "type": "track.audio_profile_completed", "occurred_at": "2026-09-07T09:20:00.000Z", "data": { "id": "cm1x…", "name": "Night Drive v3.wav", "audio_profile": { "bpm": 92, "key": "Am", "…": "…" } } } ``` `data` is the affected object at the time of the event. Treat it as a hint and re-fetch from the API if you need the current state. ## Verify the signature [#verify-the-signature] Each key has its own webhook secret, created the first time an event is sent. The Integration console does not show the webhook secret yet. Contact support with your key id to receive it. Until you have it, treat webhook payloads as untrusted hints and re-fetch from the API. The signature header is `t=, v1=` where `v1 = HMAC-SHA256(secret, ".")`. ```ts title="Node" import crypto from 'node:crypto'; export function verify(rawBody: string, header: string, secret: string) { const parts = Object.fromEntries( header.split(',').map((p) => p.trim().split('=') as [string, string]), ); const expected = crypto .createHmac('sha256', secret) .update(`${parts.t}.${rawBody}`) .digest('hex'); const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300; return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1)); } ``` Verify against the raw request body, before any JSON parsing or re-serialising. ## Delivery and retries [#delivery-and-retries] * Events are queued per key and delivered in the background. * A non-2xx response or a network error is retried up to 5 times in total, after 1 minute, 5 minutes, 30 minutes, 2 hours and 24 hours. * After the fifth failure the event is marked dead and not retried. * Deliveries can arrive out of order and, in rare cases, more than once. Use `X-Postal-Event-Id` to deduplicate. * Switching the webhook off drops any events still pending for that key. # API keys (/getting-started/api-keys) Every request to `api.postal.music/api/v1/*` and every MCP connection is authenticated with an API key that belongs to one Postal account. The key acts as that account: it sees that account's library and nothing else. ## Create a key [#create-a-key] Sign in to Postal and open **Settings → Integrations**. Click **Create API key**. The key is shown once; copy it somewhere safe. You can reveal it again later from the same screen. Open **Tools → Integration** to narrow the key's scopes or attach a webhook URL. A key created from settings starts with every assignable scope and no expiry. Keys look like an 8-character id, a dot, and a 32-character secret: ```text k7Qw2ZpL.Xj9c3vT1nR8mBq4sLw0yFh6dKe2aGu5p ``` The part before the dot identifies the key in logs and in the usage report. The part after it is never stored in plain text. ## Send it [#send-it] Put the key in the `X-API-Key` header: ```bash curl https://api.postal.music/api/v1/tracks/count \ -H "X-API-Key: YOUR_API_KEY" ``` `Authorization: Bearer` is reserved for the web app's session token and is not accepted by the `/api/v1` endpoints. The MCP server accepts either header. ## Scopes [#scopes] A key carries a list of scopes. Each endpoint declares the scope it needs, and a key missing that scope gets `403 insufficient_scope`. | Scope | Grants | | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | | `read:tracks` | List, get and count tracks; taxonomy; suggested tags; duplicates; submissions; natural-language search | | `create:tracks` | Upload tracks (multipart or by URL) | | `update:tracks` | Edit details and tags, trigger and apply auto-tagging, cover art | | `read:playlists` | List and get playlists | | `create:playlists` | Create playlists, add tracks | | `update:playlists` | Rename, remove tracks, reorder | | `read:composers` | List and count composers | | `read:composer` | Get a single composer | | `create:composer` | Create a composer | | `update:composer` | Update a composer | | `delete:composer` | Delete a composer | | `read:analytics` | Dashboard, per-entity stats, top tracks | | `read:briefs` | Briefs you own and their submissions | | `read:shareholders` | Reserved | | `read_usage`, `create_key`, `revoke_key`, `rotate_key` | Reserved for key management | Two scopes you may see on older keys are not assignable: `all` is a legacy wildcard that still works on the keys that have it, and `act-as-user` marks service keys provisioned by Postal. `platform:*` scopes belong to [platform keys](/api/platform) and are issued by Postal staff only. Listing and counting composers needs `read:composers`; fetching one needs `read:composer`. A key with only one of the two will half-work. Grant both. ## Rotate and revoke [#rotate-and-revoke] Both live in **Tools → Integration**. Rotating issues a new id and secret at once and invalidates the old pair; revoking disables the key permanently. Either action also disconnects any MCP client authorised with that key. The same operations are exposed over HTTP for dashboard sessions; see [Key management](/api/key-management). ## Good practice [#good-practice] * Create one key per integration so you can revoke one without touching the others. * Grant only the scopes the integration uses. Read-only keys cannot change your library even if leaked. * Keys have no expiry by default. Set `days` on creation if you want one. * Never ship a key in client-side code. The API is meant to be called from a server or a trusted desktop tool. # Quickstart (/getting-started/quickstart) You need an [API key](/getting-started/api-keys). Export it once: ```bash export POSTAL_API_KEY="YOUR_API_KEY" ``` ## 1. Count your tracks [#1-count-your-tracks] ```bash curl https://api.postal.music/api/v1/tracks/count \ -H "X-API-Key: $POSTAL_API_KEY" ``` ```ts const res = await fetch('https://api.postal.music/api/v1/tracks/count', { headers: { 'X-API-Key': process.env.POSTAL_API_KEY! }, }); const { data } = await res.json(); console.log(data.count); ``` ```python import os, requests r = requests.get( "https://api.postal.music/api/v1/tracks/count", headers={"X-API-Key": os.environ["POSTAL_API_KEY"]}, ) print(r.json()["data"]["count"]) ``` ```json title="Response" { "status": 200, "messageCode": 200, "message": "Operation was successful", "data": { "count": 1284 } } ``` Every successful response is wrapped like this. Your result is always under `data`. See [Conventions](/api#response-envelope). ## 2. List the newest tracks [#2-list-the-newest-tracks] ```bash curl "https://api.postal.music/api/v1/tracks?limit=5&sort=-created_at&fields=id,name,createdAt,audio_profile,link" \ -H "X-API-Key: $POSTAL_API_KEY" ``` ```json title="Response (data)" { "data": [ { "id": "cm1x…", "name": "Night Drive v3.wav", "createdAt": "2026-09-01T10:12:44.000Z", "audio_profile": { "title": "Night Drive", "artist": "…", "bpm": 92, "key": "Am", "…": "…" }, "link": "https://postal.music/song/8f2k…" } ], "paging": { "limit": 5, "next_cursor": "cm1w…" } } ``` Pass `cursor=` to get the next page. It is `null` on the last page. ## 3. Find a track by describing it [#3-find-a-track-by-describing-it] ```bash curl -X POST https://api.postal.music/api/v1/tracks/search \ -H "X-API-Key: $POSTAL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "warm downtempo instrumental for a coffee ad", "limit": 5, "constraints": { "hasVocals": false, "bpmRange": [80, 100] } }' ``` ```json title="Response (data, trimmed)" { "query": "warm downtempo instrumental for a coffee ad", "count": 5, "results": [ { "id": "cm1x…", "title": "Night Drive", "artists": ["…"], "bpm": 92, "key": "Am", "genres": ["Downtempo"], "moods": ["Warm"], "score": 0.87, "reason": ["Matches the relaxed, warm brief", "Instrumental, 92 BPM"] } ], "interpretation": { "briefParsed": true, "summary": "Warm, relaxed instrumental around 90 BPM", "genres": ["Downtempo", "Chillhop"], "moods": ["Warm", "Relaxed"], "bpmRange": [80, 100], "vocalPreference": "instrumental" }, "suggestedName": "Coffee Ad Shortlist", "searchId": "…" } ``` This endpoint runs an AI ranking pass and costs one credit per call. The `interpretation` block shows how your words were read, so you can tighten the query or move a requirement into `constraints`. Details in [Search](/api/search). ## Next [#next] # Auth and scopes (/mcp/auth-and-scopes) ## Two ways in [#two-ways-in] **OAuth 2.1 (connectors).** Claude and other connector-style clients discover the server at `/.well-known/oauth-authorization-server`, register dynamically, and send you to an authorisation page where you paste a Postal API key. The key is exchanged for a one-hour access token with a 30-day refresh token. The client never holds the key. **Direct header.** Clients that let you set headers can send the key on every request as `X-API-Key: ` or `Authorization: Bearer `. No token exchange. Either way, every tool call becomes REST API calls made with that key, so the key's [scopes](/getting-started/api-keys#scopes) apply. ## Scope per tool [#scope-per-tool] | Scope | Tools | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `read:tracks` | `list_tracks`, `get_track_count`, `get_track`, `search_tracks`, `find_duplicate_tracks`, `get_track_submissions`, `get_tag_taxonomy`, `get_suggested_tags`, `wait_for_auto_tag` | | `create:tracks` | `upload_track_by_url` | | `update:tracks` | `update_track_details`, `update_track_tags`, `trigger_auto_tag`, `batch_auto_tag`, `apply_suggested_tags`, `upload_artwork`, and `wait_for_auto_tag` with `trigger: true` | | `read:playlists` | `list_playlists`, `get_playlist` | | `create:playlists` | `create_playlist`, `add_tracks_to_playlist` | | `update:playlists` | `update_playlist`, `remove_track_from_playlist`, `reorder_playlist` | | `read:composers` | `list_composers`, `get_composer_count` | | `read:composer` | `get_composer` | | `create:composer` | `create_composer` | | `update:composer` | `update_composer` | | `delete:composer` | `delete_composer` | | `read:analytics` | `get_dashboard`, `get_entity_stats`, `get_top_tracks` | | `read:briefs` | `list_briefs`, `get_brief_submissions` | ## Suggested key profiles [#suggested-key-profiles] **Read-only assistant.** Safe to hand to anyone who should look but not touch: ```text read:tracks, read:playlists, read:composers, read:composer, read:analytics, read:briefs ``` **Librarian.** Can tag, organise and build playlists, but not upload or delete: ```text read:tracks, update:tracks, read:playlists, create:playlists, update:playlists, read:composers, read:composer, read:analytics, read:briefs ``` **Everything.** Every tool works: ```text read:tracks, create:tracks, update:tracks, read:playlists, create:playlists, update:playlists, read:composers, read:composer, create:composer, update:composer, delete:composer, read:analytics, read:briefs ``` A key created from **Settings → Integrations** has this full set. Narrow it in **Tools → Integration**. ## Composer scopes [#composer-scopes] Listing and counting use `read:composers`; fetching one uses `read:composer`. Grant both or the assistant will be able to list composers and then fail to open any of them. ## Credits [#credits] `search_tracks` costs one credit per call from the connected account's balance, the same as [`POST /tracks/search`](/api/search). No other tool is metered. ## Revoking access [#revoking-access] Revoke or rotate the key in Postal. OAuth tokens minted from it stop working on their next refresh, at most one hour later. The direct-header path stops immediately. # Compact mode (/mcp/compact-mode) Assistants pay for every token a tool returns. The Postal MCP server therefore returns **compact** objects by default and lets you opt into the raw API payload per call. ## The `detail` parameter [#the-detail-parameter] Eight read tools accept `detail: "compact" | "full"`: `list_tracks`, `get_track`, `search_tracks`, `list_playlists`, `get_playlist`, `get_tag_taxonomy`, `list_composers`, `get_composer`. There is no server-wide switch. Pass `detail: "full"` on the call that needs it. ## What every response looks like [#what-every-response-looks-like] Each tool returns a one-line human summary followed by pretty-printed JSON. The summary is computed from the original payload, so counts and paging cursors stay accurate even when the body is trimmed. ```text Found 42 tracks (showing 20, next cursor: ck_…) [ { "id": "…", "title": "Night Drive", "artist": "…", "bpm": 92, … } ] ``` ## What compact keeps [#what-compact-keeps] | Object | Kept | Dropped | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | Track | `id`, `title`, `artist`, `durationSec`, `bpm`, `key`, `createdAt`, `link`, `cid`, tag names per category | audio file URLs, shareholders, share-link internals, per-tag confidence | | Playlist | metadata, `publicLink`, per-track `{ id, title, artist, durationSec, order }` | download links, full audio profiles | | Composer | composer fields, linked tracks as `{ id, name }` (max 25, `tracksTruncated: true` beyond that) | full track objects | | Taxonomy | plain name arrays per category | ids, codes, ordering | | Search hit | `id`, `title`, `artist`, `bpm`, `key`, `durationSec`, `genres`, `moods`, `score`, first two `reason`s, plus the `interpretation` block | remaining reasons, raw feature vectors | | Dashboard | everything except `entityImage` on activity items (always trimmed) | | `list_tracks` also asks the API for a sparse field set, so trimming happens server-side as well as in the MCP layer. ## Mutations [#mutations] Write tools do not echo the object back. They return a confirmation such as: ```json { "ok": true, "playlistId": "…", "added": 3 } ``` Exceptions that return the full created object: `upload_track_by_url`, `create_playlist`, and the tagging read and poll tools. ## Soft failures [#soft-failures] When the API answers with `{ ok: false, … }` the server converts it into a tool error so the assistant sees `Error: (missing: …)` rather than a successful-looking payload. # Connect a client (/mcp/connect) You need a Postal account and an API key. Keys are created in **Settings → Integrations** in the Postal app. See [API keys](/getting-started/api-keys) for scopes and rotation. The server speaks Streamable HTTP at `https://mcp.postal.music/mcp`. Clients that support remote servers connect to that URL directly. Clients that only launch local `stdio` servers can bridge through `mcp-remote` (last section). ## Claude (web, desktop and mobile) [#claude-web-desktop-and-mobile] Claude connects through a **custom connector**. This is the recommended path and the one we support first. Open **Settings → Connectors** in Claude. Choose **Add custom connector** and enter `https://mcp.postal.music/mcp`. Claude opens a Postal page. Paste your API key and approve. The key is exchanged for an OAuth token; Claude never sees the key itself. Start a chat and enable the **Postal** connector. Try: *"How many tracks do I have, and which genre dominates?"* Tokens last one hour and refresh automatically for 30 days. Revoking the API key in Postal disconnects the connector immediately. ## Claude Code [#claude-code] ```bash claude mcp add --transport http postal https://mcp.postal.music/mcp ``` The first tool call opens the same authorisation page in your browser. To skip the browser flow, pass the key as a header instead: ```bash claude mcp add --transport http postal https://mcp.postal.music/mcp \ --header "X-API-Key: YOUR_API_KEY" ``` ## Cursor [#cursor] Add the server to `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` globally: ```json title=".cursor/mcp.json" { "mcpServers": { "postal": { "url": "https://mcp.postal.music/mcp", "headers": { "X-API-Key": "YOUR_API_KEY" } } } } ``` Omit `headers` to use the OAuth flow instead; Cursor will prompt you to authorise on first use. ## Any other client [#any-other-client] For clients with native remote-server support, use the URL above with either OAuth or an `X-API-Key` header. Both `X-API-Key: ` and `Authorization: Bearer ` are accepted. For `stdio`-only clients (older Claude Desktop builds, some IDE plugins), bridge through [`mcp-remote`](https://www.npmjs.com/package/mcp-remote): ```json { "mcpServers": { "postal": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.postal.music/mcp", "--header", "X-API-Key:${POSTAL_API_KEY}" ], "env": { "POSTAL_API_KEY": "YOUR_API_KEY" } } } } ``` ## Verify the connection [#verify-the-connection] Ask the assistant to call `get_track_count`. A working connection returns a number in under a second. If you get a scope error, see [Auth and scopes](/mcp/auth-and-scopes). You can also check the server itself without credentials: ```bash curl https://mcp.postal.music/health curl https://mcp.postal.music/tools # full tool catalog with JSON schemas ``` # MCP server overview (/mcp) The Postal MCP server exposes your library to AI assistants through the [Model Context Protocol](https://modelcontextprotocol.io). Once connected, an assistant can search your tracks by describing the music, read and write tags, build playlists, look up composers, check listening analytics and trace where a track has been submitted. It is a hosted, remote server. There is nothing to install or run locally. | | | | ------------ | ---------------------------------------------------------------------- | | Server URL | `https://mcp.postal.music/mcp` | | Transport | Streamable HTTP (stateless) | | Auth | OAuth 2.1 with PKCE, or an `X-API-Key` header | | Tools | 39 (see the [tool reference](/mcp/tools)) | | Version | 1.6.0, semantic versioning (see [Versioning](/mcp/versioning)) | | Live catalog | [`GET https://mcp.postal.music/tools`](https://mcp.postal.music/tools) | ## What it can do [#what-it-can-do] "Find me a warm, downtempo instrumental around 90 BPM with no vocals" ranks your whole library against the brief. Trigger AI tagging, review the suggestions, apply or correct them. Corrections train the tagging model. Create, reorder and share playlists from a conversation. Public links come back ready to paste. Weekly dashboard, per-track engagement and top-track rankings, with date ranges. Keep shareholder records (PRO, IPI, publisher) in sync with your other systems. See every brief a track went to, and every submission a brief received. ## How it fits together [#how-it-fits-together] Every tool call is translated into one or more requests against the [REST API](/api) using the API key you connected with. The server never stores your audio, and the key's [scopes](/mcp/auth-and-scopes) bound what an assistant can do. ## Next steps [#next-steps] # Example prompts (/mcp/prompts) These are prompts we use ourselves. Each one is a whole workflow the assistant carries out by chaining tools, so you do not need to name the tools. ## Find music for a brief [#find-music-for-a-brief] > A sync supervisor needs a warm, mid-tempo indie-folk instrumental for a > coffee ad, 60–90 seconds, no vocals. Shortlist five and build a playlist I > can send them. Uses `search_tracks` (with `hasVocals: false` and a duration window), `create_playlist`, then returns the public link from `get_playlist`. ## Clean up tags across a batch [#clean-up-tags-across-a-batch] > Tag everything I uploaded this week, then show me the suggestions you're > least sure about before applying anything. Uses `list_tracks` (sorted by `-created_at`), `batch_auto_tag`, `wait_for_auto_tag`, `get_suggested_tags`, and `apply_suggested_tags` only after you confirm. ## Fix metadata in bulk [#fix-metadata-in-bulk] > Every track whose artist is "koens" should be "Koen Schuite", and add the > record label "Postal Records" where it's empty. Uses `list_tracks` and `update_track_details` per track. ## Replace a track's audio [#replace-a-tracks-audio] > Here is the final master for "Alpha": [https://cdn.example.com/alpha\_master.wav](https://cdn.example.com/alpha_master.wav). > Swap it in, label it "master", and keep the old mix in the history. Uses `list_tracks` to find the track, `upload_track_version` (active by default, so playlists and share links pick up the new file), then `list_track_versions` to confirm which version is active. ## Weekly report [#weekly-report] > Give me a short weekly update: uploads, listens, downloads, and my three > most-played tracks this month. Uses `get_dashboard` and `get_top_tracks` with a `since` date. ## Find duplicates before a delivery [#find-duplicates-before-a-delivery] > I'm delivering the catalogue to a distributor next week. Are there any > duplicate files I should merge first? Uses `find_duplicate_tracks`. Tracks without a content hash are listed under `missingCid`; ask the assistant to trigger analysis for those. ## Keep composer records in sync [#keep-composer-records-in-sync] > Here's a CSV of our writers with PRO and IPI numbers. Make sure every one of > them exists as a composer in Postal, and update the ones that changed. Uses `list_composers`, `create_composer` and `update_composer`. ## Trace a pitch [#trace-a-pitch] > Where has "Night Drive (v3)" been submitted, and did any of those briefs > close? Uses `list_tracks` to resolve the id, then `get_track_submissions`. ## Tips [#tips] * **Say what must be true.** BPM windows, "instrumental only", a key or a language are hard filters on `search_tracks`. Put them in the prompt as constraints and the assistant will pass them as parameters instead of hoping the ranking gets it right. * **Ask for the taxonomy first** when setting tags by hand. Invalid tag names are rejected. * **Search costs credits.** One credit per `search_tracks` call by default. Listing and filtering by name or tag is free. # Tool reference (/mcp/tools) The server registers 39 tools. The always-current, machine-readable catalog with full JSON schemas is served without auth at [`https://mcp.postal.music/tools`](https://mcp.postal.music/tools). Tools marked **compact** accept a `detail` parameter: `"compact"` (default) returns trimmed objects for low token cost, `"full"` returns the raw API payload. See [Compact mode](/mcp/compact-mode). Every tool needs an API key carrying the listed [scope](/mcp/auth-and-scopes). A missing scope surfaces as an error ending in *"the API key is missing a required scope for this action"*. ## Tracks [#tracks] ### `list_tracks` compact [#list_tracks-compact] Search and list tracks with optional filters (query, tags, date range, sorting). Use this when you already know the name, tag or date you want. | Parameter | Type | Notes | | --------- | -------------------------------------------- | ------------------------- | | `query` | string | Name match | | `tags` | string\[] | Tag names | | `tagMode` | `any` \| `all` | How multiple tags combine | | `limit` | 1–200 | | | `cursor` | string | From the previous page | | `sort` | `created_at`, `-created_at`, `name`, `-name` | | | `detail` | `compact` \| `full` | | Calls `GET /api/v1/tracks`. Scope `read:tracks`. ### `get_track_count` [#get_track_count] Total number of tracks in the library. Cheap, no paging. Calls `GET /api/v1/tracks/count`. Scope `read:tracks`. ### `get_track` compact [#get_track-compact] One track with its audio profile and tags. `full` adds shareholders and per-tag metadata. | Parameter | Type | Notes | | --------- | ------------------- | -------- | | `trackId` | string | required | | `detail` | `compact` \| `full` | | Calls `GET /api/v1/tracks/single`. Scope `read:tracks`. ### `update_track_details` [#update_track_details] Update title, artist, album, BPM, key, year, ISRC, composer, record label or cover. | Parameter | Type | | ------------------------------------------------------------------------------------- | ---------------- | | `trackId` | string, required | | `title`, `artist`, `album`, `key`, `year`, `isrc`, `composer`, `recordLabel`, `cover` | string | | `bpm` | number | Calls `PATCH /api/v1/tracks/details`. Scope `update:tracks`. ### `upload_track_by_url` [#upload_track_by_url] Upload up to 25 tracks from public audio URLs in one call. Each item may carry metadata and an audio profile. Returns created track IDs and any per-item failures. | Parameter | Type | Notes | | ---------------------- | ------------ | ----------------------------------------------------------------------------------- | | `items` | array (1–25) | required | | `items[].fileUrl` | URL | required | | `items[].visible` | boolean | | | `items[].details` | object | `title`, `artist`, `album`, `bpm`, `key`, `year`, `isrc`, `composer`, `recordLabel` | | `items[].audioProfile` | object | | Calls `POST /api/v1/tracks/upload/url`. Scope `create:tracks`. ### `find_duplicate_tracks` [#find_duplicate_tracks] Exact-duplicate tracks grouped by content hash. Renamed copies are caught, re-encoded files are not. Tracks that have not been analysed yet have no hash and are reported under `missingCid`. | Parameter | Type | Default | | --------- | ----- | ------- | | `limit` | 1–100 | 20 | | `offset` | ≥ 0 | 0 | Calls `GET /api/v1/tracks/duplicates`. Scope `read:tracks`. ## Track versions [#track-versions] A track keeps every audio file uploaded to it as a version; one is active. These tools replace a track's audio while keeping its ID, tags, playlists and share links. See [Track versions](/api/track-versions) for the semantics. ### `list_track_versions` compact [#list_track_versions-compact] Upload history of a track with the active version flagged and a one-hour download link per version. | Parameter | Type | Notes | | --------- | ------------------ | -------- | | `trackId` | string | required | | `detail` | `compact` / `full` | | Calls `GET /api/v1/tracks/{trackId}/versions`. Scope `read:tracks`. ### `upload_track_version` [#upload_track_version] Add a new audio version from a public URL. Active by default, which re-runs analysis on the new audio; `activate: false` only adds it to the history. | Parameter | Type | Notes | | ---------- | ------- | ------------------------ | | `trackId` | string | required | | `fileUrl` | URL | required | | `name` | string | defaults to the filename | | `label` | string | defaults to `v` | | `activate` | boolean | default `true` | Calls `POST /api/v1/tracks/{trackId}/versions/url`. Scope `update:tracks`. ### `activate_track_version` [#activate_track_version] Make an existing version the file the track plays and shares. | Parameter | Type | | ----------- | ------ | | `trackId` | string | | `versionId` | string | Calls `POST /api/v1/tracks/{trackId}/versions/{versionId}/activate`. Scope `update:tracks`. ### `update_track_version` [#update_track_version] Rename, relabel or hide a version. Only the fields passed change. | Parameter | Type | | ---------------------- | ------- | | `trackId`, `versionId` | string | | `name`, `label` | string | | `visible` | boolean | Calls `PATCH /api/v1/tracks/{trackId}/versions/{versionId}`. Scope `update:tracks`. ### `delete_track_version` [#delete_track_version] Permanently delete an inactive version and its file. Deleting the active version fails; activate another one first. | Parameter | Type | | ---------------------- | ------ | | `trackId`, `versionId` | string | Calls `DELETE /api/v1/tracks/{trackId}/versions/{versionId}`. Scope `update:tracks`. ## Search [#search] ### `search_tracks` compact · uses credits [#search_tracks-compact--uses-credits] Find tracks by describing the music in plain language: mood, genre, energy, instrumentation, a reference artist, or what the music is for. The description is parsed into musical targets and the whole library is ranked against it. The response includes an `interpretation` block showing how the query was read. Anything that must be strictly true belongs in the dedicated parameters, not the prose. | Parameter | Type | Notes | | ---------------------------------- | ------------------- | ----------------- | | `query` | string (1–2000) | required | | `limit` | 1–50 | default 20 | | `bpmMin`, `bpmMax` | 1–300 | hard filter | | `durationMinSec`, `durationMaxSec` | number | hard filter | | `hasVocals` | boolean | hard filter | | `key` | string | hard filter | | `language` | string | hard filter | | `seedTrackIds` | string\[] (≤ 50) | "more like these" | | `excludeTrackIds` | string\[] (≤ 200) | | | `detail` | `compact` \| `full` | | Calls `POST /api/v1/tracks/search`. Scope `read:tracks`. This tool runs an AI ranking pass and **consumes one account credit per call** by default. Use `list_tracks` when you already know the exact name, tag or date. ## Tagging [#tagging] ### `get_tag_taxonomy` compact [#get_tag_taxonomy-compact] All valid tag values grouped by category: genres, moods, instruments, vocal types, lyric themes, audio types, tempos, eras, characters and movements. Call this before writing tags. `full` returns raw rows with ids and ordering. Calls `GET /api/v1/taxonomy`. Scope `read:tracks`. ### `update_track_tags` [#update_track_tags] Replace the tags in each category you provide. Categories you omit are left alone. Every change is recorded as tag feedback that trains the auto-tagging model, so only set tags you are confident about. | Parameter | Type | | ----------------------------------------------------------------------------------------------------------------------- | ---------------- | | `trackId` | string, required | | `genres`, `moods`, `types`, `vocals`, `instruments`, `lyricThemes`, `eras`, `characters`, `movements`, `additionalTags` | string\[] | Calls `PATCH /api/v1/tracks/tags`. Scope `update:tracks`. ### `trigger_auto_tag` [#trigger_auto_tag] Start AI tagging for one track. Asynchronous: follow up with `wait_for_auto_tag` or `get_suggested_tags`. Calls `POST /api/v1/tracks/auto-tag`. Scope `update:tracks`. ### `batch_auto_tag` [#batch_auto_tag] Queue AI tagging for many tracks. Takes `trackIds` (string\[], at least one). Calls `POST /api/v1/tracks/auto-tag/batch`. Scope `update:tracks`. ### `get_suggested_tags` [#get_suggested_tags] AI-suggested tags pending review, plus analysis status and intelligence data. Takes `trackId`. Calls `GET /api/v1/tracks/suggested-tags`. Scope `read:tracks`. ### `wait_for_auto_tag` [#wait_for_auto_tag] Block until a track's analysis is `completed` or `failed`, then return the suggested tags. Optionally triggers the analysis first. | Parameter | Type | Default | | --------------------- | ------- | -------- | | `trackId` | string | required | | `trigger` | boolean | false | | `timeoutSeconds` | 5–300 | 90 | | `pollIntervalSeconds` | 2–30 | 3 | Scope `read:tracks`, plus `update:tracks` when `trigger` is true. ### `apply_suggested_tags` [#apply_suggested_tags] Accept the AI suggestions and **overwrite** the track's tags with them. Review with `get_suggested_tags` first if unsure. Takes `trackId`. Calls `POST /api/v1/tracks/apply-suggested-tags`. Scope `update:tracks`. ### `upload_artwork` [#upload_artwork] Set cover art for a track from a public image URL or base64 data. | Parameter | Type | Notes | | ---------- | ------ | ----------------------- | | `trackId` | string | required | | `url` | URL | one of `url` / `base64` | | `base64` | string | | | `filename` | string | default `cover.jpg` | Calls `POST /api/v1/tracks/cover-art`. Scope `update:tracks`. ## Playlists [#playlists] ### `list_playlists` compact [#list_playlists-compact] Playlists with track counts. Parameters `limit` (1–200), `cursor`, `detail`. Calls `GET /api/v1/playlists`. Scope `read:playlists`. ### `get_playlist` compact [#get_playlist-compact] One playlist with its tracks. Compact returns track micro-summaries and the public share link; `full` adds download links and full audio profiles. Parameters `playlistId` (required), `detail`. Calls `GET /api/v1/playlists/single/{id}`. Scope `read:playlists`. ### `create_playlist` [#create_playlist] | Parameter | Type | Notes | | ------------- | --------------- | ----------------- | | `name` | string (≤ 120) | required | | `description` | string (≤ 2000) | | | `trackIds` | string\[] (≥ 1) | required | | `dedupe` | boolean | drop repeated ids | Calls `POST /api/v1/playlists`. Scope `create:playlists`. ### `update_playlist` [#update_playlist] Rename or re-describe a playlist. Parameters `playlistId` (required), `name`, `description`. Calls `PATCH /api/v1/playlists/{id}`. Scope `update:playlists`. ### `add_tracks_to_playlist` [#add_tracks_to_playlist] Parameters `playlistId`, `trackIds` (both required). Calls `POST /api/v1/playlists/{id}/tracks`. Scope `create:playlists`. ### `remove_track_from_playlist` [#remove_track_from_playlist] Parameters `playlistId`, `trackId` (both required). Calls `DELETE /api/v1/playlists/{id}/tracks/{trackId}`. Scope `update:playlists`. ### `reorder_playlist` [#reorder_playlist] Provide `trackIds` in the desired order. Parameters `playlistId`, `trackIds`. Calls `PATCH /api/v1/playlists/{id}/order`. Scope `update:playlists`. ## Composers [#composers] Composers are the shareholders on a track: writers, producers and publishers with their PRO and IPI details. ### `list_composers` compact [#list_composers-compact] Composers with the tracks linked to each. Compact caps linked tracks at 25 and sets `tracksTruncated`. Parameters `cursor` (a composer id), `detail`. Calls `GET /api/v1/composers`. Scope `read:composers`. ### `get_composer_count` [#get_composer_count] Calls `GET /api/v1/composers/count`. Scope `read:composers`. ### `get_composer` compact [#get_composer-compact] Parameters `composerId` (required), `detail`. Calls `GET /api/v1/composers/single`. Scope `read:composer` (singular; see [scopes](/mcp/auth-and-scopes#composer-scopes)). ### `create_composer` [#create_composer] | Parameter | Type | | --------------------------------------- | ---------------- | | `fullName` | string, required | | `email` | email, required | | `artistName`, `publisher`, `pro`, `ipi` | string | Calls `POST /api/v1/composers`. Scope `create:composer`. ### `update_composer` [#update_composer] `composerId` plus any of the fields above. Calls `PATCH /api/v1/composers`. Scope `update:composer`. ### `delete_composer` [#delete_composer] Permanent. Takes `composerId`. Calls `DELETE /api/v1/composers`. Scope `delete:composer`. ## Analytics [#analytics] ### `get_dashboard` [#get_dashboard] This week's summary: tracks uploaded, views and downloads received, recent activity, library totals, dominant genre and mood. Calls `GET /api/v1/analytics/dashboard`. Scope `read:analytics`. ### `get_entity_stats` [#get_entity_stats] Plays, views, downloads, saves, shares and comments for specific tracks or playlists, optionally within a date range. | Parameter | Type | Notes | | ---------------- | --------------------- | -------- | | `entity` | `track` \| `playlist` | required | | `ids` | string\[] (1–50) | required | | `since`, `until` | ISO date | | Calls `GET /api/v1/analytics/stats`. Scope `read:analytics`. ### `get_top_tracks` [#get_top_tracks] Rank tracks by one metric. | Parameter | Type | Notes | | ---------------- | ---------------------------------------------------------------------- | ---------- | | `metric` | `plays` \| `views` \| `downloads` \| `saved` \| `shares` \| `comments` | required | | `since`, `until` | ISO date | | | `limit` | 1–50 | default 10 | Calls `GET /api/v1/analytics/top`. Scope `read:analytics`. ## Briefs [#briefs] Briefs are calls for submissions: an opportunity you own that other people send tracks to. ### `list_briefs` [#list_briefs] Briefs you own with submission and invite counts. Calls `GET /api/v1/briefs`. Scope `read:briefs`. ### `get_brief_submissions` [#get_brief_submissions] Every submission a brief received, with the submitted tracks or external links and who was invited. Takes `briefId`. Calls `GET /api/v1/briefs/{id}/submissions`. Scope `read:briefs`. ### `get_track_submissions` [#get_track_submissions] Every brief a track was submitted to, with brief status, deadline and whether it went via an email invite. Takes `trackId`. Calls `GET /api/v1/tracks/submissions`. Scope `read:tracks`. # Troubleshooting (/mcp/troubleshooting) ## "The API key is missing a required scope" [#the-api-key-is-missing-a-required-scope] The key you connected with does not carry the scope the tool needs. Check the [tool reference](/mcp/tools) for the scope, then create a key with that scope in Postal and reconnect. Note the composer tools use two different read scopes; see [Auth and scopes](/mcp/auth-and-scopes#composer-scopes). ## The connector asks me to authorise again [#the-connector-asks-me-to-authorise-again] OAuth access tokens last one hour and refresh silently for 30 days. A new authorisation prompt after that window is expected. If it happens sooner, the API key was revoked or rotated in Postal, or the server's signing secret was rotated. Reconnect with a current key. ## Search returns nothing useful [#search-returns-nothing-useful] `search_tracks` ranks tracks that have finished audio analysis. Newly uploaded tracks appear once analysis completes; call `wait_for_auto_tag` on them first. Very restrictive hard filters (a narrow BPM window plus a key plus a language) can also empty the result set. Loosen one at a time. ## Search says I'm out of credits [#search-says-im-out-of-credits] `search_tracks` is credit-metered. Top up in **Settings → Usage** in the Postal app, or use `list_tracks` for name and tag lookups, which are free. ## A stdio-only client can't connect [#a-stdio-only-client-cant-connect] The server is HTTP-only; there is no local process to run. Bridge with `mcp-remote` as shown in [Connect a client](/mcp/connect#any-other-client). ## Check the server is up [#check-the-server-is-up] ```bash curl https://mcp.postal.music/health ``` Returns the deployed version. If this fails, the problem is on our side; see [status.postal.music](https://status.postal.music) or contact support. # Versioning (/mcp/versioning) The MCP server is a hosted service, so you always talk to the latest release. Versioning exists so you can tell what changed, and so tool names and inputs you built a workflow on do not move under you without warning. ## Semantic versions [#semantic-versions] The server carries a [semantic version](https://semver.org), currently **1.6.0**. What each part means for a client: | Bump | What it can contain | Example | | ------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | | **Major** (`2.0.0`) | A tool removed or renamed; a required input added; a compact response field removed; a move to a new REST API version | `get_track_count` folded into another tool | | **Minor** (`1.6.0`) | New tools; new optional inputs; new fields in compact responses; new `/health` or `/tools` fields | five track-version tools added in 1.6.0 | | **Patch** (`1.4.1`) | Fixes, description wording, deployment changes with no visible surface change | Compact track serializer gains tag groups | Within a major version a tool you call today keeps its name, its inputs keep their names and types, and the fields in its compact response stay present. ## Where to read the version [#where-to-read-the-version] **On `initialize`.** Every MCP client receives the version in `serverInfo`, along with an `instructions` string that names the version, the REST API it targets and this policy. Clients that show server instructions to the model show this too. ```json { "serverInfo": { "name": "postal", "version": "1.6.0" }, "instructions": "Postal MCP server 1.6.0 (REST API v1). Tool names and input schemas are stable within a major version; …" } ``` **Health endpoint.** No authentication. ```bash curl https://mcp.postal.music/health ``` ```json { "status": "ok", "service": "postal-mcp", "version": "1.6.0", "apiVersion": "v1" } ``` **Tool catalog.** The same fields precede the list of tools. ```bash curl https://mcp.postal.music/tools ``` ```json { "version": "1.6.0", "apiVersion": "v1", "count": 39, "tools": [ … ] } ``` `apiVersion` is the [REST API version](/api/versioning) the tools are written against. A change there is always a major release of the server. ## Deprecated tools [#deprecated-tools] MCP has no built-in deprecation flag, so a tool being retired says so in its description: ```text [Deprecated — removed in 2.0.0, use get_library_stats instead] Get the total number of tracks in your library ``` A deprecated tool keeps working exactly as before. It stays for at least one minor release before the major version that removes it, and both the deprecation and the removal are listed in the [changelog](/changelog). Prefer the named replacement as soon as you see the prefix; assistants reading the catalog are told the same in the server instructions. ## Full responses follow the REST contract [#full-responses-follow-the-rest-contract] Read tools return compact objects by default (see [Compact mode](/mcp/compact-mode)). Fields in compact objects are governed by the server version above. Passing `detail: "full"` returns the REST API's own payload, which is governed by the [REST versioning rules](/api/versioning) instead: fields are only ever added within `/api/v1`. ## Protocol versions [#protocol-versions] The server speaks the Model Context Protocol through the official TypeScript SDK and negotiates the protocol version with each client on `initialize`. It currently accepts `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05` and `2024-10-07`. Dropping an old protocol version is a minor release, since maintained clients always negotiate the newest one they share with the server. ## Pinning [#pinning] There is no way to pin the hosted server to an older release. If a workflow needs a surface that does not move at all, call the [REST API](/api) directly: `/api/v1` is frozen by contract, the MCP server is not. # AI assistant (/use-cases/ai-assistant) Connect the [MCP server](/mcp) and an assistant can do most of what the other recipes describe, in conversation, without writing code. ## Set up [#set-up] 1. Create an [API key](/getting-started/api-keys). For a first run, keep the default full scope set; narrow it later. 2. [Connect a client](/mcp/connect). For Claude, that is one custom connector pointing at `https://mcp.postal.music/mcp`. 3. Ask: *"How many tracks do I have, and what's my dominant genre?"* ## What works well [#what-works-well] * **Questions about the catalogue.** Counts, filters by tag and date, who is on a track, where it was submitted. * **Organising.** Building and reordering playlists, renaming, fixing metadata in bulk. * **Tagging.** Triggering analysis, reviewing suggestions, applying or correcting them. * **Finding music for a brief.** `search_tracks` with hard constraints, then a playlist link. * **Weekly reporting.** Dashboard and top tracks, summarised in plain language. See [Example prompts](/mcp/prompts) for phrasing that gets good results. ## What to be careful with [#what-to-be-careful-with] * `update_track_tags` replaces categories and trains the model. Ask the assistant to show the change before applying it. * `delete_composer` is permanent. * `search_tracks` costs a credit per call. Ask for a name or tag lookup when you already know what you want. * Give a read-only key to assistants that only need to answer questions. See [Suggested key profiles](/mcp/auth-and-scopes#suggested-key-profiles). ## Building your own agent [#building-your-own-agent] The MCP server works with any client library that speaks Streamable HTTP. Fetch the live tool catalog from [`https://mcp.postal.music/tools`](https://mcp.postal.music/tools) to get JSON schemas for every tool, and authenticate with `X-API-Key`. Compact responses are the default; pass `detail: "full"` when your agent needs the raw object. See [Compact mode](/mcp/compact-mode). # Catalogue sync (/use-cases/catalogue-sync) A label, publisher or distributor keeps its own database of works and recordings. Postal holds the audio and the working metadata. This recipe keeps the two aligned in both directions. ## Into Postal [#into-postal] 1. **Ingest audio by URL.** Put the files somewhere Postal can fetch them (a signed S3 or R2 URL is fine) and call [`POST /tracks/upload/url`](/api/tracks#upload-by-url) with up to 25 items per request. Pass `details` so ISRC, artist and album land on the first write. 2. **Create composers once.** For each writer or publisher, call [`POST /composers`](/api/composers#create-a-composer) with their PRO and IPI. Keep a map from your internal id to the Postal `id`. 3. **Patch metadata as it changes.** Use [`PATCH /tracks/details`](/api/tracks#update-details) when a title, ISRC or release year changes on your side. Scopes: `create:tracks`, `update:tracks`, `create:composer`, `update:composer`, `read:composers`, `read:composer`. ## Out of Postal [#out-of-postal] Attach a [webhook](/api/webhooks) to the key and handle: | Event | What to do | | ---------------------------------------------------------- | ------------------------------------------------- | | `track.audio_profile_completed` | Read BPM, key, duration and tags into your system | | `track.updated` | Pull the changed details | | `shareholder.linked_to_track`, `shareholder.track_updated` | Update split percentages | | `composer.updated` | Refresh PRO and IPI | Verify every delivery with the signature recipe, deduplicate on `X-Postal-Event-Id`, and re-fetch with the API rather than trusting the payload for anything you write to a ledger. ## Reconcile [#reconcile] Once a day, page through [`GET /tracks`](/api/tracks#list-tracks) with `fields=id,name,createdAt,cid` and diff against your records. Use [`GET /tracks/duplicates`](/api/tracks#find-duplicates) to catch the same master uploaded twice under different names. ## Notes [#notes] * Uploads are asynchronous. A track exists immediately but its audio profile is empty until analysis completes. * Email is unique per composer within an account. Search before you create. * Rate limit is 100 requests per minute per IP. Batch uploads and space reconciliation runs accordingly. # Use cases (/use-cases) Keep a label or publisher's system of record in step with Postal: uploads by URL, metadata patches, composer records, webhooks back. Turn a brief into a shortlist with natural-language search and ship it as a playlist link. Auto-tag every upload, review low-confidence suggestions, write corrections back. Weekly engagement digests, top-track leaderboards, partner platform reporting. Let Claude or Cursor work in a library through the MCP server. ## Who builds on Postal [#who-builds-on-postal] * **Labels and publishers** syncing a catalogue and split sheets with their royalty or rights system. * **Sync agencies and music supervisors** searching a roster for briefs and sending shortlists. * **Producers with large libraries** automating tagging, deduplication and clean-up. * **Tool builders** adding Postal as a source or destination in their own product. * **Anyone with an AI assistant** who wants to ask questions of their music instead of scrolling. # Reporting (/use-cases/reporting) ## Weekly digest for an artist or label [#weekly-digest-for-an-artist-or-label] Every Monday: 1. [`GET /analytics/dashboard`](/api/analytics#dashboard) for the headline numbers and the dominant genre and mood. 2. [`GET /analytics/top?metric=plays&since=<7 days ago>`](/api/analytics#top-entities) and again for `downloads`. 3. Post the result to Slack, email, or a sheet. Scope: `read:analytics`. ## Per-release performance [#per-release-performance] For a release with a known set of track ids, call [`GET /analytics/stats?entity=track&ids=…`](/api/analytics#entity-stats) with `since` set to the release date. Up to 50 ids per call. ## Where a track went [#where-a-track-went] Combine [`GET /tracks/submissions`](/api/tracks#track-submissions) with the stats above to see whether a pitch turned into listens. ## Partner platform reporting [#partner-platform-reporting] Partners that run file requests on Postal get a [platform key](/api/platform) and can pull: * every file request with free and paid submission counts, * per-request submission lists with a stable, anonymised submitter id, * accounts acquired through each request. Platform keys are read-only and issued by Postal. ## Notes [#notes] * Engagement comes from the activity log and updates within seconds of the event. * Date ranges are inclusive and in UTC. * The dashboard's "this week" is the current calendar week, not a rolling 7 days. # Sync and pitching (/use-cases/sync-pitching) A supervisor sends a brief. You want the five best matches from a roster and a link to send back, without opening the app. ## Flow [#flow] 1. **Search.** Call [`POST /tracks/search`](/api/search) with the brief as `query`. Move hard requirements into `constraints`: duration window, instrumental, BPM range, key. 2. **Read the interpretation.** If `interpretation.genres` or `moods` is off, name the genre in the query and search again. Put tracks you have already pitched into `excludeTrackIds`. 3. **Make the playlist.** Call [`POST /playlists`](/api/playlists#create-a-playlist) with the top ids and `suggestedName` from the search. The response includes `publicLink`. 4. **Send the link.** Anyone can listen without an account. 5. **Track it.** [`GET /tracks/submissions`](/api/tracks#track-submissions) shows every brief a track went to if you also log the pitch as a brief submission in the app. ## Example [#example] ```bash curl -X POST https://api.postal.music/api/v1/tracks/search \ -H "X-API-Key: $POSTAL_API_KEY" -H "Content-Type: application/json" \ -d '{ "query": "uplifting indie-folk with handclaps for a travel ad, builds to a big chorus", "limit": 10, "constraints": { "durationRange": [60, 150], "hasVocals": true, "language": "en" } }' ``` Then: ```bash curl -X POST https://api.postal.music/api/v1/playlists \ -H "X-API-Key: $POSTAL_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "Travel ad shortlist", "trackIds": ["cm1x…","cm1y…","cm1z…"], "dedupe": true }' ``` ## Cost [#cost] Each search is one credit. Listing and playlist calls are free. A typical pitch is two or three searches. ## With an assistant instead [#with-an-assistant-instead] The same flow is one sentence in a connected MCP client: *"Find five uplifting indie-folk tracks with vocals, 60–150 seconds, for a travel ad, and make me a playlist link."* See [Example prompts](/mcp/prompts). # Tagging pipeline (/use-cases/tagging-pipeline) Postal's tagging model proposes genres, moods, instruments, vocal types and more for every track. This recipe runs it across a library and puts a human in the loop only where it matters. ## Steps [#steps] 1. **Find untagged tracks.** Page [`GET /tracks`](/api/tracks#list-tracks) with `fields=id,name,audio_profile` and pick tracks whose profile has no genres. 2. **Queue analysis.** Send ids in batches to [`POST /tracks/auto-tag/batch`](/api/tagging#batch-auto-tag). 3. **Wait.** Handle `track.audio_profile_completed` [webhooks](/api/webhooks), or poll [`GET /tracks/suggested-tags`](/api/tagging#get-suggested-tags) until `analysisStatus` is `completed`. 4. **Apply or review.** For tracks where `intelligence.ready` is true and the suggestion looks complete, call [`POST /tracks/apply-suggested-tags`](/api/tagging#apply-suggested-tags). Route the rest to a review queue. 5. **Write corrections.** When a reviewer changes something, send the full corrected category with [`PATCH /tracks/tags`](/api/tagging#update-tags). Corrections are recorded as training feedback. Scopes: `read:tracks`, `update:tracks`. ## Validating tag names [#validating-tag-names] Load [`GET /taxonomy`](/api/tagging#taxonomy) once at startup. Reject any tag a reviewer types that is not in it, or map it to the nearest valid name before writing. ## Throughput [#throughput] Analysis runs in Postal's queue and can take from seconds to a few minutes per track depending on load. Queue in batches of a few hundred and let webhooks drive the next step rather than polling every track. ## In an assistant [#in-an-assistant] The MCP `wait_for_auto_tag` tool collapses trigger, poll and read into one call, and `apply_suggested_tags` commits. See the [tool reference](/mcp/tools#tagging).