API reference
The full, always-current reference is the interactive OpenAPI/Swagger UI:
- Swagger UI:
/v1/docs - OpenAPI spec (JSON):
/v1/openapi.json— import into Postman, generate clients, etc.
Both are public — you can read the full contract (every request/response schema and enum) without a key. You only need a key to try calls: use the Authorize button in Swagger to paste your mag_… key and run endpoints live.
Endpoint summary
All paths are under /v1. These require Authorization: Bearer mag_… (a firm key) except the docs endpoints. The partner-management endpoints under /v1/partner take a partner key (magp_…) instead and are listed in the Partner API.
| Method | Path | Scope | Purpose |
|---|---|---|---|
| GET | /v1/organization |
any | Who am I (org, owner, scopes) |
| GET | /v1/clients |
clients.read |
List clients |
| POST | /v1/clients |
clients.write |
Create a client |
| GET | /v1/clients/{id} |
clients.read |
Get a client (with its submissions) |
| PATCH | /v1/clients/{id} |
clients.write |
Update a client (e.g. rename) |
| DELETE | /v1/clients/{id} |
clients.write |
Delete/archive a client |
| POST | /v1/clients/{id}/documents/prepare |
documents.write |
Request presigned upload targets |
| POST | /v1/clients/{id}/documents/finalize |
documents.write |
Confirm presigned uploads |
| POST | /v1/clients/{id}/documents |
documents.write |
Direct multipart upload |
| GET | /v1/clients/{id}/documents |
clients.read |
List a client's documents (with each document's state) |
| DELETE | /v1/clients/{id}/documents/{doc_id} |
documents.write |
Delete a document (refused once attached to a submission) |
| POST | /v1/clients/{id}/submissions |
submissions.write |
Create a submission (auto-detects resubmissions) |
| GET | /v1/submissions |
submissions.read |
List submissions (?client_id=, ?status=) |
| GET | /v1/submissions/{id} |
submissions.read |
Get a submission's status (webhooks preferred; polling is a fallback) |
| POST | /v1/submissions/{id}/cancel |
submissions.write |
Cancel a submission (within 30 minutes of creating it) |
| POST | /v1/submissions/{id}/sandbox/complete |
submissions.write |
Sandbox only: force completed |
| POST | /v1/submissions/{id}/sandbox/cancel |
submissions.write |
Sandbox only: force cancelled |
Partner management (magp_ key)
Partners can also manage the firms they serve and those firms' keys. These take a partner key, not a firm key, and are documented in full — with their own Swagger — on the Partner API page.
| Method | Path | Scope | Purpose |
|---|---|---|---|
| GET | /v1/partner |
partner.read |
Who am I (partner, status, this key's scopes) |
| GET | /v1/partner/firms |
partner.read |
List the firms you manage |
| POST | /v1/partner/firms |
partner.firms.write |
Onboard a firm (invites its admin) |
| GET | /v1/partner/firms/{org_id} |
partner.read |
Get one managed firm |
| DELETE | /v1/partner/firms/{org_id} |
partner.firms.write |
Remove a firm you onboarded, while it's still yours to remove |
| POST | /v1/partner/firms/{org_id}/invitations |
partner.firms.write |
Invite someone to a managed firm |
| POST | /v1/partner/firms/{org_id}/sandbox/accept |
partner.firms.write |
Sandbox only: accept management, to rehearse onboarding |
| GET | /v1/partner/firms/{org_id}/api_keys |
partner.read |
List the keys you issued for that firm |
| POST | /v1/partner/firms/{org_id}/api_keys |
partner.keys.write |
Issue a firm key (returned once) |
| PATCH | /v1/partner/firms/{org_id}/api_keys/{id} |
partner.keys.write |
Rename, or set/clear the webhook URL |
| DELETE | /v1/partner/firms/{org_id}/api_keys/{id} |
partner.keys.write |
Revoke a key you issued |
| GET | /v1/partner/webhook |
partner.read |
Read your onboarding webhook config |
| PATCH | /v1/partner/webhook |
partner.keys.write |
Set or clear your onboarding webhook URL |
| GET | /v1/partner/onboarding |
partner.read |
Read where firms return to after answering |
| PATCH | /v1/partner/onboarding |
partner.keys.write |
Set or clear that return URL |
Core request/response shapes
The essentials for the happy path, inline. The Swagger UI above has the complete, always-current contract for every field and endpoint.
Create a client — POST /v1/clients
// request (friendly_name required; taxpayer_tin optional — dashes stripped, stored as digits)
{ "friendly_name": "Jane Q. Taxpayer", "taxpayer_tin": "123-45-6789" }
// 201 response (taxpayer_tin is null when you don't send one)
{ "id": 812, "friendly_name": "Jane Q. Taxpayer", "taxpayer_tin": 123456789, "created_at": "2026-06-23T20:00:00Z" }
Prepare presigned uploads — POST /v1/clients/{id}/documents/prepare
// request
{ "documents": [ { "filename": "W2.pdf", "content_type": "application/pdf" } ] }
// 200 response — POST each file to its target (form-data), then finalize. Failures: prefer
// `failed_document_ids`; `failed_document_indices` is deprecated (kept for failures with no id).
{ "upload_targets": [ { "document_id": 456, "upload_url": "https://s3…", "fields": { "…": "…" } } ],
"failed_document_ids": [], "failed_document_indices": [] }
Finalize presigned uploads — POST /v1/clients/{id}/documents/finalize
// request
{ "document_ids": [456] }
// 200 response — failures come back as `failed_document_ids` (the older `failed_document_indices` is deprecated)
{ "documents": [ { "id": 456, "filename": "W2.pdf", "download_url": "https://…" } ],
"failed_document_ids": [], "failed_document_indices": [] }
Create a submission — POST /v1/clients/{id}/submissions
// request (proconnect_* only when tax_software is "ProConnect")
{ "tax_software": "Drake", "tax_year": 2025 }
// 201 response — status starts at "processing"; full shape below
Deleting a client
DELETE /v1/clients/{id} archives the client — and it isn't only the client that changes. Any of that client's submissions still in flight are cancelled automatically, with cancellation_code: client_deleted, and each fires a submission.updated webhook. A submission a preparer has already picked up is left alone rather than cancelled.
So if you delete a client as cleanup, expect cancellation webhooks for its open work — don't treat them as failures.
Get a submission — GET /v1/submissions/{id} → the submission object (also delivered in the webhook):
{
"id": 789, "client_id": 812, "status": "completed",
"tax_year": 2025, "tax_software": "Drake",
"is_resubmission": false, "resubmission_of": null,
"expected_completion_at": "2026-06-26T20:00:00Z", "created_at": "2026-06-23T20:00:00Z",
"input_documents": [ { "id": 456, "filename": "W2.pdf", "download_url": "https://…" } ],
"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://…" }
]
}
Note review_notes appears twice — the notes ship as a .txt and normally also as a rendered .pdf. See Submissions & statuses for every field, the status values, and cancellation codes.
Conventions
- IDs are integers (the resource's own id — client, submission, document). Pass them back exactly as received, in the path or request body.
- Timestamps are ISO‑8601 UTC.
- List responses are enveloped, not bare arrays:
GET /v1/clientsreturns{ "clients": [...], "next_cursor": … }andGET /v1/submissionsreturns{ "submissions": [...], "next_cursor": … }. - Pagination: the list endpoints are cursor-paginated — see Pagination below.
- Errors carry a stable machine-readable
codealongsidemessage— branch oncode. See Errors & codes. - Versioning: the surface is under
/v1and is currently1.0.5— one version line shared with the Partner API. It stays backwards compatible — additive changes bump the patch; anything we replace is deprecated and only removed in the next minor version. Truly breaking changes would ship under a new/vprefix. What landed in each version is in the Changelog.
Pagination
The list endpoints — GET /v1/clients and GET /v1/submissions — are cursor-paginated (keyset, not offset), returning newest-first pages. Two names are involved, and they are deliberately different:
| Where | Name | Meaning |
|---|---|---|
| Request (query param) | limit |
Page size. Default 100, max 500. |
| Request (query param) | cursor |
Where to continue from. Omit it on the first page. On later pages, set it to the previous response's next_cursor. |
| Response (body field) | next_cursor |
Opaque token for the next page. null on the last page — that's how you know you're done. |
So the loop is: first request has no cursor; each response hands you a next_cursor; pass that value back as ?cursor= on the next request; stop when next_cursor comes back null. The token is opaque — don't parse, construct, or persist it across schema changes; just echo it back.
# page 1 — no cursor
curl -s "${auth[@]}" "$BASE/v1/clients?limit=100"
# → { "clients": [...], "next_cursor": "eyJpZCI6..." }
# page 2 — feed the previous next_cursor back in as ?cursor=
curl -s "${auth[@]}" "$BASE/v1/clients?limit=100&cursor=eyJpZCI6..."
# → { "clients": [...], "next_cursor": null } # last page
Because it's keyset pagination, paging is stable under inserts/deletes (no skipped or repeated rows the way offset pagination drifts), and there is no total-count — you page until next_cursor is null.