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

# Webhooks

> Receive a signed notification when a humanization job finishes, verify it, and handle deliveries safely.

A webhook is an HTTP `POST` that Wibble sends to a URL you control when a humanization job reaches a terminal state. Instead of polling `GET /humanize/{id}`, you give Wibble a URL up front and it notifies you once the job succeeds or fails. The request body contains the terminal job result plus a `type` field that names the event.

Webhooks are a convenience, not a guarantee. Network failures and downtime happen, so keep polling available as a fallback. See [Jobs](/concepts/jobs) for the lifecycle and the polling flow.

## Enable webhooks

Pass a `webhook_url` when you submit a job. Wibble sends a signed `POST` to that URL when the job finishes.

```bash theme={null}
curl https://www.wibbleai.com/api/v1/humanize \
  -H "Authorization: Bearer wib_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "text": "The mitochondria is the powerhouse of the cell.",
    "webhook_url": "https://example.com/webhooks/wibble"
  }'
```

The `webhook_url` must:

* Use `https`.
* Be publicly reachable from the internet.
* Contain no embedded credentials (no `user:pass@` in the URL).
* Not resolve to a private or loopback address.
* Be 2048 characters or fewer.

A URL that fails these checks is rejected at submit time with a `400 validation_error`. See [Errors](/concepts/errors) for the response shape.

## Events

There are two events. Both deliver the same payload shape: job identifiers, billing fields, `status_url`, timestamps, and either `output` or `error`, with a `type` field added. Webhooks do not include polling-only progress fields such as `current_stage` or `detected_language`.

| Event                    | Sent when                                                                                    | Notable fields                                                  |
| ------------------------ | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `humanize.job.succeeded` | The job completes and `status` is `succeeded`.                                               | `output` holds the humanized text.                              |
| `humanize.job.failed`    | The job ends and `status` is `failed`. Reserved words are refunded before the event is sent. | `error` holds the `code` and `message`. `words_charged` is `0`. |

This is an example `humanize.job.succeeded` payload:

```json humanize.job.succeeded theme={null}
{
  "id": "7b42f1d6-0a8c-4e8f-9a21-c6d9f47c2b10",
  "type": "humanize.job.succeeded",
  "status": "succeeded",
  "mode": "humanize",
  "input_words": 9,
  "words_reserved": 9,
  "words_charged": 9,
  "status_url": "https://www.wibbleai.com/api/v1/humanize/7b42f1d6-0a8c-4e8f-9a21-c6d9f47c2b10",
  "created_at": "2026-06-14T12:00:00.000Z",
  "completed_at": "2026-06-14T12:00:42.000Z",
  "output": "Mitochondria are the cell's powerhouses."
}
```

For the exact schema of each event, see the reference pages:

<Columns cols={2}>
  <Card title="Job succeeded" icon="circle-check" href="/api-reference/webhooks/job-succeeded">
    The payload Wibble sends when a job succeeds.
  </Card>

  <Card title="Job failed" icon="circle-xmark" href="/api-reference/webhooks/job-failed">
    The payload Wibble sends when a job fails.
  </Card>
</Columns>

## Delivery headers

Every delivery carries four headers.

| Header               | Meaning                                                                                                |
| -------------------- | ------------------------------------------------------------------------------------------------------ |
| `X-Wibble-Delivery`  | Unique delivery ID (UUID). Stable across retries of the same event, so you can deduplicate.            |
| `X-Wibble-Event`     | The event type, matching the payload `type` field (`humanize.job.succeeded` or `humanize.job.failed`). |
| `X-Wibble-Signature` | HMAC-SHA256 signature of the request, of the form `sha256=<hex>`.                                      |
| `X-Wibble-Timestamp` | Unix timestamp in seconds at which the request was signed. Recomputed on each retry.                   |

## Verify the signature

Anyone who learns your `webhook_url` could `POST` to it. Verify the signature before you trust a delivery.

Wibble signs each request with the API key's webhook signing secret. The secret is shown once when the key is created and has the format `whsec_...`. Store it where your webhook handler can read it.

<Warning>
  The signing secret is shown only when the key is created. Save it then. If you lose it, create a new key.
</Warning>

To verify a delivery, recompute the signature and compare it to the header:

