Webhooks & events
Get notified when async generations finish, batch jobs complete, or quotas fire.
Webhooks & events
SimplyFill posts JSON events to URLs you control whenever something interesting happens server-side. The two most common reasons to wire a webhook: an async PDF generation finished and the file is ready to download, and a quota warning threshold has been crossed. Webhooks are configured per environment — your staging endpoint won't receive prod events, and vice versa.
Event types
Today's supported events:
| Event | When it fires | Common payload fields |
|---|---|---|
generation.completed | An async POST /api/v1/generate/pdf finished successfully | task_id, template_id, download_url, expires_at |
generation.failed | An async generation failed permanently after retries | task_id, template_id, error, error_code |
batch.completed | A bulk envelope or batch job finished | batch_job_id, total, succeeded, failed, download_url |
batch.failed | A bulk job failed before producing results | batch_job_id, error |
Roadmap (v2)
template.version_published, quota.threshold_crossed, and per-row batch events are planned. Today, quota crossings surface via dashboard banners and account-owner emails only.
Every event shares a common envelope:
{
"id": "evt_2nB9d4Z...",
"type": "generation.completed",
"created_at": "2026-05-17T14:22:09Z",
"environment": "production",
"data": {
"task_id": "tsk_98e2b...",
"template_id": 123,
"download_url": "https://api.simplyfill.app/v1/generate/download/abc",
"expires_at": "2026-05-18T14:22:09Z"
}
}Registering a webhook
curl -X POST https://api.simplyfill.app/v1/webhook-configs \
-H "Authorization: Bearer $SIMPLYFILL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/simplyfill",
"events": ["generation.completed", "generation.failed"],
"active": true
}'The response includes a webhook.id and a secret — store the secret somewhere durable. SimplyFill won't show it to you again, and you'll need it to verify every incoming delivery.
Signature verification
Every webhook delivery carries two headers:
| Header | Meaning |
|---|---|
SimplyFill-Signature | sha256=<hex> — HMAC-SHA-256 of the raw request body, using your webhook secret as the key |
SimplyFill-Event | The event type (matches the type field in the body) |
Always verify the signature before trusting the body. A forged request without a valid signature is the easiest way to poison your system; the verification step is two lines of code.
# Verifying via shell — read the raw body and the header, then hash.
RAW_BODY=$(cat) # from stdin in your webhook handler
SIGNATURE=${HTTP_SIMPLYFILL_SIGNATURE#sha256=}
EXPECTED=$(printf '%s' "$RAW_BODY" | openssl dgst -sha256 -hmac "$SIMPLYFILL_WEBHOOK_SECRET" -binary | xxd -p -c 256)
if [ "$EXPECTED" = "$SIGNATURE" ]; then
echo "valid"
else
echo "INVALID — reject"
fiimport crypto from 'node:crypto'
export function verifyWebhook(
rawBody: string,
signatureHeader: string,
secret: string,
): boolean {
const provided = signatureHeader.replace(/^sha256=/, '')
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex')
// timingSafeEqual avoids leaking the secret via response-time analysis
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(provided, 'hex')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}import hmac
import hashlib
def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
provided = signature_header.removeprefix("sha256=")
expected = hmac.new(
secret.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
# compare_digest is constant-time
return hmac.compare_digest(expected, provided)Your handler MUST verify the signature against the raw, unparsed request body. Most web frameworks parse JSON automatically — make sure you grab the body before parsing, or use the framework's raw-body hook.
Retry policy
A delivery is considered successful when your endpoint responds with any 2xx status within 10 seconds. Anything else is a failure and triggers a retry:
| Attempt | Delay after previous |
|---|---|
| 1 | immediately |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
| 7 | 24 hours |
After the seventh attempt the delivery is marked permanently failed. You can see every attempt, including request and response bodies, in Dashboard → Webhooks → Deliveries or via GET /api/v1/webhook-configs/{id}/deliveries.
Idempotency is your responsibility — duplicate deliveries can and will happen (especially during the retry window when your endpoint flapped between 200 and 500). Use the id field in the event envelope as your idempotency key.
Test deliveries
The dashboard has a Send test event button on every webhook config. Programmatically:
curl -X POST https://api.simplyfill.app/v1/webhook-configs/test \
-H "Authorization: Bearer $SIMPLYFILL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"webhook_id": 42, "event_type": "generation.completed"}'The test fires a synthetic generation.completed event (or whichever type you name) to the registered URL with a valid signature derived from your stored secret. Use this to confirm signature verification works end-to-end after a deploy.
Common failure modes
- Wrong signature — usually caused by parsing the request body before computing the HMAC. Grab the raw bytes, then verify, then parse.
- Timeouts — your handler took longer than 10 seconds. Queue the work and respond 2xx immediately; do the slow part asynchronously.
- TLS issues — SimplyFill rejects endpoints that present an expired or invalid certificate. Test with
openssl s_client -connect hooks.example.com:443. - Redirects — SimplyFill follows up to one 3xx; further redirects are treated as a failure. Use the final URL directly when you register.
What's next
- Webhooks (API) — register, list, delete, inspect deliveries
- HR onboarding workflow — async generation + webhook completion in a real flow
- Quotas — quota events fire on the same webhook channel