Webhooks
Have cloro push the finished result straight to your server the moment an async task settles, instead of repeatedly polling for it.
Overview
Attach a webhook.url to any async task and cloro will deliver an HTTP POST to that address as soon as the task reaches a terminal state — either COMPLETED or FAILED. The request body carries the same result you would otherwise fetch by hand, so a webhook consumer never has to call the status endpoint at all. If no URL is supplied, the task falls back to ordinary polling.
The opt-in lives entirely in the create-task call. Add the webhook object alongside your normal payload:
{
"taskType": "CHATGPT",
"webhook": {
"url": "https://your-app.example.com/hooks/cloro"
},
"payload": {
"prompt": "Summarize today's market open",
"country": "US"
}
}
Nothing else is required. When the task finishes, the URL receives the full record described below.
Delivery payload
Each delivery is a single JSON document with three top-level sections: identifying metadata for the task, the credit accounting for the run, and the provider's response.
| Field | Type | Description |
|---|---|---|
task.id | string | Stable UUID for the task; use it to deduplicate repeated deliveries. |
task.taskType | string | The provider the task ran against, e.g. CHATGPT. |
task.status | string | Terminal outcome — COMPLETED or FAILED. |
task.priority | integer | Queue priority the task was submitted with. |
task.createdAt | string | ISO 8601 timestamp of when the task was accepted. |
task.idempotencyKey | string | The client-supplied key echoed back, when one was provided. |
credits.creditsToCharge | integer | Credits the run was expected to cost. |
credits.creditsCharged | integer | Credits actually deducted from your balance. |
response.model | string | Underlying model that produced the answer. |
response.text | string | Plain-text answer from the provider. |
response.html | string | URL to the captured HTML rendering of the result. |
response.markdown | string | Markdown version of the answer. |
response.sources | array | Cited sources referenced in the answer. |
response.searchQueries | array | Search queries the provider issued while answering. |
Response example
{
"task": {
"id": "9f3c0d84-51ab-4e0f-bc72-6d2a1f9e4c10",
"taskType": "CHATGPT",
"status": "COMPLETED",
"priority": 5,
"createdAt": "2026-08-14T09:15:00.000Z",
"idempotencyKey": "order-4821"
},
"credits": {
"creditsToCharge": 10,
"creditsCharged": 10
},
"response": {
"model": "gpt-5-3-mini",
"text": "Markets opened higher on strong earnings...",
"html": "https://storage.cloro.cloud/results/9f3c0d84-51ab-4e0f-bc72-6d2a1f9e4c10/page-1.html",
"markdown": "Markets opened higher on strong earnings...",
"sources": [],
"shoppingCards": [],
"entities": [],
"searchQueries": ["market open today"]
}
}
Acknowledging a delivery
Return any 2xx status — 200 OK is the convention — to tell cloro the delivery was accepted. Reply as soon as you have persisted the body and run the heavier work afterward. Any non-2xx response, TLS failure, or timeout is treated as a failed delivery and will be retried.
Retries and deduplication
Failed deliveries are retried up to five times with an exponential backoff, roughly doubling the wait between attempts (about 2, 4, 8, and 16 minutes after the first try). Because of this, the same task can legitimately reach your endpoint more than once. To process each result exactly once, key your handler on task.id. Signed deliveries additionally carry a per-attempt X-Cloro-Webhook-Id header shaped as <task-id>-<attempt>.
Verifying deliveries
Anyone who learns your endpoint URL could forge a request that looks like a genuine delivery, so enable webhook signing whenever your handler performs sensitive actions. Signing is turned on per organization from the dashboard; when you enable it, cloro shows a secret prefixed with whsec_ exactly once. Store it in your secret manager — it is all your endpoint needs to check signatures.
Every signed delivery adds three headers on top of the standard Content-Type: application/json:
| Field | Type | Description |
|---|---|---|
X-Cloro-Timestamp | string | Unix time in seconds when cloro signed the delivery. |
X-Cloro-Signature | string | HMAC-SHA256 digest, hex-encoded and prefixed with a scheme version, e.g. v1=.... |
X-Cloro-Webhook-Id | string | Unique per-attempt delivery id, <task-id>-<attempt>. |
The signed string is the timestamp, a literal dot, and the exact raw request body concatenated together: signed = timestamp + "." + rawBody. Compute HMAC-SHA256(secret, signed), hex-encode it, and compare it against the value after v1= in the signature header.
Notes
Verify against the raw request bytes, not a re-serialized copy. Frameworks that parse and re-encode JSON (Express's express.json(), Flask's request.json, some serverless wrappers) can shift whitespace, key order, or number formatting, which will make an otherwise-correct signature fail. Capture the raw body first and parse it only after verification passes.
Always compare signatures with a constant-time function — crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python, or hmac.Equal in Go — and reject any delivery whose timestamp is more than a few minutes old to defeat replay attempts. If a signing secret is ever exposed, rotate it from the dashboard immediately. For help, reach us at [email protected].