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.