Partner API

Manage the firms you serve and their API keys programmatically — the things that otherwise mean clicking through the partner console.

This is part of the Integrations API, not a separate one — the same /v1 surface, the same conventions, the same version number (it arrived in 1.0.3; see the Changelog). It gets its own page and its own Swagger because it serves a different reader with a different credential: your platform team automating onboarding, rather than your integration moving a client's documents.

Onboarding a firm at a glance

Onboarding has a handoff in the middle: you create the firm, but an admin at that firm has to accept your management before you can hold its API key. That acceptance is the firm consenting to you filing on its behalf, so it is theirs to give — you can't complete it for them.

You choose how they get there. Let us email the invitation, or redirect them yourself with the onboarding_url we return and get them back on your own return_url when they've answered — the shape an OAuth consent screen has, and the one to use when onboarding starts on your platform. See Onboarding from your own platform.

sequenceDiagram
    autonumber
    participant You as Your platform
    participant API as Magnetic API
    participant Admin as Firm admin
    You->>API: POST /v1/partner/firms (name, firm_admin_email)
    API-->>You: firm (organization_id, pending, onboarding_url)
    alt You run the hand-off
        You->>Admin: redirect to onboarding_url
    else You'd rather we invited them
        API->>Admin: invitation email
    end
    Note over You,API: Pending — issuing its key now returns 409 firm_not_accepted
    Admin->>API: creates the account, agrees to the terms
    Admin->>API: answers your request: approve or not now
    Note over You,API: Recommended: register a webhook, we notify you either way
    alt Approved
        API-->>You: POST webhook: firm.management_accepted
    else Not now
        API-->>You: POST webhook: firm.management_declined
        Note over You,API: Stays pending with declined_at set — you may ask again
    end
    opt You configured an onboarding return_url
        API->>Admin: back to you, with ?status= and ?organization_id=
        Note over You,Admin: A courtesy for the user — the signed webhook is the record
    end
    opt Fallback when you can't receive webhooks
        You->>API: GET /v1/partner/firms/{org_id}
        API-->>You: management_status + accepted_at / declined_at
    end
    You->>API: POST /v1/partner/firms/{org_id}/api_keys
    API-->>You: firm key mag_… (returned ONCE)
    Note over You,API: From here the mag_ key drives /v1/clients, documents, submissions
    You->>API: POST /v1/clients (as the firm)
    opt The firm ends it later, from its own settings
        API-->>You: POST webhook: firm.management_revoked
        Note over You,API: revoked — its keys stop working
    end

Two ways to know when acceptance lands, and you should use one of them rather than discovering it by trying: the webhook tells you the moment it happens, and GET /v1/partner/firms/{org_id} answers on demand. The 409 on key issuance is a safety net for a race, not a status check — see below.

You can rehearse this whole sequence — including the webhook — without a real firm or a real person: see Rehearsing the acceptance step.

Two credentials, on purpose

Firm API key Partner API key
Looks like mag_… magp_…
Scope of access One firm's data Your partner account
Can do Clients, documents, submissions List firms, issue/revoke their keys, webhook config
Cannot do Anything partner-level Touch any client data
Issued from The partner console, or this API The partner console only

A partner key cannot read or write client data — not by policy, but by construction: the two credentials live in separate stores, so a magp_ key presented to /v1/clients isn't recognised at all, and a mag_ key presented to /v1/partner isn't either. Both come back 401.

The workflow that follows from that: use your partner key to issue a firm key, then use that key against the Integrations API to move the firm's data.

export PBASE=https://api.magnetictax.com
pauth=(-H "Authorization: Bearer $MAGNETIC_PARTNER_KEY")

# 1. which firms do I manage?
curl -s "${pauth[@]}" "$PBASE/v1/partner/firms"

# 2. issue a key for one of them (returned ONCE)
curl -s "${pauth[@]}" -X POST "$PBASE/v1/partner/firms/org_123/api_keys" \
  -H 'Content-Type: application/json' \
  -d '{"name":"Production","submission_updated_webhook_url":"https://example.com/webhooks/magnetic"}'

