API reference

The full, always-current reference is the interactive OpenAPI/Swagger UI:

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 clientPOST /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 uploadsPOST /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 uploadsPOST /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 submissionPOST /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 submissionGET /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/clients returns { "clients": [...], "next_cursor": … } and GET /v1/submissions returns { "submissions": [...], "next_cursor": … }.
  • Pagination: the list endpoints are cursor-paginated — see Pagination below.
  • Errors carry a stable machine-readable code alongside message — branch on code. See Errors & codes.
  • Versioning: the surface is under /v1 and is currently 1.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 /v prefix. 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.

results matching ""

    No results matching ""