Finetuning.aiFinetuning.ai

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: 472

Successful generation

status: completed
{
  "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

status: failed
{
  "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

FieldTypeDescription
idstringGeneration ID
statusstringcompleted or failed
audioUrlstring | nullPublic download URL. null when status: "failed"
durationnumber | nullActual length of the rendered MP3 in seconds. null on failure
promptstringThe tags/prompt used for this generation
parametersobjectThe parameters you submitted at create time
parameters.bpmnumberBPM used
parameters.durationnumberThe duration you requested — may differ slightly from top-level duration
parameters.keyscalestringCombined key and scale (e.g., "A minor")
parameters.timesignaturenumberTime signature
parameters.seednumberSeed used
createdAtstringISO 8601 — when the generation was queued
completedAtstringISO 8601 — when the generation finished or failed
errorMessagestringPresent only on status: "failed"

Securing your endpoint

Webhooks come from the open internet, so authenticate them before trusting the payload:

  1. Put a secret in the URL. Include a random token as a query parameter (e.g., ?token=...) when you submit the webhook URL. Reject any inbound request that doesn't carry it. We send the URL back to you verbatim — query string and all.
  2. Check the User-Agent. Every webhook request carries User-Agent: finetuning-webhook/1.0. This is a weak filter, not auth.
  3. Match on id. Drop payloads whose id doesn'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 "", 200

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

On this page