# 3. from here on, that mag_ key is what talks to /v1/clients etc.

Getting a partner key

Partner keys are minted in the console — Partner → Settings → API keys — by a partner admin. A partner key can't mint another partner key, so a leaked one can't quietly extend its own reach.

The key is shown once, at creation. There is no way to retrieve it afterwards; if it's lost, revoke it and issue another.

You can hold up to 5 live keys at a time. That's enough to separate environments and to overlap during a rotation; revoking one frees a slot. The ceiling exists because this credential issues firm keys, so every copy in the wild reaches every firm you manage.

Scopes

Scope Grants
partner.read List and read firms, their API keys, and your webhook config
partner.keys.write Issue, rename, re-point and revoke firm API keys; set the partner webhook
partner.firms.write Onboard firms and invite their users

Ask for only what the integration needs. A reporting job should hold partner.read alone — then a leak of that key can't mint credentials. A call missing the right scope returns 403 with code insufficient_scope; GET /v1/partner echoes back the scopes the calling key actually holds, so you can check rather than guess.

Sandbox firms

Pass "sandbox": true to POST /v1/partner/firms to create a throwaway firm instead of a real one. A sandbox firm is auto-accepted, needs no firm_admin_email, sends no email, and its submissions never reach a preparer — so you can exercise the whole onboarding flow without leaving real organizations behind.

Add "auto_accept": false to have it begin pending instead, so you can rehearse the acceptance handshake too — see Rehearsing the acceptance step.

You may hold 5 at a time (sandbox_limit_reached). Delete one to free a slot.

Deleting a firm

DELETE /v1/partner/firms/{org_id} removes a firm you onboarded — the organization itself as well as our record of it, so nothing is left orphaned.

Your default sandbox can't be deleted (default_sandbox_protected). It doesn't count against the sandbox limit either.

Otherwise: only while it's still yours to remove. Once a real firm has accepted your management, the organization belongs to them — their staff, clients and submissions live in it — and this returns 409 firm_already_accepted. Sandbox firms can always be deleted. A firm you didn't onboard returns 404.

Onboarding a firm

POST /v1/partner/firms creates the firm and invites the person who will administer it.

curl -s "${pauth[@]}" -X POST "$PBASE/v1/partner/firms" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"name":"Acme Tax LLC","firm_admin_email":"[email protected]"}'

firm_admin_email is required, not optional. You join the firm as a member, not an admin — so without an admin of their own the firm can never accept your management, and would sit pending forever. We validate it before creating anything, so a bad request doesn't leave a half-made organization behind.

Send an Idempotency-Key when you onboard. It's optional, as on every write — but these endpoints provision a real organization and send email, so a retried timeout without one can leave a second firm to clean up. Use a unique key per firm you onboard and reuse it for every retry of that onboarding. See Retrying safely.

If an onboarding call times out, retry it with the same Idempotency-Key — that replays the original response, organization_id included, and is the reliable way to recover. Only if you didn't send one should you fall back to reconciling: GET /v1/partner/firms and match name against your own record before creating anything. Persist the organization_id as soon as you have it; it is the identifier every other endpoint takes, and unlike the name it is yours the moment the firm is created.

The new firm comes back pending. Once its admin accepts, it flips to accepted and you can issue its API keys.

Onboarding from your own platform

By default the firm admin's only way in is the invitation email. If you'd rather start the flow on your platform — your user clicks "connect to Magnetic" and comes back when they're done — use the two halves of the hand-off.

Send them in. Every create returns an onboarding_url: the link that email carries, handed to you directly so you can redirect the admin instead of waiting on an inbox you can't see.

curl -s "${pauth[@]}" -X POST "$PBASE/v1/partner/firms" \
  -H 'Content-Type: application/json' -H "Idempotency-Key: $(uuidgen)" \
  -d '{"name":"Acme Tax LLC","firm_admin_email":"[email protected]"}'
# → { "organization_id": "org_123", "management_status": "pending", "onboarding_url": "https://…", … }

