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.
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.