Webhooks
Pass webhook_url to POST /v1/generate and Jantaris sends a POST request to that URL when the task reaches succeeded or failed. No polling loop needed.
Payload
The body is the same JSON you would get from GET /v1/tasks/{id}:
{
"id": "task_8f2k...",
"status": "succeeded",
"model": "seedance-2.5",
"output": [{ "type": "video", "url": "https://...", "duration": 5, "width": 1280, "height": 720 }],
"cost": { "amount": "0.77", "currency": "USD" },
"error": null,
"created_at": "2026-09-27T10:15:02Z",
"completed_at": "2026-09-27T10:16:41Z"
}Headers
| Header | Value |
|---|---|
Content-Type | application/json |
x-jantaris-event | task.succeeded or task.failed |
x-jantaris-signature | sha256=<hex>, HMAC-SHA256 of the raw request body with your webhook secret |
Your webhook secret is shown in the dashboard. Keep it on the server, like an API key.
Verifying the signature
Compute HMAC-SHA256 over the raw body bytes exactly as received, before any JSON parsing, and compare it with the header in constant time. Reject requests that do not match.
# pip install flask
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["JANTARIS_WEBHOOK_SECRET"].encode()
seen = set() # use a database table in production
@app.post("/jantar-hook")
def jantar_hook():
raw = request.get_data() # raw bytes
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
received = request.headers.get("x-jantaris-signature", "")
if not hmac.compare_digest(expected, received):
abort(401)
task = request.get_json()
event = request.headers.get("x-jantaris-event")
if task["id"] in seen:
return "", 200 # duplicate delivery
seen.add(task["id"])
enqueue_processing(task, event) # your code: hand off to a queue, return fast
return "", 200// npm install express
import express from "express";
import crypto from "node:crypto";
const app = express();
const SECRET = process.env.JANTARIS_WEBHOOK_SECRET;
const seen = new Set(); // use a database table in production
app.post("/jantar-hook", express.raw({ type: "application/json" }), (req, res) => {
const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const received = req.get("x-jantaris-signature") ?? "";
const ok =
expected.length === received.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
if (!ok) return res.sendStatus(401);
const task = JSON.parse(req.body.toString("utf8"));
if (seen.has(task.id)) return res.sendStatus(200);
seen.add(task.id);
queueMicrotask(() => processTask(task, req.get("x-jantaris-event"))); // your code
res.sendStatus(200);
});
app.listen(3000);Common mistake: verifying against JSON.stringify(req.body) after a JSON body parser. Re-serialized JSON differs from the original bytes and the signature will not match. Use the raw body.
Retries
The first delivery goes out as soon as the task finishes. If your endpoint does not answer with a 2xx status (or does not answer at all), Jantaris retries up to 5 times with growing gaps:
| Retry | Delay after the previous attempt |
|---|---|
| 1 | 10 s |
| 2 | 1 min |
| 3 | 5 min |
| 4 | 30 min |
| 5 | 2 h |
After the last failed retry the delivery is dropped. The task itself stays available through GET /v1/tasks/{id}.
Handling rules
- Respond with 2xx fast. Save the payload, put the work (downloading files, transcoding) in a queue, and return. Slow responses count as failures and trigger retries.
- Deduplicate by task id. A retry can arrive after you already processed the first delivery, for example when your 200 got lost on the network. Store processed ids and ignore repeats.
- Download output right away. URLs in
outputare temporary. - Have a fallback. If your server was down for longer than the retry window, call
GET /v1/tasks/{id}for tasks you started but never heard back about.
Testing locally
webhook_url has to be reachable from the internet. During development, expose your local port with a tunneling tool of your choice and pass the public URL as webhook_url. You can also replay a saved payload against your handler with curl, signing it yourself:
BODY='{"id":"task_test","status":"succeeded","model":"gpt-image-2.5","output":[],"cost":{"amount":"0.00","currency":"USD"},"error":null}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$JANTARIS_WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST http://localhost:3000/jantar-hook \
-H "Content-Type: application/json" \
-H "x-jantaris-event: task.succeeded" \
-H "x-jantaris-signature: sha256=$SIG" \
-d "$BODY"








