Guides

Async jobs & webhooks

Run long rewrites in the background and verify signed webhook deliveries.

Long rewrites take longer than a sensible HTTP timeout. Send them as jobs, then receive the result on a signed webhook (or poll for it).

When to use async

  • Any rewrite above 5,000 words (we recommend it; above 15,000 words it's required).
  • Batch work: content migrations, back-catalogue rewrites, nightly detection sweeps.
  • Serverless callers with short execution limits.

Create a job

Add async: true and a webhook_url. The API responds immediately with 202 Accepted and a job:

curl -X POST https://api.intactvoice.com/v1/rewrite \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 4f1c8a2e-migrate-post-2291" \
  -d '{
    "text": "…",
    "voice_id": "voice_8f2k1x",
    "platform": "blog",
    "async": true,
    "webhook_url": "https://example.com/webhooks/rewrite"
  }'

Polling

Without a webhook, poll GET /jobs/{job_id} with backoff. Polling is free but counts toward your request rate limit.

Warning:

We don't keep results. The first GET that returns a finished job's result (or a 2xx from your webhook) deletes it, and anything not collected within 60 minutes is deleted. After that, GET answers 410 result_expired. Save the result when you receive it.
async function waitForJob(id: string) {
  for (let delay = 1000; ; delay = Math.min(delay * 2, 15000)) {
    const res = await fetch(`https://api.intactvoice.com/v1/jobs/${id}`, {
      headers: { Authorization: `Bearer ${process.env.API_KEY}` },
    });
    const job = await res.json();
    if (job.status === "succeeded") return job.result;
    if (job.status === "failed") throw new Error(job.error.code);
    await new Promise((r) => setTimeout(r, delay));
  }
}

Webhook payload

When a job finishes we POST an event to your webhook_url. Events are job.succeeded or job.failed; failed jobs include the same error object the synchronous API would have returned (for example fidelity_failed).

POST /webhooks/rewrite HTTP/1.1
Content-Type: application/json
Webhook-Id: evt_01JD4QD1X8
Webhook-Signature: t=1791100042,v1=5c3e0f9b2a…

Verify the signature

Each endpoint has a signing secret (whsec_…) in the dashboard. The signature is an HMAC-SHA256 of {t}.{raw body}. Verify it against the raw request body, before parsing JSON, and reject timestamps older than five minutes to block replays.

import crypto from "node:crypto";

const TOLERANCE_S = 300;

export async function POST(req: Request) {
  const raw = await req.text(); // raw body: don't JSON.parse first
  const header = req.headers.get("webhook-signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string]));
  const t = Number(parts.t);

  if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) {
    return new Response("stale or missing timestamp", { status: 400 });
  }

  const expected = crypto
    .createHmac("sha256", process.env.WEBHOOK_SECRET!)
    .update(`${t}.${raw}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return new Response("bad signature", { status: 401 });
  }

  const event = JSON.parse(raw);
  await queue.add(event.id, event); // dedupe on event.id, do the work async
  return new Response(null, { status: 204 });
}

Retries and idempotency

BehaviourDetail
SuccessAny 2xx within 10 seconds. Do slow work after responding.
Retries6 retries after 30s, 1m, 2m, 5m, 10m and 20m (the last about 40 minutes after the first attempt), then the delivery is marked failed. The payload copy is deleted on success, after the last retry, or after 60 minutes at most.
OrderingNot guaranteed. Use created and the job status, not arrival order.
DuplicatesPossible. Deduplicate on Webhook-Id / event.id.
Idempotency-KeySend one on job creation. Repeating a request with the same key within 24 hours returns the original job instead of starting (and billing) a new one. We keep only the key, a request hash and the job ID.