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.

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