Webhooks
Get notified when a generation finishes — skip polling with a webhook URL
Instead of polling GET /v1/generations/:id until a track is ready, pass a webhook URL when you create the generation. We'll POST the finished (or failed) generation to that URL as soon as it's ready.
Sound effects support the same webhook parameter and payload. The only difference: sound effects carry no musical parameters, so their payload's parameters field is always null.
Reimagine and Remove vocals (POST /v1/reimagine, POST /v1/remove-vocals) take the same webhook parameter and send the same payload, plus a type field — "reimagine" or "remove_vocals" — so one endpoint can tell them apart. For Remove vocals, the musical fields in parameters (bpm, keyscale, timesignature) are null. For Reimagine with a curated style, prompt is an empty string: style prompts stay private. duration is the length of the upload's window. See Reimagine & Remove vocals.
Setting up a webhook
Include webhook in the body of POST /v1/generations:
curl -X POST https://pub.finetuning.ai/v1/generations \
-H "X-API-Key: ft_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"tags": "lofi chill, piano, vinyl crackle",
"duration": 90,
"bpm": 80,
"webhook": "https://your-app.com/finetuning-callback?token=YOUR_SECRET"
}'Requirements:
- Must be a valid HTTPS URL
- Maximum 2048 characters
- Reachable from the public internet (no
localhost, no private IPs)
The request you'll receive
We send the webhook as a POST with a JSON body. The request is fire-once — there is no retry, regardless of whether your server returns 2xx.
POST /hook?token=test123 HTTP/1.1
Host: their-server.com
Content-Type: application/json
User-Agent: finetuning-webhook/1.0
Content-Length: 472Successful generation
{
"id": "gen_abc123def456",
"status": "completed",
"audioUrl": "https://media.finetuning.ai/abc123def456.mp3",
"duration": 90.3,
"prompt": "lofi chill, piano, vinyl crackle",
"parameters": {
"bpm": 80,
"duration": 90,
"keyscale": "A minor",
"timesignature": 4,
"seed": 1729384756
},
"createdAt": "2026-05-15 08:24:11",
"completedAt": "2026-05-15T08:25:47.000Z"
}Failed generation
{
"id": "gen_abc123def456",
"status": "failed",
"audioUrl": null,
"duration": null,
"prompt": "lofi chill, piano, vinyl crackle",
"parameters": {
"bpm": 80,
"duration": 90,
"keyscale": "A minor",
"timesignature": 4,
"seed": 1729384756
},
"createdAt": "2026-05-15 08:24:11",
"completedAt": "2026-05-15T08:25:47.000Z",
"errorMessage": "GPU job timed out after 240s"
}Payload fields
| Field | Type | Description |
|---|---|---|
id | string | Generation ID |
status | string | completed or failed |
audioUrl | string | null | Public download URL. null when status: "failed" |
duration | number | null | Actual length of the rendered MP3 in seconds. null on failure |
prompt | string | The tags/prompt used for this generation |
parameters | object | The parameters you submitted at create time |
parameters.bpm | number | BPM used |
parameters.duration | number | The duration you requested — may differ slightly from top-level duration |
parameters.keyscale | string | Combined key and scale (e.g., "A minor") |
parameters.timesignature | number | Time signature |
parameters.seed | number | Seed used |
createdAt | string | When the generation was queued. UTC, YYYY-MM-DD HH:MM:SS |
completedAt | string | ISO 8601 — when the generation finished or failed |
errorMessage | string | Present only on status: "failed" |
certificate | object | null | Present only when the generation was created with certificate: true. See Certificates below |
type | string | Only on Reimagine and Remove vocals tracks: reimagine or remove_vocals |
createdAt and completedAt are not in the same format: createdAt is a UTC datetime string (2026-05-15 08:24:11), while completedAt is ISO 8601 with a Z. Parse them accordingly.
Certificates
If you created the generation with certificate: true, the completed payload carries one extra object — a generation certificate issued at the moment the track finished:
{
"certificate": {
"number": "FT-A7K2-M9QX-4BQC",
"issuedAt": "2026-05-15 08:25:47",
"verifyUrl": "https://finetuning.ai/verify/FT-A7K2-M9QX-4BQC",
"downloadUrl": "https://pub.finetuning.ai/v1/generations/gen_abc123def456/certificate",
"sizeBytes": 17537
}
}Everything else in the payload is unchanged. The object is null if we couldn't issue the certificate — a certificate problem never stops us delivering your track — and it's absent entirely from generations that didn't request one, from status: "failed" payloads, and from Zapier trigger payloads.
downloadUrl needs your X-API-Key header and has no expiry, but it only works for as long as the generation exists. If you delete tracks after archiving them, fetch the PDF in the same step that archives the MP3 — see Certificates for the full pattern.
Securing your endpoint
Webhooks come from the open internet, so authenticate them before trusting the payload:
- Put a secret in the URL. Include a random token as a query parameter (e.g.,
?token=...) when you submit thewebhookURL. Reject any inbound request that doesn't carry it. We send the URL back to you verbatim — query string and all. - Check the
User-Agent. Every webhook request carriesUser-Agent: finetuning-webhook/1.0. This is a weak filter, not auth. - Match on
id. Drop payloads whoseiddoesn't correspond to a generation you submitted.
Your endpoint should respond within ~10 seconds with a 2xx. Long-running work belongs on a queue inside your service, not inline in the webhook handler.
Receiving in Node.js (Express)
import express from 'express';
const app = express();
app.use(express.json());
app.post('/finetuning-callback', (req, res) => {
if (req.query.token !== process.env.FINETUNING_WEBHOOK_TOKEN) {
return res.status(401).end();
}
// Acknowledge quickly, then handle asynchronously
res.status(200).end();
const { id, status, audioUrl, errorMessage } = req.body;
if (status === 'completed') {
console.log(`Generation ${id} ready at ${audioUrl}`);
} else {
console.error(`Generation ${id} failed: ${errorMessage}`);
}
});
app.listen(3000);Receiving in Python (Flask)
import os
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["FINETUNING_WEBHOOK_TOKEN"]
@app.post("/finetuning-callback")
def finetuning_callback():
if request.args.get("token") != SECRET:
return "", 401
payload = request.get_json()
if payload["status"] == "completed":
print(f"Generation {payload['id']} ready at {payload['audioUrl']}")
else:
print(f"Generation {payload['id']} failed: {payload.get('errorMessage')}")
return "", 200When to use polling instead
Webhooks are great for backend integrations, but polling is simpler when:
- Your client runs in a browser (no public endpoint)
- You're building a CLI or a short-lived script
- You're prototyping and don't want to expose a public endpoint yet
See GET /v1/generations/:id for the polling flow.