Partners
POST /partners
Create a partner and grant them programs. By default, sends a magic-link invite email.
Auth: Admin API key, OR scoped key with partners:write, OR scoped key with
partners:create (limited: see partners:create keys).
POST <base>/partnersAuthorization: Bearer <key>Content-Type: application/json
{ "name": "Alice", "sendInvite": true, "programIds": ["prg_starter", "prg_pro"], "programCommissions": [ { "programId": "prg_starter", "commissionType": "percent", "commissionValue": 40, "holdbackDays": 14, "commissionRules": [{ "trigger": "every", "type": "percent", "value": 40, "recurring": true }] } ]}| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | Lowercased on storage. Returns 409 email_taken if a partner already has it; look them up with GET /partners?email=. |
name | string | yes | Display name. |
sendInvite | boolean | no | Default true. When false, the partner is created active and OpenPartner sends no email. Use it when your own app is the partner’s UI (an in-app referral page). |
portalAccess | boolean | no | Whether the partner may sign in to the hosted partner portal. false needs sendInvite: false (otherwise 400 invite_requires_portal_access). Omitted: true for an invited partner, and your account’s default for one created without an invite (see Portal access). |
metadata | object | no | Free-form. Not sent to the OpenPartner Network, except a partner’s own join-page answers (application: website, social links, audience size) once that partner opts in to the Network. |
programIds | string[] | no | Programs the partner can create share-links for. Omitted = all current programs. Empty array = none. A coupon is minted for each granted program. |
campaignIds | string[] | no | Deprecated alias for programIds; programIds wins when both are sent. |
campaignGrantSource | 'admin' | 'offering' | no | Audit hint. 'offering' is what Network federation sends. |
commissionSnapshot | object | no | Used by Network federation to grandfather a partner’s commission terms at approval. When the request names exactly one program, the snapshot applies to that program only; otherwise it’s the partner-wide default. See commissions. |
programCommissions | object[] | no | Per-program terms to set at creation: programId plus the same fields as PUT /partners/:id/programs/:programId/commission (see Commission terms). Each program must be one this request grants, at most one entry per program. Otherwise returns 400 program_commission_not_granted or duplicate_program_commission, before anything is created. |
recruitedByPartnerId | string | no | The partner who recruited this one, for two-tier commissions. In programs with a recruiter share, that partner earns it on this partner’s commissions from now on. Must be a partner in your account who isn’t revoked: otherwise 400 recruiter_not_found or recruiter_revoked. |
Response
201 Created with the partner, plus invited:
{ "id": "01HXX...", "tenantId": "01HXX...", "name": "Alice", "stripeConnectAccountId": null, "metadata": {}, "createdAt": "2026-04-24T12:00:00Z", "updatedAt": "2026-04-24T12:00:00Z", "activatedAt": null, "revokedAt": null, "revokeReason": null, "portalAccess": true, "recruitedByPartnerId": null, "recruitedAt": null, "recruitCode": null, "invited": true}recruitedByPartnerId and recruitedAt say who recruited the partner and when that
started; both are null when nobody did. recruitCode is the partner’s own code for
recruiting others, null until it’s first needed (see Recruiting).
activatedAt is null until the partner clicks their magic-link invite. With
sendInvite: false, it’s set to now() immediately.
The call fires partner.created. A partner created with
sendInvite: false is active at once, so partner.activated (with invited: false) follows
straight after. An invited partner fires partner.activated when they accept.
On the hosted plan, creating partners needs an active plan (402 plan_required) and an
approved account (403 brand_pending_review).
partners:create keys
partners:create is the scope for an in-app referral page’s runtime key. It can create
partners and nothing else, and only without an invite or commission terms:
sendInvitemust befalse. Leaving it out counts as asking for an invite, so a forgotten field fails instead of emailing your customer.commissionSnapshot,programCommissionsandrecruitedByPartnerIdaren’t allowed. A recruiter earns a share of this partner’s commissions, so naming one directs money, like commission terms do.
Either one answers 403 forbidden_scope with "required": "partners:write". The key can’t
invite, revoke, import or change partners either. Give a job that needs those its own
partners:write key.
GET /partners
List partners, newest first.
Auth: Admin API key OR scoped key with partners:read.
GET <base>/[email protected]&cursor=<...>&limit=100Authorization: Bearer <key>| Query | Notes |
|---|---|
email | Exact-match filter (case-insensitive). |
q | Case-insensitive substring match on name or email. |
limit | 1–200, default 100. |
cursor | Opaque cursor from a previous nextCursor. |
Response
{ "partners": [ { "id": "01HXX...", "name": "Alice", "stripeConnectAccountId": "acct_xxx", "createdAt": "2026-04-24T12:00:00Z", "activatedAt": "2026-04-24T12:05:00Z", "revokedAt": null, "portalAccess": true } ], "nextCursor": "eyJjcmVhdGVk..."}Each partner has the same fields as the POST /partners response, without invited.
nextCursor is null on the last page.
GET /partners/:id
Get a single partner.
Auth: Admin API key, scoped key with partners:read, OR the partner’s own key.
PATCH /partners/:id
Turn a partner’s portal access on or off, or set, change or clear who recruited them. It
takes portalAccess and recruitedByPartnerId; any other field answers 400 invalid_body.
Auth: Admin API key OR scoped key with partners:write.
PATCH <base>/partners/01HXX...Authorization: Bearer <key>Content-Type: application/json
{ "portalAccess": false }| Field | Type | Notes |
|---|---|---|
portalAccess | boolean | See Portal access for what each value does. |
recruitedByPartnerId | string | null | The partner’s recruiter, for two-tier commissions. null clears it. |
Returns the partner as saved.
A new recruiter takes effect at the change: recruitedAt is set to that moment, so the new
recruiter earns only on commissions from then on, and commissions already accrued stay with
whoever earned them. Sending the current recruiter again changes nothing. Refused with 400:
recruiter_is_partner: the partner themselves.recruiter_cycle: a partner this one recruited. Two partners can’t recruit each other.recruiter_revoked: a revoked partner.recruiter_not_found: no such partner in your account.
POST /partners/:id/invite
Send, or re-send, the magic-link invite email to a partner who hasn’t accepted yet. Each link expires in 15 minutes, and the partner can click any one. For a join-page applicant held for review, this is also the approval.
Auth: Admin API key OR scoped key with partners:write.
POST <base>/partners/01HXX.../inviteAuthorization: Bearer <key>Returns 409 already_activated for a partner who already accepted, and 409
portal_access_disabled for a partner without portal access (the invite is a sign-in link).
POST /partners/:id/revoke
Suspend a partner. Sessions and partner keys end immediately, no new attribution accrues,
historical commissions stay intact. Reversible via POST /partners/:id/reinstate.
Auth: Admin API key OR scoped key with partners:write.
POST <base>/partners/01HXX.../revokeAuthorization: Bearer <key>Content-Type: application/json
{ "reason": "Violated terms" }| Field | Notes |
|---|---|
reason | Optional. Surfaced in the revoke notification email, and visible if they try to sign in again. |
notify | Optional. Whether to email the partner a suspension notice. Defaults to true for a partner with portal access and false for one without. |
Portal access
Every account has a hosted partner portal. portalAccess decides whether a partner may sign
in to it. Running an in-app referral program, where your own app shows customers their link
and earnings? Keep them out of the portal.
A partner with portalAccess: false:
- gets nothing from the portal’s sign-in form, which answers as it does for an email it doesn’t know;
- is signed out at once, and any unused sign-in links stop working;
- can’t be invited (
POST /partners/:id/inviteanswers 409portal_access_disabled), and gets no email from OpenPartner unless you ask for one (notify: trueon a revoke); - keeps any partner API keys. Revoke those with
DELETE /api-keys/:keyId.
Turning access back on sends nothing. An active partner can then request a sign-in link from
the portal; one who never accepted an invite needs POST /partners/:id/invite.
The default for partners added without an invite
Invited partners always get portal access. For partners created with sendInvite: false and
no portalAccess, and for every CSV import row, your account’s default applies. It’s true
until you change it:
POST <base>/config/partner-accessAuthorization: Bearer <admin key>Content-Type: application/json
{ "uninvitedPortalAccess": false }GET /config/partner-access reads it. Both take an admin key only. The setting applies to
partners created from then on; change existing ones with PATCH /partners/:id. It’s also
under Admin → Settings → Partner portal access.
Referrals
GET /partners/:id/referrals lists each customer the partner referred, newest first,
cursor-paginated (limit, up to 200; pass nextCursor back as cursor). Each row has the
stages the customer reached, as the brand shows them, with firstAt, lastAt, count and
reversed. It also has latestStage, and commission / commissionByCurrency: live
commissions net of refund clawbacks.
- A partner sees only their own referrals, with a pseudonymous
referralIdand alabel(“Referral #12”, ormetadata.customerLabelif the brand shows those), never theuserId. Admin keys, and scoped keys withpartners:read, also getuserId. GET /partners/:id/customersandGET /partners/:id/eventsfollow the same rules: a partner calling getsuserId: nulland thereferralId, and sees only the event types the brand shows as stages.referredAtis when the customer first clicked the partner’s link. The list is newest referral first, andnumbercounts up in the order customers became referrals, so a new one always takes the next number. The cursor is encrypted.
GET /config/referrals and POST /config/referrals (admin) read and set the stages:
{ "stages": [ { "eventType": "lead_created", "label": "Lead" }, { "eventType": "demo_booked", "label": "Demo booked" }, { "eventType": "deal_won", "label": "Deal won" }, { "eventType": "invoice_paid", "label": "Paid" } ], "showCustomerLabels": false}stages replaces the whole list, in order (at most 20). Omitted fields keep their value.
A type listed twice answers 400 duplicate_stage. refund, chargeback,
dispute_created and invoice_payment_failed can’t be stages (400 corrective_stage).
Recruiting
Two-tier commissions: in a program with a recruiter share (recruiterCommissionPercent on
the program, set under Programs),
a partner’s recruiter earns that percent of the partner’s commissions there (see
Recruiter commissions for which ones). Two
levels only: a recruiter earns nothing on partners their recruits bring in.
A partner’s recruiter is set when they apply on your join page through the recruiter’s link
(/join?ref=<recruitCode>), or with recruitedByPartnerId on
POST /partners or PATCH /partners/:id.
GET /partners/:id/recruits
A partner’s recruit link, the programs that pay a recruiter share, who recruited them, and the partners they recruited.
Auth: Admin API key, scoped key with partners:read, OR the partner’s own key.
GET <base>/partners/01HXX.../recruitsAuthorization: Bearer <key>{ "partnerId": "01HXX...", "recruitCode": "k7m2q9xwhd", "recruitUrl": "https://partners.example.com/join?ref=k7m2q9xwhd", "signupOpen": true, "programs": [ { "id": "01HXX...", "name": "Pro plan", "recruiterCommissionPercent": 10, "recruiterCommissionMonths": 12 } ], "recruitedBy": null, "recruits": [ { "id": "01HXX...", "name": "Ben Ortiz", "recruitedAt": "2026-10-24T12:00:00.000Z", "status": "active", "earned": { "USD": "15.00" } } ], "total": 1, "limit": 200}recruitUrlis your join page with the partner’sref. The code (recruitCode) is minted the first time this is read while a program pays a recruiter share, and kept from then on.recruitUrlisnullwheneverprogramsis empty, and for a revoked partner, even if a code was minted earlier.signupOpenisfalsewhile your join page is closed: the link then opens a page that says so.programslists the programs paying a recruiter share. A partner calling sees only the running programs they’re granted; an admin orpartners:readkey sees all of them.recruiterCommissionMonthsisnullwhen the share has no time limit.recruitedByis{ id, name, recruitedAt }when someone recruited this partner, elsenull.recruitslists the newestlimitrecruits.statusisactive,pending(not active yet: invite not accepted, or held for review) orrevoked.earnedis what each recruit has earned this partner, per currency: their live recruiter commissions net of clawbacks.totalcounts all recruits.
The recruiter’s commissions themselves are in the commission lists, marked by
sourceCommissionId (see Commissions).
Commission terms
A partner can have a partner-wide default and terms for individual programs. A commission uses the partner’s terms for its program, else the partner-wide default, else the program’s own rule. Holdback comes from the same terms; blank means the program’s holdback. See the snapshot model.
Both scopes take the same body:
{ "commissionType": "percent", "commissionValue": 30, "recurring": true, "holdbackDays": 60, "commissionRules": [{ "trigger": "every", "type": "percent", "value": 30, "recurring": true }]}| Field | Type | Required | Notes |
|---|---|---|---|
commissionType | 'percent' | 'fixed' | yes | Single-rule summary. Ignored at accrual when commissionRules is set. |
commissionValue | number | yes | Percent as 30 for 30%, or a fixed amount. |
recurring | boolean | no | Single-rule summary. |
holdbackDays | integer | null | no | Days before the commission can be approved. null = use the program’s holdback. |
commissionRules | object[] | no | Compound rules, same shape as a program’s. When set, these are what’s paid. Send the full array on every PUT. |
A PUT replaces the terms for that scope in full and returns { "snapshot": { … } }. Rows
carry id, partnerId, programId (null for partner-wide), the fields above, source
(approval, backfill or amendment) and snapshottedAt.
GET /partners/:id/commission-snapshot
The partner-wide default, or { "snapshot": null } when there isn’t one.
Auth: Admin API key OR the partner themselves.
PUT /partners/:id/commission-snapshot
Set or replace the partner-wide default. Programs with their own terms keep them.
Auth: Admin API key.
DELETE /partners/:id/commission-snapshot
Remove the partner-wide default. Program terms are left alone.
Auth: Admin API key.
GET /partners/:id/programs/:programId/commission
The partner’s terms for one program, or { "snapshot": null }.
Auth: Admin API key OR the partner themselves.
PUT /partners/:id/programs/:programId/commission
Set or replace the partner’s terms for one program. The partner doesn’t need to be granted
the program yet. Returns 404 program_not_found for an unknown program.
Auth: Admin API key.
DELETE /partners/:id/programs/:programId/commission
Remove one program’s terms. That program then falls back to the partner-wide default, or the program’s own rule.
Auth: Admin API key.