Documentation menu

Callbacks

Securely receive and verify Task callback deliveries.

3 min read

Instead of polling, pass a callback_url when creating a task and CAPI will POST the finished result to you.

Register a callback

Add callback_url to any async generation request:

SHELL
curl -X POST https://capi.ai/api/v1/kling/text_to_video \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "kling-v3-turbo-text-to-video",
    "prompt": "A paper kite flying above a quiet coastal town at sunrise",
    "duration_seconds": 5,
    "callback_url": "https://api.example.com/hooks/capi"
  }'

The URL must be HTTPS and publicly reachable. Private addresses and self-signed certificates are rejected at submission time, so you find out immediately rather than after the generation finishes.

Callback payload

CAPI sends the same envelope that GET /api/v1/tasks/{task_id} returns:

JSON
{
  "task_id": "tsk_8f21c4ba",
  "status": "completed",
  "output": {
    "url": "https://file.capi.ai/v/tsk_8f21c4ba.mp4",
    "duration": 5
  },
  "cost": { "amount": 0.21, "currency": "USD" },
  "delivered_at": "2026-03-14T09:22:41Z"
}

Sending the same shape for both paths means one handler can serve polling and callbacks.

Verify the signature

Every delivery is signed so you can prove it came from CAPI. The signature covers the raw request body and the delivery timestamp.

The request carries:

  • CAPI-Signaturet=<unix timestamp>,v1=<hex hmac>
  • CAPI-Task-Id — the task identifier
  • CAPI-Delivery-Id — unique per attempt, useful for de-duplication

Compute an HMAC-SHA256 over ${timestamp}.${rawBody} using your callback signing secret, then compare in constant time:

PYTHON
import hmac
import hashlib
import time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp = int(parts["t"])

    # Reject replays outside the tolerance window.
    if abs(time.time() - timestamp) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{parts['t']}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, parts["v1"])

The same check in Node.js:

JAVASCRIPT
import crypto from "node:crypto";

function verify(rawBody, header, secret, tolerance = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=")),
  );

  const timestamp = Number(parts.t);
  if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(parts.v1),
  );
}

Always verify against the raw request body, before any JSON parsing. Re-serialising the payload changes byte order and invalidates the signature.

Respond quickly

Return 2xx as soon as you have durably queued the payload, then do the heavy work asynchronously. Any non-2xx response, or a response that takes longer than 10 seconds, counts as a failed delivery.

Retries

Failed deliveries are retried with exponential backoff:

AttemptDelay
1immediate
230 seconds
32 minutes
410 minutes
51 hour

After five failed attempts the delivery is parked. Retrieve the result manually with GET /api/v1/tasks/{task_id}, and use CAPI-Delivery-Id to ignore duplicates when two attempts both reach you.

Local development

Forward to your machine with any tunnel:

SHELL
ngrok http 3000
# then pass the generated https URL as callback_url

Next steps