Skip to main content
When you provide a webhook.url on an async task, cloro sends an HTTP POST to your endpoint once the task reaches a terminal state — COMPLETED or FAILED.

Enabling deliveries

Include a webhook.url when you create the async task:
That’s the whole opt-in. If you omit webhook.url, fall back to polling.
Enable signing if your endpoint performs sensitive operations (charging, writing to your database) based on webhook content.

Receiving deliveries

The body is what GET /v1/async/task/{taskId} returns for the finished task, minus its webhook block:
Webhook payload

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.

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 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.

Enabling signing

Enable signing from the dashboard. cloro generates a secret prefixed with whsec_ and shows it to you exactly once — copy it immediately. It’s the only thing your endpoint needs to verify signatures.
Anyone with the signing secret can forge payloads that pass your verification check. Store it in a secret manager, not in source code, and rotate it from the dashboard if it ever leaks.

What we send

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

How the signature is computed

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 5 minutes ago, as the samples below do. Widen that tolerance if your clock drifts or deliveries reach you slowly.

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 do many serverless wrappers and 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. Capture the raw bytes first, and parse the JSON only after verification passes.

Step 2 — verify the signature

Compare signatures in constant time, as the samples do with crypto.timingSafeEqual, hmac.compare_digest, and hmac.Equal: a plain == on the hex strings leaks the expected signature through timing differences.

Disabling or rotating

You can rotate or disable signing at any time from the dashboard.
  • 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?

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.
  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.