Skip to navigation

Webhooks

Be told when a job finishes instead of polling

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:

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

"webhook": { "status": "delivered", "attempts": 1 }
webhook.statusMeaning
pendingNot yet sent
deliveredYour endpoint responded 2xx
failingThe latest attempt failed; it will be retried
gave_upRetries 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.

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", ""))