# Callbacks

Creating a form and filling from context are asynchronous. Instead of
[polling](/docs/tracking-jobs) for the result, you can have Emboss **call you**:
pass a `callback_url` when you start a job, and Emboss POSTs the result to that
URL the moment the job finishes, whether it succeeds or fails.

Callbacks don't replace polling. They're an alternative. The result also stays
fetchable from the usual endpoints, so a missed callback is never fatal.

## Request a callback

Three requests take a `callback_url`: `POST /forms`, `POST /forms/with-context`
and `POST /forms/{form_id}/with-context`. It must be a public `https` URL
(private, loopback, and non-https URLs are rejected with `400`). Other jobs,
such as prepare and commit, batch fills and faxes, have no callback: follow
them by [polling](/docs/tracking-jobs).

```bash
curl -X POST https://api.getemboss.ai/forms/with-context \
  -H "Authorization: Bearer sk_yourkey" \
  -F "file=@form.pdf" \
  -F "context=@notes.txt" \
  -F "callback_url=https://your-app.example.com/emboss/webhook"
```

## What Emboss sends

When the job reaches a terminal state, Emboss makes one `POST` to your URL with a
JSON body. Context-fill jobs (`/forms/with-context`,
`/forms/{form_id}/with-context`) send `job.*` events:

```json
{
  "event": "job.ready",
  "status": "ready",
  "job_id": "6c47f7f5-f921-4698-910f-95dd7d81310b",
  "session_id": "9b1c…",
  "report": {
    "filled": [{ "id": 3, "confidence": "high" }],
    "dropped": [{ "id": 9, "value": "…", "reason": "not an allowed option" }]
  }
}
```

```json
{
  "event": "job.failed",
  "status": "failed",
  "job_id": "6c47f7f5-f921-4698-910f-95dd7d81310b",
  "error": { "code": "context_fill_failed", "message": "…" }
}
```

Creating a form (`POST /forms`) sends `form.*` events with a `form_id`:

```json
{ "event": "form.ready", "status": "ready", "form_id": "…" }
```

```json
{ "event": "form.failed", "status": "failed", "form_id": "…",
  "error": { "code": "form_processing_failed", "message": "…" } }
```

## Verify the signature

Each callback carries an `X-Emboss-Signature` header: `sha256=` followed by the
HMAC-SHA256 of the **raw request body**, keyed by your callback signing secret.
Compute the same HMAC over the raw bytes you received and compare in constant
time, before parsing the JSON.

```js
import crypto from "crypto";

function verify(rawBody, signature, secret) {
  const expected =
    "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

```python
import hmac, hashlib

def verify(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")
```

Verify against the raw body bytes, not a re-serialized object: re-encoding JSON
can change bytes and break the signature.

## Your signing secret

Each Emboss account has its own callback signing secret, starting `whsec_`.
Find it on the **API Keys** page of your dashboard: press **Reveal** to see it
and **Copy** to copy it.

**Rotate**, on the same page, replaces it. Emboss asks you to confirm first,
because callbacks are signed with the new secret from that moment: put the new
secret on your server straight away, or its checks will fail.

A callback that arrives without an `X-Emboss-Signature` header was not signed:
treat it as a configuration signal, not a valid unsigned request.

## Fetch the result

The callback tells you the job is done; fetch the artifact the same way the poll
flow does. For a context fill, use the `session_id` from the payload:

```bash
curl https://api.getemboss.ai/sessions/9b1c…/pdf \
  -H "Authorization: Bearer sk_yourkey" -o filled.pdf
```

For a created form, download the fillable PDF with the `form_id`:
`GET /forms/{form_id}/fillable`.

## Delivery & reliability

Delivery is best-effort: Emboss retries a few times with backoff on a timeout or
5xx, then gives up. A callback failure never affects the job itself: the result
is always still available from the GET endpoints above, so you can fall back to
[polling](/docs/tracking-jobs) if a callback is ever missed.

## See also

- [Batch fill PDFs from a spreadsheet](https://getemboss.ai/use-cases/batch-fill-from-spreadsheet)
- [Fillable PDF](https://getemboss.ai/glossary/fillable-pdf)
- [Errors & status codes](https://getemboss.ai/docs/reference/errors)
