---
title: Callbacks
url: https://runapi.ai/docs/guides/task-api/callbacks.md
canonical: https://runapi.ai/docs/guides/task-api/callbacks
locale: en
---

# Callbacks

Use callbacks to receive Task lifecycle events at a public HTTPS
endpoint. Verify every callback before processing its JSON body so that
only deliveries signed for your account can change application state.

## Configure a callback URL

Add a public HTTPS `callback_url` when you create a Task. The event body
and lifecycle states depend on the endpoint; consult that endpoint's API
Reference for its callback payloads.

```json
{
  "model": "flux-2-pro-text-to-image",
  "prompt": "A product photograph on a clean studio background",
  "callback_url": "https://your-domain.com/webhooks/runapi"
}
```

## Create a Callback Secret

Open [API Keys](/api_keys) and create a Callback Secret for the account
that creates the Task. Store the value in a secret manager and make it
available only to your callback receiver. It is not an API key and must
never be sent in a Task request.

The secret signs callbacks for that account. Rotating it changes the
signature for later deliveries, so update every callback receiver
immediately and keep both values available only long enough to handle
in-flight deliveries.

## Verify a callback signature

Each callback is an HTTP `POST` with `Content-Type: application/json`.
RunAPI does not add an `Authorization` header to this request. Verify
these headers before deserializing the body:

| Header | Meaning |
|----------
| `X-Callback-Id` | Unique identifier for this delivery attempt. |
| `X-Callback-Timestamp` | Unix timestamp in seconds when the delivery was signed. |
| `X-Callback-Signature` | Base64-encoded HMAC-SHA-256 signature. |

Build the signed value exactly as follows, using the unmodified request
body bytes:

```text
X-Callback-Id + "." + X-Callback-Timestamp + "." + raw request body
```

Base64-decode the Callback Secret, calculate HMAC-SHA-256 over that
value, Base64-encode the result, and compare it with
`X-Callback-Signature` using a timing-safe comparison. Do not parse and
re-serialize JSON before verifying it.

### JavaScript

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyCallback({headers, rawBody, callbackSecret}) {
  const callbackId = headers["x-callback-id"];
  const timestamp = Number(headers["x-callback-timestamp"]);
  const signature = headers["x-callback-signature"];

  if (!callbackId || !signature || !Number.isSafeInteger(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const signedContent = `${callbackId}.${timestamp}.${rawBody}`;
  const expected = createHmac("sha256", Buffer.from(callbackSecret, "base64"))
    .update(signedContent, "utf8")
    .digest();
  const received = Buffer.from(signature, "base64");

  return expected.length === received.length && timingSafeEqual(expected, received);
}
```

Pass the framework's raw request-body string to `rawBody`; do not call
`JSON.stringify` on parsed data.

### Python

```python
import base64
import hashlib
import hmac
import time


def verify_callback(headers, raw_body, callback_secret):
    callback_id = headers.get("X-Callback-Id")
    timestamp = headers.get("X-Callback-Timestamp")
    signature = headers.get("X-Callback-Signature")

    if not callback_id or not timestamp or not signature:
        return False

    try:
        timestamp = int(timestamp)
        secret = base64.b64decode(callback_secret, validate=True)
        received = base64.b64decode(signature, validate=True)
    except (ValueError, TypeError):
        return False

    if abs(time.time() - timestamp) > 300:
        return False

    signed_content = f"{callback_id}.{timestamp}.{raw_body}".encode("utf-8")
    expected = hmac.new(secret, signed_content, hashlib.sha256).digest()
    return hmac.compare_digest(expected, received)
```

Pass the exact raw request-body string received by your HTTP framework
as `raw_body`.

## Process deliveries safely

* Return a `2xx` response only after the callback has been accepted for
  processing. After a non-`2xx` response or transport failure, delivery
  is attempted up to 10 times.
* Respond within 15 seconds. Queue slower work after verification
  instead of blocking the HTTP response.
* Reject callbacks with missing signature headers, an invalid signature,
  or a timestamp outside the tolerance window your receiver defines.

## Troubleshoot verification

* **Signature mismatch:** confirm that the Callback Secret belongs to
  the same account as the Task, decode it with Base64, and sign the
  original body rather than parsed JSON.
* **Missing signature headers:** create a Callback Secret before relying
  on callbacks for state changes.
* **Timestamp rejected:** synchronize the receiver's clock and use a
  tolerance appropriate for your deployment.

For setup and lifecycle guidance, return to the [Task API
quickstart](/docs/guides/task-api/quickstart).
