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

Pass callback_url when you start any async job. It must be a public https URL (private, loopback, and non-https URLs are rejected with 400).

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

The same callback_url field works on POST /forms and POST /forms/{form_id}/with-context.

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": "full_name", "confidence": "high" }],
    "dropped": [{ "id": "ssn", "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 is configured on your Emboss account. If signing isn't enabled, callbacks arrive without an X-Emboss-Signature header — treat a missing signature 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_live_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.