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.

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: 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-15 08:24:11",
  "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-15 08:24:11",
  "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
createdAtstringWhen the generation was queued. UTC, YYYY-MM-DD HH:MM:SS
completedAtstringISO 8601 — when the generation finished or failed
errorMessagestringPresent only on status: "failed"
certificateobject | nullPresent only when the generation was created with certificate: true. See Certificates below
typestringOnly 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:

  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