> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.emtelligent.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.emtelligent.com/_mcp/server.

# Webhooks

Add a `callback_url` to a submission and the API sends a `POST` to it when the
job finishes, whether it succeeded, failed or was canceled:

```json
{
  "event": "job.finished",
  "id": "job_8f2c1d0e9b7a6c5d4e3f2a1b",
  "type": "questions",
  "status": "succeeded",
  "completed_at": "2026-10-07T02:09:13Z",
  "expires_at": "2026-10-08T02:09:08Z"
}
```

The webhook carries the job's identity and status, not its result. When it
arrives, fetch the result with your key as usual: `GET /v1/jobs/{id}/result`.

## Setting up

Webhooks are enabled per organization. Contact
[support@emtelligent.com](mailto:support@emtelligent.com) and you will be given a
**signing secret**, beginning `whsec_`. Store it as you would an API key. Until
webhooks are enabled, a submission with a `callback_url` is rejected with
`400 callback_not_configured`.

The URL must:

* use `https`;
* resolve to public internet addresses. A URL that resolves to a private,
  loopback or link-local address is rejected with `400 callback_not_allowed`;
* contain no credentials and no fragment.

Other problems with the URL are rejected with `400 invalid_callback_url`. The URL
is checked when you submit, so a bad one is reported immediately rather than when
the job finishes.

## Responding

Respond with any `2xx` status within 10 seconds. Redirects are not followed.

If delivery fails, it is retried with exponential backoff, starting at 30 seconds
and rising to one hour between attempts, for up to 12 hours. The job's status
shows the progress of delivery:

```json
"webhook": { "status": "delivered", "attempts": 1 }
```

| `webhook.status` | Meaning                                       |
| ---------------- | --------------------------------------------- |
| `pending`        | Not yet sent                                  |
| `delivered`      | Your endpoint responded `2xx`                 |
| `failing`        | The latest attempt failed; it will be retried |
| `gave_up`        | Retries were exhausted. Poll the job instead  |

A webhook may be delivered more than once, for example if your response is lost.
Make your handler idempotent, keyed on the job `id`.

## Verifying the signature

Verify every webhook before acting on it. Each request carries a header:

```
Formfill-Signature: t=1791338953,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

`t` is the time of signing in Unix seconds, and `v1` is the hex HMAC-SHA256 of
`"<t>.<raw request body>"`, keyed with your signing secret. Compute it over the
**raw** body bytes, before any JSON parsing, and reject a timestamp more than a
few minutes old to prevent replays.

#### Python

```python
import hashlib
import hmac
import time


def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    try:
        fields = dict(part.split("=", 1) for part in header.split(","))
        t = int(fields["t"])
    except (ValueError, KeyError):
        return False
    if abs(time.time() - t) > tolerance:  # reject replays
        return False
    expected = hmac.new(
        secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, fields.get("v1", ""))
```

#### Node.js

```javascript
const crypto = require("crypto");

function verify(secret, rawBody /* Buffer */, header, toleranceSec = 300) {
  const fields = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const t = Number(fields.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(Buffer.concat([Buffer.from(`${t}.`), rawBody])).digest("hex");
  const got = Buffer.from(fields.v1 || "", "utf8");
  return got.length === expected.length &&
         crypto.timingSafeEqual(got, Buffer.from(expected, "utf8"));
}
```