Callbacks

Creating a form and filling from context are asynchronous. Instead of polling 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.

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:

{
  "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" }]
  }
}
{
  "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:

{ "event": "form.ready", "status": "ready", "form_id": "…" }
{ "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.

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);
}
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:

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 if a callback is ever missed.