onboarding_url is a credential. Whoever opens it creates that firm's admin account. Redirect the user to it; don't log it, email it onward, or put it anywhere it outlives the session. If you lose it, GET /v1/partner/firms/{org_id} returns it again while the firm is still pending and your key holds partner.firms.write — a read-only key never receives one.

Get them back. Set a return URL once, and every firm that answers lands back on it:

curl -s "${pauth[@]}" -X PATCH "$PBASE/v1/partner/onboarding" \
  -H 'Content-Type: application/json' \
  -d '{"return_url":"https://yourapp.example/magnetic/connected"}'
# → { "return_url": "https://yourapp.example/magnetic/connected" }

We append status (accepted, declined or revoked) and organization_id, so the page they land on can say what happened:

https://yourapp.example/magnetic/connected?status=accepted&organization_id=org_123

https only, and no credentials in the URL — it's where a real person's browser is sent. GET /v1/partner/onboarding reads it back; {"return_url": null} clears it, after which a firm that answers simply stays in the Magnetic portal. Setting it needs partner.keys.write, the same scope as the webhook: both decide where something of yours is sent.

The redirect is a courtesy, not the record. A user can close the tab, answer on a different device, or lose connectivity on the way back — and the answer still stands. Treat the signed firm.management_* webhook as the source of truth and the redirect as the thing that makes the experience feel finished.

Checking whether a firm has accepted

GET /v1/partner/firms/{org_id} reports it directly, and GET /v1/partner/firms reports it for every firm you manage:

curl -s "${pauth[@]}" "$PBASE/v1/partner/firms/org_123"
# → { "organization_id": "org_123", "management_status": "pending", "accepted_at": null, … }

management_status is one of pending, accepted or revoked, and accepted_at carries the timestamp once it flips. Either endpoint needs only partner.read, so a job that watches for acceptance doesn't have to hold a credential that can mint keys.

A firm can also say no. Declining sets declined_at and leaves the status pending — it means "not now", not "never", and you may ask again — so declined_at is the only thing that distinguishes a firm that hasn't answered from one that has answered no. Poll management_status alone and you'll wait indefinitely on a firm that already declined.

And a firm that accepted can end it later. That sets revoked: its API keys stop working and every operation on it fails. A revoked firm stays in GET /v1/partner/firms and remains readable, so reconciliation can see that the arrangement ended rather than inferring it from a firm that silently disappeared.

You see What it means What to do
pending, declined_at: null Not answered yet Keep waiting
pending, declined_at set Answered "not now" Stop polling; follow up out of band
accepted Approved Issue the firm's key
revoked Ended by the firm Stop submitting; the keys are dead

Don't use key issuance to test acceptance. Attempting POST .../api_keys and treating 409 as "not yet" works, but it makes a write your status check: it's rate-limited alongside your real onboarding traffic, and on success it mints a real credential you then have to store or revoke — a GET costs you nothing and mints nothing. The 409 exists to make the race safe (an acceptance landing between your check and your call), not to answer the question.

Prefer the webhook to polling. Set your onboarding webhook and firm.management_accepted arrives the moment a firm accepts, so nothing has to poll at all. The GET is the right fallback when you can't receive webhooks, and the right way to reconcile state at startup or after an outage — see Retrying safely.

POST /v1/partner/firms/{org_id}/invitations invites anyone else to a firm that has already accepted — an admin by default, or {"as_admin": false} for an ordinary member.

Both need partner.firms.write. It is a separate scope from partner.keys.write — neither implies the other, and a 403 names the one you're missing.

In practice the console issues them together: a management key gets read, keys and firms, because onboarding is create the firm, then issue its key and a key that could only do the second half couldn't finish the job. If you want a credential that genuinely cannot create companies, use a view-only key.

Firms

GET /v1/partner/firms returns the firms you manage, each keyed by the organization_id you'll use in every other path.

