At high volume, polling every task is wasted work. Pass a webhook_url and we POST the finished task to you, whether it completed or failed.
1. Set the URL
Per task, in the request:
{
"model": "image/demo-1",
"input": { "prompt": "Studio shot of a leather wallet" },
"webhook_url": "https://hooks.example.com/tovano"
}
Or once per project, as the default, under Settings. A task that sends no webhook_url uses the project's default.
2. What we send
POST /tovano HTTP/1.1
Content-Type: application/json
Tovano-Signature: t=1763283600,v1=5f1c0b9e...
Tovano-Attempt: 1
{ "id": "tsk_9f2c41ab7d0e", "status": "completed", "charged_micro": 80000, ... }
The body is the full task object, the same as GET /v1/tasks/{id} returns.
3. Check the signature
The signature is an HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with the project's webhook secret. Always check it before you trust the body.
import { webhooks } from "tovano";
const secret = process.env.TOVANO_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const raw = await req.text(); // the raw body, before any JSON parsing
let task;
try {
task = await webhooks.constructEvent(raw, req.headers.get("tovano-signature"), secret);
} catch {
return new Response("bad signature", { status: 400 });
}
await queue.add("tovano-task", task); // hand off, answer fast
return new Response("ok");
}
Without the SDK:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(raw: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = createHmac("sha256", secret).update(`${parts.t}.${raw}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return (
fresh &&
expected.length === parts.v1?.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
);
}
4. Answer fast, work later
Answer with any 2xx within 10 seconds. Do the real work, such as downloading files or updating your database, in a background job. A slow handler causes timeouts, and timeouts cause retries.
5. Retries and duplicates
If we get no 2xx, we try again three more times: after 10 seconds, 1 minute and 5 minutes. The Tovano-Attempt header says which attempt it is. Each delivery and its result is listed on the task in the dashboard.
Because of retries, the same task can reach you more than once. Make the handler idempotent: store the task id and skip ones you have already processed.
INSERT INTO processed_tasks (task_id) VALUES ($1)
ON CONFLICT (task_id) DO NOTHING
RETURNING task_id; -- no row returned: already handled
6. A safety net
If your endpoint is down for longer than the retry window, list what you missed and process it the same way:
curl "https://api.tovano.ai/v1/tasks?status=completed&limit=100" \
-H "Authorization: Bearer $TOVANO_API_KEY"
Good practice
- Use one secret per project, and rotate it under Settings if it leaks. The old secret stops working at once.
- Reject timestamps older than five minutes.
- Do not depend on the order of deliveries. Use
completed_aton the task.