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.
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-15T08:24:11.000Z",
"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-15T08:24:11.000Z",
"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 | ISO 8601 — when the generation was queued |
completedAt | string | ISO 8601 — when the generation finished or failed |
errorMessage | string | Present only on status: "failed" |
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.