> ## Documentation Index
> Fetch the complete documentation index at: https://docs.querying.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Delivery payload, retry schedule, and how to make your handler safe.

Supply `webhook.url` on a task and we POST the result there when the task reaches a
terminal state — `COMPLETED` or `FAILED`.

```json theme={null}
{
  "taskType": "PERPLEXITY",
  "payload": { "prompt": "best wireless earbuds 2026" },
  "webhook": { "url": "https://your-server.example.com/hook" }
}
```

## Delivery payload

We POST `Content-Type: application/json` with this body:

<ResponseField name="task" type="object" required>
  The same task metadata you got at submit time, now with a terminal `status` and your
  `idempotencyKey` echoed back.
</ResponseField>

<ResponseField name="credits" type="object" required>
  A nominal per-engine figure mirroring Cloro's pricing table — `3` for Perplexity, `7` for
  ChatGPT, and so on. Nothing is billed. `creditsCharged` is `0` when the task failed.
  Unlike the submit and poll responses, which always report zero, this field is non-zero.
</ResponseField>

<ResponseField name="response" type="object" required>
  The engine-specific result. See [Engines](/engines/overview). On a failed task this is
  `{ "error": "<reason>" }` instead of the engine result.
</ResponseField>

```json theme={null}
{
  "task": {
    "id": "8f2c1e40-...",
    "taskType": "PERPLEXITY",
    "status": "COMPLETED",
    "priority": 1,
    "createdAt": "2026-07-09T04:12:00.000Z",
    "idempotencyKey": "job-42:prompt-7:perplexity"
  },
  "credits": { "creditsToCharge": 3, "creditsCharged": 3 },
  "response": {
    "text": "The best wireless earbuds in 2026 are ...",
    "sources": [{ "position": 1, "url": "https://...", "label": "..." }]
  }
}
```

A failed task still delivers:

```json theme={null}
{
  "task": { "id": "8f2c1e40-...", "status": "FAILED", "...": "..." },
  "credits": { "creditsToCharge": 3, "creditsCharged": 0 },
  "response": { "error": "engine timeout after 3 attempts" }
}
```

## Retries

Return any `2xx` to acknowledge. Anything else — including a timeout — is a failure and
we retry with exponential backoff:

| Attempt | Fires after |
| ------- | ----------- |
| 1       | immediately |
| 2       | 2 minutes   |
| 3       | 4 minutes   |
| 4       | 8 minutes   |
| 5       | 16 minutes  |

After the fifth attempt the delivery is abandoned and logged. There is no dead-letter
queue you can read from, so **the task result is only recoverable by polling** within the
24-hour retention window.

Each attempt has a **30 second** request timeout.

<Warning>
  Delivery is fire-and-forget from the task's perspective. A task that completes but whose
  webhook can never be delivered still shows `COMPLETED` on `GET /v1/async/task/{id}` —
  the status describes the engine work, not the notification.
</Warning>

## Writing a safe handler

<Steps>
  <Step title="Acknowledge fast">
    Return `200` as soon as you have durably enqueued the payload. Do your parsing and
    database writes after. A slow handler burns the 30-second timeout and earns a retry
    you did not want.
  </Step>

  <Step title="Deduplicate on idempotencyKey">
    Retries mean your handler can legitimately see the same task more than once. Match on
    `task.idempotencyKey` (or `task.id`) and make the write idempotent.
  </Step>

  <Step title="Branch on status, not on presence">
    Check `task.status === "FAILED"` explicitly. A failed task still delivers a webhook.
  </Step>
</Steps>

<Note>
  Webhook requests carry no signature or shared secret. If your endpoint is public, treat
  the payload as untrusted and use an unguessable URL path, or place the handler behind
  network-level restrictions.
</Note>
