Webhooks
Signed HTTP callbacks when tracks, composers and shares change.
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
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.
Your endpoint must accept POST, respond with any 2xx within a few seconds,
and be reachable from the public internet over HTTPS.
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
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…{
"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
Each key has its own webhook secret, created the first time an event is sent.
Getting your secret
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=<unix seconds>, v1=<hex> where
v1 = HMAC-SHA256(secret, "<t>.<raw body>").
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
- 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-Idto deduplicate. - Switching the webhook off drops any events still pending for that key.