Webhooks
Webhooks are the recommended way to track a submission's status. Register a callback URL on your API key and we'll POST a signed event whenever a submission's status changes — so you're notified the moment a return is ready, instead of polling GET /v1/submissions/{id}. Polling GET /v1/submissions/{id} is a supported fallback when you can't receive webhooks.
To enable, set a submission_updated_webhook_url on your API key — when you create the key, or edit it anytime on an existing one. A firm does this from its key in the portal (Preferences → Self-managed API); a partner from the firm's key in the partner console or via the Partner API.
Signing secret — issued with a URL, and rotated when the URL changes. A key gets its own whsec_… signing secret only once it has a webhook URL (a key created with no URL has no secret — there's nothing to sign yet). Setting or changing the URL rotates the secret: a new whsec_… is issued and shown once, and the previous one stops verifying immediately. So whenever you set or change a key's webhook URL, capture the new signing secret and update your verifier. Clearing the URL removes the secret. (Rotating on change means a webhook that moves to a new endpoint can't keep validating against a secret its new owner might not hold.)
Event
The main event is submission.updated, fired on every status change — including creation (processing), completed, and cancelled. A submission is created directly in processing, so you won't receive a received event; still, write your handler to tolerate a status it doesn't recognize.
Partners also get firm-management events on a partner-level webhook, whenever a managed firm answers — see Firm management events below. All event types use the same HMAC-SHA256 algorithm (below), but the secret differs by event type: verify submission.updated with the API key's whsec_… signing secret, and every firm.management_* event with the partner-level signing secret shown in the partner console (see partners.md) — using the wrong secret will make valid onboarding webhooks fail verification.
Firm management events
Three events, one partner-level webhook, one payload shape — they differ only in event:
| Event | What happened | Resulting management_status |
|---|---|---|
firm.management_accepted |
The firm approved your management | accepted — issue its API key |
firm.management_declined |
The firm answered "not now" | stays pending, with declined_at set — you may ask again |
firm.management_revoked |
A firm that had approved you ended the arrangement | revoked — its keys stop working |
{
"event": "firm.management_declined",
"delivered_at": "2026-08-17T20:00:00.000000+00:00",
"firm": { "organization_id": "org_…", "name": "Acme Tax LLC" }
}
Handle all three. An integration listening only for accepted waits forever on a firm that declined, and keeps filing for one that revoked. As with every webhook, treat the event as the trigger and the API as the truth: GET /v1/partner/firms/{org_id} reports the same state, so a missed delivery is recoverable — see Retrying safely.
Payload
The POST body is JSON:
{
"event": "submission.updated",
"delivered_at": "2026-07-10T20:00:00.000000+00:00",
"submission": {
"id": 789,
"client_id": 123,
"status": "completed",
"tax_year": 2025,
"tax_software": "Drake",
"output_documents": [
{ "id": 999, "filename": "..._Return.DI5", "category": "tax_return", "download_url": "https://…" },
{ "id": 998, "filename": "workpapers.pdf", "category": "workpapers", "download_url": "https://…" },
{ "id": 997, "filename": "..._notes.txt", "category": "review_notes", "download_url": "https://…" },
{ "id": 996, "filename": "..._notes.pdf", "category": "review_notes", "download_url": "https://…" }
]
}
}
The submission object is the same shape as GET /v1/submissions/{id}. Download URLs are presigned and time-limited — if you don't use them immediately, re-fetch the submission.
Verifying signatures
Each delivery is signed with HMAC-SHA256 over the raw request body, sent in the header:
X-Magnetic-Signature: sha256=<hex_hmac>
Recompute and compare (constant-time) with the signing secret for that event type — for submission.updated, your key's whsec_… secret (shown once when you set or change the key's webhook URL, and rotated on each change — save the new value then); for the partner-level firm.management_accepted, the partner signing secret from the partner console:
import hmac, hashlib
def verify(raw_body: bytes, header: str, signing_secret: str) -> bool:
expected = "sha256=" + hmac.new(signing_secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or "")
Reject the delivery if the signature doesn't match.
Managing many firms (partners)
Each partner-issued key has its own webhook URL and its own signing secret, and the payload identifies the client, not the firm. So point each firm's key at a distinct callback URL (e.g. /webhooks/{firm}) — the URL tells you which firm the event belongs to and therefore which signing secret to verify it with.
Delivery & retries
- We retry non-2xx responses a few times with backoff. Make your handler idempotent (a status can be delivered more than once).
- Respond
2xxquickly; do heavy work asynchronously. - Delivery history (troubleshooting). Each delivery retry set is recorded as a single record capturing its final outcome — the event, destination URL, final HTTP status, and the number of attempts made. A firm sees its own deliveries in the portal (Preferences → Self-managed API); a partner sees deliveries across its managed firms in the partner console. Use it to confirm we fired an event and see what your endpoint returned, without adding logging on your side.
- Webhooks are your primary completion signal; treat an occasional
GET /v1/submissionsreconciliation as a safety net, not your main loop. Delivery is best-effort (at-least-once), not exactly-once — so make your handler idempotent and reconcile if a delivery is ever in doubt.
Testing with the sandbox
Point a sandbox API key's webhook at your endpoint, create a submission with that sandbox key, then drive it to a terminal state with the sandbox endpoints — each transition fires a real signed webhook so you can validate your handler end-to-end.