Recommendations
Practical guidance for a production-grade integration. None of this is required to make a call work — it's what keeps a multi-firm integration correct and secure over time.
Storing keys and webhook secrets
Every firm API key (mag_…) is scoped to exactly one organization and can only ever see that org's data. (An org can hold more than one key — that's how you rotate without downtime.) If you serve multiple firms, you hold at least one key per firm — treat them accordingly. (A partner key is the exception and the opposite: it spans the firms you manage but can't touch client data at all.)
- Never share a key across firms. A shared key would file one firm's returns under another. Store each firm's key against that firm's record, and resolve the key from the firm the work belongs to — with no global or fallback key.
- Encrypt keys and webhook secrets at rest. They are long-lived credentials. Seal them (e.g. AES-256-GCM) and decrypt only on use; don't keep them in plaintext config or logs. Bind the encryption to the firm (e.g. the org id as additional authenticated data) so a sealed key can't be moved between firm records.
- Keep the master key out of your codebase. Source it from a secrets manager / KMS, not an environment variable baked into an image.
- The secret is shown once, and rotates with the URL. The API key is returned only at creation. A key's webhook signing secret is issued when the key has a webhook URL and is rotated (a new value shown once) whenever you set or change that URL — so capture the new signing secret every time you touch a key's webhook URL, and update your verifier. The previous secret stops verifying immediately.
The example integration demonstrates exactly this — a per-firm sealed credential store behind a single seam.
Retrying safely
A network timeout on a write leaves you unable to tell whether it landed. Send an Idempotency-Key and you don't have to find out — retry the same request with the same key and, within the retention window, the work is not repeated.
In the ordinary case you get the original response back. In the rare case where the operation succeeded but we couldn't record its response, the retry is refused with 409 idempotency_request_in_progress rather than executed — deliberately, because repeating the write would be worse than making you reconcile. So treat the key as a guarantee about not doing the work twice, not as a promise that a response is always replayable.
curl -s "${auth[@]}" -H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"friendly_name":"Jane Q. Taxpayer"}' \
"$BASE/v1/clients"
- It's optional, and it works on every write — any
POST,PATCHorDELETEunder/v1, including the Partner API. Omit the header and behaviour is unchanged. - Generate one key per logical operation — a UUID per attempt-set, not per HTTP call. Reuse it for every retry of that same operation, and keep it with whatever you're persisting so a resumed job retries with the same key.
- Any non-blank string up to 255 characters works. A UUID is the usual choice. An empty header, or one over 255 characters, is rejected with
400 invalid_idempotency_key. Omitting the header is fine — but sending a blank one is an error rather than a no-op, so a key generator that returns nothing fails loudly instead of quietly leaving the write unprotected. - A replayed response carries
Idempotent-Replayed: true. The body is byte-identical to the original, so this header is the only way to tell stored work from fresh work — useful for retry logging and reconciliation. It's absent when the work actually happened. - Keys last 24 hours. Long past any sane retry window; after that the same key is treated as new.
- Same key, different body →
409 idempotency_key_reuse. That means a key got reused for a genuinely different request, which is a bug worth surfacing rather than silently accepting. This holds on the document upload endpoints too: uploads are matched on their contents and filenames, so a key reused with a different file is caught even when the sizes are identical. - Replay while the first is still running →
409 idempotency_request_in_progress. Wait a moment and retry; you'll then get the stored response. - The same code, persisting for the rest of the 24 hours, means we couldn't record a response after the operation succeeded. This is rare. The work is done and is deliberately not repeated, so that key stays unavailable for the remainder of its window — reconcile with a
GETrather than waiting or retrying with a fresh key. - Keys are scoped to your API key, so your key strings can never collide with another integrator's.
Without a key, a write is not safe to blindly retry — reconcile first with the endpoint that would show what it created (GET /v1/clients, GET /v1/submissions, or GET /v1/clients/{id}/documents for prepare, whose duplicate rows would otherwise be left orphaned). Those listings are cursor-paginated, so page through via next_cursor before concluding something is absent. Persisting each remote id as it comes back is still worthwhile either way — it lets an interrupted flow resume rather than restart.
Verifying webhooks
- Verify
X-Magnetic-Signature(HMAC-SHA256 over the raw request body) with a constant-time comparison before trusting a delivery. - Handle deliveries idempotently — delivery is at-least-once, so the same event can arrive more than once.
- A valid signature only proves the sender holds that firm's secret; still confirm the submission in the payload belongs to the firm you expect.
Documents
- Use the presigned
prepare→ upload →finalizepath for anything but the smallest files; it sends bytes straight to storage. - Before creating a submission, you can list a client's documents to confirm exactly which finalized documents it will take, and delete any uploaded in error.