Skip to content

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>/partners
Authorization: Bearer <key>
Content-Type: application/json
{
"email": "[email protected]",
"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 }]
}
]
}
FieldTypeRequiredNotes
emailstringyesLowercased on storage. Returns 409 email_taken if a partner already has it; look them up with GET /partners?email=.
namestringyesDisplay name.
sendInvitebooleannoDefault 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).
portalAccessbooleannoWhether 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).
metadataobjectnoFree-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.
programIdsstring[]noPrograms the partner can create share-links for. Omitted = all current programs. Empty array = none. A coupon is minted for each granted program.
campaignIdsstring[]noDeprecated alias for programIds; programIds wins when both are sent.
campaignGrantSource'admin' | 'offering'noAudit hint. 'offering' is what Network federation sends.
commissionSnapshotobjectnoUsed 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.
programCommissionsobject[]noPer-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.
recruitedByPartnerIdstringnoThe 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...",
"email": "[email protected]",
"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:

  • sendInvite must be false. Leaving it out counts as asking for an invite, so a forgotten field fails instead of emailing your customer.
  • commissionSnapshot, programCommissions and recruitedByPartnerId aren’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=100
Authorization: Bearer <key>
QueryNotes
emailExact-match filter (case-insensitive).
qCase-insensitive substring match on name or email.
limit1–200, default 100.
cursorOpaque cursor from a previous nextCursor.

Response

{
"partners": [
{
"id": "01HXX...",
"email": "[email protected]",
"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 }
FieldTypeNotes
portalAccessbooleanSee Portal access for what each value does.
recruitedByPartnerIdstring | nullThe 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.../invite
Authorization: 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.../revoke
Authorization: Bearer <key>
Content-Type: application/json
{ "reason": "Violated terms" }
FieldNotes
reasonOptional. Surfaced in the revoke notification email, and visible if they try to sign in again.
notifyOptional. 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/invite answers 409 portal_access_disabled), and gets no email from OpenPartner unless you ask for one (notify: true on 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-access
Authorization: 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 referralId and a label (“Referral #12”, or metadata.customerLabel if the brand shows those), never the userId. Admin keys, and scoped keys with partners:read, also get userId.
  • GET /partners/:id/customers and GET /partners/:id/events follow the same rules: a partner calling gets userId: null and the referralId, and sees only the event types the brand shows as stages.
  • referredAt is when the customer first clicked the partner’s link. The list is newest referral first, and number counts 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.../recruits
Authorization: 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
}
  • recruitUrl is your join page with the partner’s ref. The code (recruitCode) is minted the first time this is read while a program pays a recruiter share, and kept from then on. recruitUrl is null whenever programs is empty, and for a revoked partner, even if a code was minted earlier.
  • signupOpen is false while your join page is closed: the link then opens a page that says so.
  • programs lists the programs paying a recruiter share. A partner calling sees only the running programs they’re granted; an admin or partners:read key sees all of them. recruiterCommissionMonths is null when the share has no time limit.
  • recruitedBy is { id, name, recruitedAt } when someone recruited this partner, else null.
  • recruits lists the newest limit recruits. status is active, pending (not active yet: invite not accepted, or held for review) or revoked. earned is what each recruit has earned this partner, per currency: their live recruiter commissions net of clawbacks. total counts 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 }]
}
FieldTypeRequiredNotes
commissionType'percent' | 'fixed'yesSingle-rule summary. Ignored at accrual when commissionRules is set.
commissionValuenumberyesPercent as 30 for 30%, or a fixed amount.
recurringbooleannoSingle-rule summary.
holdbackDaysinteger | nullnoDays before the commission can be approved. null = use the program’s holdback.
commissionRulesobject[]noCompound 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.