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

# Webhooks

> Receive async task results via webhooks: payload shape, retry behavior, HMAC signature verification in Node.js, Python, and Go, and troubleshooting tips.

When you provide a `webhook.url` on an [async task](/guides/making-requests/async), cloro sends an HTTP `POST` to your endpoint once the task reaches a terminal state — `COMPLETED` or `FAILED`. This page covers everything that happens at that endpoint: the payload shape, how to respond, how cloro retries, how to verify deliveries came from cloro, and what to check when one doesn't arrive.

## Enabling deliveries

Include a `webhook.url` when you create the async task:

```json theme={null}
{
  "taskType": "CHATGPT",
  "webhook": {
    "url": "https://your-app.com/webhook-handler"
  },
  "payload": { "prompt": "...", "country": "US" }
}
```

That's the whole opt-in. cloro will POST the full task result to that URL when the task completes. If you omit `webhook.url`, fall back to [polling](/guides/making-requests/async#option-b-polling).

<Info>
  Enable [signing](#verifying-deliveries) from the dashboard if your endpoint performs sensitive operations (charging, writing to your database) based on webhook content — anyone who can reach the URL could otherwise send a forged delivery.
</Info>

## Receiving deliveries

Your endpoint receives a JSON body containing the task metadata, credit accounting, and the full provider response:

```json Webhook payload theme={null}
{
  "task": {
    "id": "b27a21e1-7c39-4aa2-a347-23e828c426f9",
    "taskType": "CHATGPT",
    "status": "COMPLETED",
    "priority": 5,
    "createdAt": "2025-11-10T15:00:00.000Z",
    "idempotencyKey": "your-custom-identifier-123"
  },
  "credits": {
    "creditsToCharge": 10,
    "creditsCharged": 10
  },
  "response": {
    "model": "gpt-5-3-mini",
    "text": "The weather in New York is currently sunny...",
    "html": "https://storage.cloro.dev/results/c45a5081-808d-4ed3-9c86-e4baf16c8ab8/page-1.html",
    "sources": [],
    "shoppingCards": [],
    "entities": [],
    "markdown": "The weather in New York is currently sunny...",
    "searchQueries": ["weather in New York"]
  }
}
```

### Responding to a webhook

Respond with any `2xx` status code (typically `200 OK`) to acknowledge receipt. Anything else — non-`2xx`, TLS errors, timeouts — counts as a failed delivery and triggers a retry.

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  app.post('/webhook-handler', (req, res) => {
    // Process the result asynchronously
    console.log(req.body);

    // Immediately acknowledge receipt
    res.status(200).send();
  });
  ```

  ```python Python (Flask) theme={null}
  @app.route('/webhook-handler', methods=['POST'])
  def handle_webhook():
      # Process the result asynchronously
      print(request.json)

      # Immediately acknowledge receipt
      return ('', 200)
  ```
</CodeGroup>

## Retries and deduplication

cloro retries failed deliveries up to **5 attempts** with exponential backoff. If an attempt fails, the next one is scheduled for:

* **Attempt 2:** \~2 minutes later
* **Attempt 3:** \~4 minutes later
* **Attempt 4:** \~8 minutes later
* **Attempt 5:** \~16 minutes later

The same logical task may therefore arrive at your endpoint multiple times. If you need exactly-once handling, deduplicate on the `task.id` field inside the payload. (Signed deliveries also carry an [`X-Cloro-Webhook-Id`](#what-we-send) header that's unique per attempt, but `task.id` is always available.)

**Correlating webhooks to your original submissions**: set `idempotencyKey` to a unique string (e.g., your internal job ID) when you submit an async task. That same key is included in every webhook payload for the task under `task.idempotencyKey`, letting you match each callback to the original request without maintaining a separate taskId lookup table.

## Verifying deliveries

**Anyone who can reach your endpoint could send a forged request that looks identical to a real cloro delivery** unless you verify the signature. Webhook signing is opt-in per organization. Once enabled, every outbound delivery from cloro includes three headers your endpoint can use to confirm the payload's origin and integrity.

### Enabling signing

Webhook signing is enabled from the [dashboard](https://dashboard.cloro.dev/webhooks). When you enable it, cloro generates a secret prefixed with `whsec_` and shows it to you **exactly once**. Copy it immediately and store it in your secret manager — it's the only thing your endpoint needs in order to verify signatures.

<Warning>
  Treat the signing secret like a password. Anyone with the secret can forge
  payloads that pass your verification check. Store it in a secret manager, not
  in source code. Rotate it from the dashboard if it ever leaks.
</Warning>

### What we send

Every signed delivery includes three headers in addition to the standard `Content-Type: application/json`:

| Header               | Example                                  | Purpose                                                      |
| -------------------- | ---------------------------------------- | ------------------------------------------------------------ |
| `X-Cloro-Timestamp`  | `1748419200`                             | Unix timestamp (seconds) when cloro signed the delivery      |
| `X-Cloro-Signature`  | `v1=ab12cd34...`                         | HMAC-SHA256 signature, prefixed with the scheme version      |
| `X-Cloro-Webhook-Id` | `b27a21e1-7c39-4aa2-a347-23e828c426f9-1` | Unique delivery ID (`<task-id>-<attempt>`) for deduplication |

### How the signature is computed

```
signed_payload = "<X-Cloro-Timestamp>" + "." + "<raw_request_body>"
signature      = HMAC-SHA256(your_signing_secret, signed_payload)
```

cloro hex-encodes the HMAC output and ships it as the `v1=` portion of `X-Cloro-Signature`. Your endpoint recomputes the same value and compares it to the header.

The timestamp goes inside the signed payload so an attacker can't replay an intercepted webhook against you indefinitely — your endpoint can reject anything signed more than a few minutes ago.

### Verification

#### Step 1 — capture the raw body

The signature is computed over the **exact bytes** of the request body. If your web framework parses the JSON and then re-serializes it before you see it (Express's `express.json()` does this, as does Flask's `request.json` when the body type is detected as JSON), the bytes you check can differ from the bytes cloro signed — different float formatting, key ordering, or whitespace — and verification will silently fail.

Always capture the raw bytes first, then parse the JSON only after verification has passed.

#### Step 2 — verify the signature

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from "crypto";
  import express from "express";

  const app = express();
  const SIGNING_SECRET = process.env.CLORO_WEBHOOK_SECRET;
  const TOLERANCE_SECONDS = 5 * 60; // reject anything older than 5 minutes

  app.post(
    "/webhooks/cloro",
    // IMPORTANT: raw() not json() — we need the exact bytes cloro signed.
    express.raw({ type: "application/json" }),
    (req, res) => {
      const timestamp = req.header("X-Cloro-Timestamp");
      const signatureHeader = req.header("X-Cloro-Signature");
      const rawBody = req.body.toString("utf8");

      if (!timestamp || !signatureHeader) {
        return res.status(400).send("missing signature headers");
      }

      // Replay protection
      const now = Math.floor(Date.now() / 1000);
      if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) {
        return res.status(400).send("timestamp outside tolerance");
      }

      // Compute the expected signature
      const expected = crypto
        .createHmac("sha256", SIGNING_SECRET)
        .update(`${timestamp}.${rawBody}`)
        .digest("hex");

      const provided = signatureHeader.replace(/^v1=/, "");
      if (
        provided.length !== expected.length ||
        !crypto.timingSafeEqual(
          Buffer.from(expected, "utf8"),
          Buffer.from(provided, "utf8")
        )
      ) {
        return res.status(401).send("invalid signature");
      }

      // Parse JSON only after verification has passed
      const payload = JSON.parse(rawBody);
      // …handle payload…

      res.status(200).send("ok");
    }
  );
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import os
  import time
  from flask import Flask, request, abort

  SIGNING_SECRET = os.environ["CLORO_WEBHOOK_SECRET"].encode()
  TOLERANCE_SECONDS = 5 * 60  # reject anything older than 5 minutes

  app = Flask(__name__)

  @app.route("/webhooks/cloro", methods=["POST"])
  def cloro_webhook():
      timestamp = request.headers.get("X-Cloro-Timestamp")
      signature_header = request.headers.get("X-Cloro-Signature", "")
      raw_body = request.get_data()  # bytes, exactly as received

      if not timestamp or not signature_header:
          abort(400, "missing signature headers")

      # Replay protection
      if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
          abort(400, "timestamp outside tolerance")

      # Compute the expected signature
      signed_payload = f"{timestamp}.".encode() + raw_body
      expected = hmac.new(SIGNING_SECRET, signed_payload, hashlib.sha256).hexdigest()

      provided = signature_header.removeprefix("v1=")
      if not hmac.compare_digest(expected, provided):
          abort(401, "invalid signature")

      # Parse the JSON only after verification has passed
      payload = request.get_json(force=True)
      # …handle payload…

      return ("ok", 200)
  ```

  ```go Go theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"io"
  	"net/http"
  	"os"
  	"strconv"
  	"strings"
  	"time"
  )

  var (
  	signingSecret    = []byte(os.Getenv("CLORO_WEBHOOK_SECRET"))
  	toleranceSeconds = int64(5 * 60)
  )

  func cloroWebhook(w http.ResponseWriter, r *http.Request) {
  	timestamp := r.Header.Get("X-Cloro-Timestamp")
  	signatureHeader := r.Header.Get("X-Cloro-Signature")

  	rawBody, err := io.ReadAll(r.Body)
  	if err != nil {
  		http.Error(w, "could not read body", http.StatusBadRequest)
  		return
  	}

  	if timestamp == "" || signatureHeader == "" {
  		http.Error(w, "missing signature headers", http.StatusBadRequest)
  		return
  	}

  	// Replay protection
  	tsInt, err := strconv.ParseInt(timestamp, 10, 64)
  	if err != nil || abs(time.Now().Unix()-tsInt) > toleranceSeconds {
  		http.Error(w, "timestamp outside tolerance", http.StatusBadRequest)
  		return
  	}

  	// Compute the expected signature
  	mac := hmac.New(sha256.New, signingSecret)
  	mac.Write([]byte(timestamp + "."))
  	mac.Write(rawBody)
  	expected := hex.EncodeToString(mac.Sum(nil))

  	provided := strings.TrimPrefix(signatureHeader, "v1=")
  	if !hmac.Equal([]byte(expected), []byte(provided)) {
  		http.Error(w, "invalid signature", http.StatusUnauthorized)
  		return
  	}

  	// Parse JSON / handle payload only after verification has passed
  	// …

  	w.WriteHeader(http.StatusOK)
  	w.Write([]byte("ok"))
  }

  func abs(n int64) int64 {
  	if n < 0 {
  		return -n
  	}
  	return n
  }
  ```
</CodeGroup>

<Info>
  The 5-minute tolerance is what we recommend — adjust if your endpoint sits
  behind slow networks or you want stricter replay protection.
</Info>

### Common pitfalls

* **Parsing the body before verifying** — the signature is over the raw bytes. Frameworks that parse-then-restringify (Express's `express.json()`, some serverless wrappers) can change byte representation and break verification. Always read the raw bytes first.
* **String equality instead of constant-time compare** — a `==` comparison on the hex strings leaks information about the expected signature via timing differences. Use `crypto.timingSafeEqual` (Node), `hmac.compare_digest` (Python), or `hmac.Equal` (Go).
* **No timestamp check** — without rejecting old timestamps, an attacker who intercepts even one signed webhook can replay it against you indefinitely. The timestamp is in the signed payload specifically to make this check possible.
* **Storing the secret in source code** — if a secret leaks, rotate it immediately from the dashboard. The secret is the only thing standing between an attacker and a forged delivery.

## Disabling or rotating

You can rotate or disable signing at any time from the [dashboard](https://dashboard.cloro.dev/webhooks).

* **Rotating** generates a new secret immediately. Your existing receivers will reject signatures until you update them with the new value, so coordinate the rotation with your deploy.
* **Disabling** stops sending the `X-Cloro-*` headers. Existing receivers that verify will start rejecting payloads until you remove the verification check on their side.

## Troubleshooting

### My webhook never arrived. What should I check?

If a task reaches `COMPLETED` or `FAILED` but you don't see the webhook hit your endpoint, work through these in order:

1. **Look up the task.** Call `GET /v1/async/task/{taskId}` — if the status is terminal, the result already exists and you can fetch it during the 24-hour retention window.
2. **Check the URL you submitted.** Typos, missing protocol, and non-public hostnames (e.g., `localhost`) will not deliver.
3. **Check your endpoint's response.** Non-`2xx` responses, TLS errors, and long timeouts can exhaust the [retry budget](#retries-and-deduplication).
4. **Don't assume order.** Webhooks for a batch arrive in completion order, not submission order. Poll `/v1/async/status` if you need a count of outstanding tasks.

## Need help?

* Reach out at [support@cloro.dev](mailto:support@cloro.dev)
* Async requests: [Making requests → Asynchronous requests](/guides/making-requests/async)
* Async task reference: [`POST /v1/async/task`](/api-reference/endpoint/create-async-task)
* Authentication: [API keys & Bearer tokens](/guides/authentication)