1. Read the **raw request body** as it arrived, before any JSON parsing. Parsing and re-serializing changes the bytes and breaks the signature.
2. Build the signed string `"{X-Wibble-Timestamp}.{raw body}"` using the timestamp from the `X-Wibble-Timestamp` header on this request.
3. Compute the HMAC-SHA256 of that string with your `whsec_...` secret and hex-encode it.
4. Compare your value to the hex after `sha256=` in `X-Wibble-Signature` using a constant-time comparison.

Recompute against the header timestamp on every delivery. Each retry is signed with a fresh timestamp, so a value cached from an earlier attempt will not match.

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

  // `rawBody` is the exact bytes received, before JSON.parse.
  function verifyWibbleSignature(rawBody, headers, signingSecret) {
    const timestamp = headers["x-wibble-timestamp"];
    const signature = headers["x-wibble-signature"];
    if (!timestamp || !signature) return false;

    const expected =
      "sha256=" +
      crypto
        .createHmac("sha256", signingSecret)
        .update(`${timestamp}.${rawBody}`)
        .digest("hex");

    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac

  # `raw_body` is the exact bytes received, before json.loads.
  def verify_wibble_signature(raw_body: bytes, headers, signing_secret: str) -> bool:
      timestamp = headers.get("X-Wibble-Timestamp")
      signature = headers.get("X-Wibble-Signature")
      if not timestamp or not signature:
          return False

      signed = f"{timestamp}.".encode() + raw_body
      digest = hmac.new(
          signing_secret.encode(), signed, hashlib.sha256
      ).hexdigest()
      expected = f"sha256={digest}"

      return hmac.compare_digest(expected, signature)
  ```
</CodeGroup>

<Warning>
  Many web frameworks expose only a parsed body. Configure your handler to capture the raw bytes for the webhook route. If you sign a re-serialized object, verification fails even when the request is genuine.
</Warning>

## Protect against replays

A valid request that is captured and resent later still carries a valid signature. Use `X-Wibble-Timestamp` to bound how old a delivery you accept.

After the signature checks out, compare the timestamp to the current time and reject deliveries outside a tolerance window, such as five minutes. Account for retries: a genuine delivery may arrive a few seconds after the first attempt, each with its own fresh timestamp.

```javascript Node.js theme={null}
function isFresh(headers, toleranceSeconds = 300) {
  const timestamp = Number(headers["x-wibble-timestamp"]);
  const now = Math.floor(Date.now() / 1000);
  return Number.isFinite(timestamp) && Math.abs(now - timestamp) <= toleranceSeconds;
}
```

## Delivery semantics

| Behavior       | Detail                                                                                                          |
| -------------- | --------------------------------------------------------------------------------------------------------------- |
| Attempts       | Up to 3 per event.                                                                                              |
| Backoff        | Exponential between attempts.                                                                                   |
| Timeout        | 5,000 ms per attempt.                                                                                           |
| Acknowledgment | Any `2xx` response. Non-`2xx` (or a timeout) is retried until the attempt limit is reached.                     |
| Delivery ID    | `X-Wibble-Delivery` is stable across retries of the same event. The timestamp and signature change per attempt. |

If all attempts fail, Wibble does not keep retrying past the limit. Fetch the job with `GET /humanize/{id}` to recover the result.

## Best practices

<AccordionGroup>
  <Accordion title="Verify before you trust" icon="shield-check">
    Confirm the signature on every delivery before reading the payload or doing any work. Reject requests that fail verification or fall outside your timestamp tolerance.
  </Accordion>

  <Accordion title="Respond fast" icon="bolt">
    Return a `2xx` quickly, within the 5,000 ms timeout. Acknowledge first, then do slower work (database writes, downstream calls) asynchronously. Holding the connection open risks a timeout and an unnecessary retry.
  </Accordion>

  <Accordion title="Handle deliveries idempotently" icon="copy">
    The same event can arrive more than once. Treat `X-Wibble-Delivery` (or the job `id` plus `type`) as a key and ignore deliveries you have already processed.
  </Accordion>

  <Accordion title="Keep polling as a fallback" icon="arrows-rotate">
    If a webhook never arrives, fall back to polling `GET /humanize/{id}`. Webhooks reduce polling; they do not replace your ability to read a job's final state.
  </Accordion>
</AccordionGroup>

## Next steps

<Columns cols={2}>
  <Card title="Jobs" icon="list-check" href="/concepts/jobs">
    The job object, statuses, and polling versus webhooks.
  </Card>

  <Card title="Quickstart" icon="rocket" href="/quickstart">
    Go from an API key to your first humanized result.
  </Card>
</Columns>
