Partner API Webhooks
Webhooks push Partner API events to your server so you don't have to poll: an import finishes, a patient submits, a completed PDF is ready. Deliveries are signed and PHI-minimized by design — events carry ids and retrieve URLs, never patient answers.
Registering
POST /webhooks (scope webhooks:manage) registers a delivery URL (public HTTPS only) and mints its signing secret:
curl -X POST -H "Authorization: Bearer $EDF_KEY" -H "Content-Type: application/json" \
-d '{"url":"https://example.com/edf-webhook","events":["submission.created"]}' \
https://form.easydocforms.com/api/v1/webhooksThe whsec_* secret is returned exactly once, in this response — store it. Omitting events (or sending an empty array) subscribes to all events. At most 10 active webhooks per organization. GET /webhooks lists active subscriptions (without secrets); DELETE /webhooks/{webhook_id} deactivates one.
POST /webhooks/{webhook_id}/test synchronously delivers one signed test event so you can verify your receiver and signature check end to end — the response reports delivered, your endpoint's status_code, or the transport error.
Events
| Event | Fires when | Data |
|---|---|---|
import.completed | A queued import finished and its template is ready | import_id, template_id, page_count, field_count, review_required |
import.failed | A queued import failed (invalid PDF, processing error) | import_id, error |
submission.created | A patient submitted a form via one of your fill links | submission_id, template_id, fill_link_id, external_ref, retrieve_url |
submission.pdf_ready | The completed PDF was frozen at submission and is ready | submission_id, template_id, external_ref, pdf_url |
test | You called the test endpoint | webhook_id |
submission.created carries no answers — fetch them from retrieve_url with your API key. submission.pdf_ready fires alongside submission.created when the freeze-on-submit render succeeded (the common case); when it doesn't fire, the PDF is still retrievable — the /pdf endpoint renders on demand.
The envelope
Every delivery is an HTTP POST of:
{
"event": "submission.created",
"timestamp": "2026-08-22T11:00:05Z",
"org_id": "org_...",
"data": { "submission_id": "sub_...", "external_ref": "visit-8675309", "...": "..." }
}Verifying signatures
Every delivery carries:
X-EDF-Signature: t=<unix seconds>,v1=<hex hmac-sha256(secret, "<t>.<raw request body>")>Verify by recomputing the HMAC over the raw body bytes (before any JSON parsing), comparing v1 in constant time, and rejecting timestamps more than 5 minutes from now (the replay window). Signatures are minted fresh per delivery attempt.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("=", 2))
);
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.t || !parts.v1 || age > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(parts.v1, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}All four SDKs ship a webhook signature verifier — use it instead of hand-rolling one.
Retries and dedup
- Every delivery carries
X-EDF-Delivery-Id, minted once per event and reused verbatim on retries — deduplicate on it. - Failed deliveries (network errors,
408/429/5xx) are retried with exponential backoff: roughly 1 minute doubling toward a 6-hour cap, up to 10 attempts over about a day. Each retry is re-signed fresh, so the timestamp check keeps working. - A non-
4294xxresponse stops retries permanently. - Return
2xxfast (do the work after acknowledging). If you were down longer than the retry window, reconcile withGET /submissions?submitted_since=....