Skip to main content

Webhooks

Pass webhook_url when you submit a generation or voice job and we POST the terminal result to that URL as soon as the job finishes. Every delivery is signed, so you can prove the callback came from us before you act on it.

We implement Standard Webhooks — the same scheme OpenAI, Anthropic, Twilio, Supabase and Replicate send. If you already verify webhooks from any of those, the same library verifies ours.

:::danger Verify before you trust the payload Without verification the webhook URL is the only secret. Anyone who learns it can POST a forged "status": "completed" body with outputs pointing at a host they control, and an unverified receiver will download that content into your pipeline. :::

Get your signing secret

Your signing secret is a per-account value that looks like whsec_…. Find it in the console under Account → Webhooks.

It is shown once, when it is issued. If you no longer hold the value, rotate to get a new one — there is no way to read an existing secret back.

What we send

Three headers, lowercase and unprefixed, on every delivery:

HeaderValue
webhook-idA unique id for this delivery. Stable across our retries.
webhook-timestampUnix time in seconds (not milliseconds).
webhook-signatureOne or more space-delimited v1,<base64> values.

The signed string is:

{webhook-id}.{webhook-timestamp}.{raw request body}

signed with HMAC-SHA256 and encoded as base64 (not hex). The v1, prefix and its comma are part of a single signature value; a space separates one value from the next.

:::warning Sign the raw body, not a re-serialized object Verify against the exact bytes we sent. Parsing the JSON and re-serializing it can reorder keys or change whitespace, and the signature will then never match. Every framework has a way to reach the raw body — use it. :::

Verifying

Use an off-the-shelf Standard Webhooks library rather than writing the HMAC yourself.

Node.js — express
import express from 'express';
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.PHANTOM_WEBHOOK_SECRET);
const app = express();

// `express.raw` keeps the exact bytes we signed. `express.json` would not.
app.post('/phantom/callback', express.raw({ type: 'application/json' }), (req, res) => {
let payload;
try {
payload = wh.verify(req.body, req.headers);
} catch {
return res.sendStatus(400);
}
// `payload` is now trustworthy: job_id, status, outputs, …
handleJob(payload);
res.sendStatus(204);
});
Python — flask
from flask import Flask, request
from standardwebhooks import Webhook

wh = Webhook(os.environ["PHANTOM_WEBHOOK_SECRET"])
app = Flask(__name__)

@app.post("/phantom/callback")
def callback():
try:
payload = wh.verify(request.data, dict(request.headers))
except Exception:
return "", 400
handle_job(payload)
return "", 204

Libraries exist for Go, Rust, Java, PHP, Ruby, Kotlin and C# too — see the specification repository.

Replay window

A verifier rejects any delivery whose webhook-timestamp is more than 5 minutes from the receiver's clock, in either direction. That is the spec's figure and the default in every conforming library, so you do not need to configure it — but your server's clock does need to be roughly correct.

Retries

We retry a failed delivery up to 3 times with exponential backoff. A retry reuses the first attempt's webhook-id, webhook-timestamp and webhook-signature unchanged, so:

  • The signature still verifies, even though the timestamp is older than the retry.
  • You can deduplicate on webhook-id — a retry is the same message, not a new one.

A delivery counts as successful on any 2xx. Return quickly and do the work asynchronously; we time out an attempt after 10 seconds.

Rotating the secret

Rotate from Account → Webhooks when a secret leaks, or on whatever schedule your policy sets. Rotation is self-serve and does not need us.

The new secret is returned once, and the previous one keeps signing alongside it for 24 hours. During that window webhook-signature carries two space-delimited values:

webhook-signature: v1,<new signature> v1,<old signature>

A Standard Webhooks library tries each value and accepts the message if any one matches, so a receiver still on the old secret keeps working while you deploy the new one. After 24 hours the old secret stops signing.

What a delivery looks like

POST /phantom/callback HTTP/1.1
content-type: application/json
webhook-id: msg_2H1ovqL9nRxKmv7WcC3sZg
webhook-timestamp: 1787654321
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=

{"job_id":"6a0c2d1e-4f3b-4b1a-9f2c-8d7e6a5b4c30","status":"completed","outputs":{...}}

The body is the same terminal job result you would get from polling — see Generation, Voice and Jobs. A failed job carries error and error_code instead of outputs.

Output URLs in a callback are presigned and expire — download the artifact rather than storing the link.