{
  "firms": [
    {
      "organization_id": "org_3Gpc0EoJuPJxgIiTCm6v2K6htvr",
      "name": "Acme Tax",
      "management_status": "accepted",
      "is_sandbox": false,
      "created_at": "2026-07-02T14:03:11+00:00",
      "accepted_at": "2026-07-02T16:40:55+00:00"
    }
  ]
}

You can only issue keys for a firm whose management_status is accepted. That field is how you check acceptance — see Checking whether a firm has accepted; don't infer it from whether key issuance succeeds.

A firm you don't manage returns 404, not 403. That's deliberate: a 403 would confirm the organization exists, which would let anyone with a partner key probe for org ids.

Firm API keys

The endpoints mirror what the console does:

GET /v1/partner/firms/{org_id} One managed firm
GET /v1/partner/firms/{org_id}/api_keys The keys you issued for this firm
POST /v1/partner/firms/{org_id}/api_keys Issue one — api_key returned once
PATCH /v1/partner/firms/{org_id}/api_keys/{id} Rename, or set/clear the webhook URL
DELETE /v1/partner/firms/{org_id}/api_keys/{id} Revoke — effective immediately

Two things worth knowing before you automate against these:

Setting a webhook URL rotates that key's signing secret. The new secret comes back once in webhook_signing_secret, and the previous one stops verifying immediately — so update your handler before you move traffic. See Webhooks.

Send an Idempotency-Key when issuing. Without one, a request that times out and gets retried issues a second key. With one, the retry returns the original response and no extra key exists — see Retrying safely.

Looking for webhook delivery history? It's in the partner console, on each key's detail page — not on this API. It's a troubleshooting view for a human chasing a specific failure, and we'd rather keep it free to change than freeze its shape into an API contract.

Onboarding webhook

GET / PATCH /v1/partner/webhook configures the partner-level webhook we POST a signed event to whenever a managed firm answers — so onboarding can react instead of polling. Three events arrive here, in one shape: firm.management_accepted, firm.management_declined and firm.management_revoked. Handle all three; listening only for accepted leaves you waiting on a firm that declined and filing for one that revoked. Signature verification works exactly as it does for submission.updated; see Firm management events.

Testing against your sandbox firm

Your sandbox firm appears in GET /v1/partner/firms like any other, with is_sandbox: true, and works as an {org_id} target for every endpoint above. It's provisioned already accepted, so you can issue keys for it immediately — no acceptance step to wait on.

Use it to exercise the whole flow before touching a real firm — including your retry path, which is worth proving against the sandbox firm rather than a client's.

The firm keys you issue for the sandbox firm behave like sandbox keys downstream — their submissions never reach a real preparer. See Sandbox testing for that half of the loop.

Rehearsing the acceptance step

Auto-acceptance is convenient, but it skips the middle of real onboarding: there's nothing to accept, so no firm.management_accepted arrives and key issuance never refuses. That leaves the two branches your integration most needs to get right untested.

auto_accept: false plus POST /v1/partner/firms/{org_id}/sandbox/accept put those steps back. The sequence is the production one with the firm admin's acceptance replaced by that one call (shaded below) — everything else, including the webhook you receive and the management_status you read, is unchanged:

sequenceDiagram
    autonumber
    participant You as Your platform
    participant API as Magnetic API
    You->>API: POST /v1/partner/firms (sandbox, auto_accept: false)
    API-->>You: firm (organization_id, management_status: pending)
    You->>API: GET /v1/partner/firms/{org_id}
    API-->>You: management_status: pending
    rect rgba(127, 127, 127, 0.15)
        Note over You,API: No human needed — this call stands in for the firm admin
        You->>API: POST /v1/partner/firms/{org_id}/sandbox/accept
        API-->>You: management_status: accepted
    end
    API-->>You: POST webhook: firm.management_accepted
    You->>API: GET /v1/partner/firms/{org_id}
    API-->>You: management_status: accepted + accepted_at
    You->>API: POST /v1/partner/firms/{org_id}/api_keys
    API-->>You: firm key mag_… (returned ONCE)

A sandbox firm accepts on creation by default. Pass "auto_accept": false alongside "sandbox": true to have it begin pending, like a real one:

# 1. a sandbox firm that behaves like a real one
curl -s "${pauth[@]}" -X POST "$PBASE/v1/partner/firms" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"name":"Rehearsal Co","sandbox":true,"auto_accept":false}'

# 2. it reads as pending, exactly as a real firm awaiting its admin would
curl -s "${pauth[@]}" "$PBASE/v1/partner/firms/$ORG"
# → { "management_status": "pending", "accepted_at": null, … }

# 3. accept it, as its admin would in the app
curl -s "${pauth[@]}" -X POST "$PBASE/v1/partner/firms/$ORG/sandbox/accept"

# 4. your firm.management_accepted webhook fires; status now reads accepted
curl -s "${pauth[@]}" "$PBASE/v1/partner/firms/$ORG"
# → { "management_status": "accepted", "accepted_at": "2026-08-13T…", … }

# 5. and the key now issues
curl -s "${pauth[@]}" -X POST "$PBASE/v1/partner/firms/$ORG/api_keys"

This is also how you prove your 409 firm_not_accepted handling, if you want to exercise that branch deliberately: issue a key between steps 1 and 3 and you'll get it. Just don't build that into your normal flow — check the status instead.

POST /v1/partner/firms/{org_id}/sandbox/accept performs the same transition a firm admin performs in the app and fires the same signed firm.management_accepted event — so what your endpoint receives is identical to production. That's the point: a bespoke "test event" would prove nothing about the code that has to work on a real firm.

The response carries already_accepted, which tells you whether this call caused the acceptance:

{ "management_status": "accepted", "already_accepted": false }
  • false — this call performed the transition, and firm.management_accepted was dispatched. Expect the delivery.
  • true — the firm was already accepted, so nothing changed and no webhook was sent. You get this on a retry, or if a concurrent call won the race.

Both are 200: the call is idempotent, so repeating it is safe. The flag exists because without it a retry is indistinguishable from a fresh acceptance, and you'd wait for a delivery that is never coming. A real acceptance behaves the same way.

Not to be confused with the error code firm_already_accepted, which is unrelated: that's a 409 on deleting a firm that has accepted your management.

It only works on a sandbox firm. On a real one it returns 403 sandbox_only: accepting management is a firm's consent to you filing on its behalf, and only its own admin can give it. auto_accept is likewise rejected with 400 on a non-sandbox firm — including false. It is redundant there (a real firm always starts pending), and a field documented sandbox-only should not quietly provision a real organization for anyone testing what it does. Omit it entirely for real firms.

Errors and limits

Identical to the Integrations API: the same stable code vocabulary, the same enveloped list shape, and Retry-After on 429. See Errors & rate limits. Codes you'll see most here:

code Status Meaning
malformed_api_key 401 Not shaped like a magp_… key — often a firm mag_ key sent by mistake
invalid_api_key 401 Well-formed but unrecognized (revoked, or a typo)
insufficient_scope 403 The key lacks partner.read / partner.keys.write / partner.firms.write
partner_paused 403 Your API access is paused; issuing keys is blocked, reads still work
sandbox_only 403 A sandbox-only control was aimed at a real firm — only its own admin can accept its management
firm_revoked 409 This firm's management was revoked, so it can't be accepted
invitation_role_conflict 409 A pending invitation exists at a different role and couldn't be withdrawn
default_sandbox_protected 409 Your default sandbox can't be deleted
firm_already_accepted 409 The firm accepted your management — it can no longer be deleted
sandbox_limit_reached 409 You hold the maximum number of sandbox firms; delete one first
firm_not_accepted 409 The firm hasn't accepted your management yet — you can't issue its keys or invite its users until it does
not_found 404 No such firm or key — including one you don't manage, or one you didn't issue

What's not here yet

Cross-firm reporting — submissions across all your managed firms, and billing credits — is still on the roadmap. Everything the partner console can do to a firm's keys and membership is now available here.

results matching ""

    No results matching ""