Skip to content

Commissions

A Commission is a row in the immutable ledger that says “partner X earned amount Y on event Z.” Status transitions: accrued → approved → paid, or accrued / approved → reversed (refund / chargeback / fraud). A paid commission is never reversed; see Refunds, replacements and clawbacks.

GET /commissions

List commissions across all partners on the tenant, newest accrual first.

Auth: Admin API key OR scoped key with commissions:read.

GET <base>/commissions?status=accrued&limit=100
Authorization: Bearer <key>
QueryNotes
statusOne of accrued, approved, paid, reversed. Omitted = all.
limit1–500, default 100.
cursorOpaque cursor from a previous nextCursor.

Response

{
"commissions": [
{
"id": "01HXX...",
"tenantId": "01HXX...",
"attributionId": "01HXX...",
"sourceCommissionId": null,
"partnerId": "01HXX...",
"amount": "12.00",
"currency": "USD",
"status": "accrued",
"accruedAt": "2026-04-24T12:00:00Z",
"paidAt": null,
"payoutId": null,
"metadata": { "rateType": "percent" },
"holdbackDays": 30,
"programId": "01HXX...",
"campaignName": "Default 20% revshare",
"matureAt": "2026-05-24T12:00:00Z",
"recruiter": null
}
],
"nextCursor": "eyJhY2NydWVk..."
}

nextCursor is null on the last page. campaignName is the program’s name, kept under its old key.

matureAt is accruedAt + holdbackDays — when the commission is eligible for auto-approval. null if the program has no holdback configured, and always null on a recruiter commission, which is approved with the recruit’s commission instead.

holdbackDays is the effective holdback: the holdback of whichever partner terms apply to this commission (the partner’s terms for its program, else their partner-wide default), or the program’s holdbackDays when those terms leave it blank or there are none (see snapshot model). Manual approve refuses with 409 holdback_active until matureAt.

metadata records how the amount was worked out:

  • rateType: the matching rule’s percent or fixed (absent on commissions accrued before October 2026).
  • slab: set when a value band or performance tier set the rate. It names the sub-rule (ruleIndex), kind (band or tier), the level reached with its threshold and value, and for tiers the metric, window and metricValue, so you can show a partner which tier they’re in.
  • refund: set on a replacement commission left by a partial refund (below).
  • rateType: "recruiter" and recruiter: set on a recruiter commission. recruiter holds the percent it was worked out at and the recruitedPartnerId.

sourceCommissionId and recruiter are null except on a recruiter commission.

status: "paid" means the commission is in a payout. On the manual payout rail that happens when the payout is planned, before you’ve sent the money: check the payout’s own status.

GET /partners/:id/commissions

Same shape, scoped to a single partner.

Auth: Admin API key OR the partner themselves (via partner-scoped key) OR scoped key with commissions:read.

Refunds, replacements and clawbacks

Report a refund or chargeback with POST /attribution/refunds. On a self-hosted install whose Stripe webhook points at OpenPartner, Stripe refunds are applied the same way. Each of the conversion’s commissions then ends up worth its original amount times the share of the payment the customer kept. How that’s recorded depends on the commission’s status.

Accrued or approved. The commission is reversed. On a partial refund, a replacement commission for the kept amount is accrued on the same attribution, with the original’s status and accruedAt, so its holdback doesn’t restart. No commission’s amount is ever rewritten. The replacement’s metadata.refund links it back:

{
"refund": {
"rootCommissionId": "01HXX...",
"replacesCommissionId": "01HXX...",
"refundEventId": "01HXX..."
}
}

rootCommissionId is the commission the conversion first accrued, replacesCommissionId the one this replaces (the root, or an earlier replacement after several partial refunds), and refundEventId the refund behind it. Webhooks carry the same link as replacementCommissionId on commission.reversed and replacesCommissionId on commission.accrued (see Webhooks).

Paid. The commission stays paid: the partner has the money. OpenPartner records a negative clawback adjustment (refund_clawback) for the refunded share against it, and the refunds response reports the commission as clawback_recorded with its clawbackAmount.

OpenPartner takes the clawback off the partner’s next payout in that currency, if it’s a manual payout or a Stripe Connect payout on a self-hosted install. A partner who owes at least what they’re due isn’t paid that run; a later run pays them once enough accrues. The payout’s payout.created webhook lists what it deducted. Stripe Connect payouts on hosted OpenPartner don’t deduct clawbacks, so for those partners recover it yourself, if your terms allow.

Adjustments aren’t returned by the commission lists; they’re in the CommissionAdjustment table of your data export.

In a payout right now. Nothing changes yet (held_in_transfer). Retry the same refund request once the payout settles.

A holdback at least as long as your refund window means most refunds land before anything is approved, let alone paid.

POST /commissions/:id/approve

Approve an accrued commission. Fires commission.approved. Refused with 409 holdback_active (with matureAt) while the holdback is running, and 409 not_approvable for a commission that isn’t accrued. With a holdback above 0, the scheduled job approves matured commissions on its own.

A recruiter’s accrued share of the commission is approved with it, in the same transaction, and returned as recruiterCommissions (omitted when there’s none):

{ "commission": { "id": "01HXX...", "status": "approved", ... }, "recruiterCommissions": [{ ... }] }

A recruiter commission can’t be approved before the commission it’s a share of: until that one is approved or paid, approving it answers 409 source_not_approved with its sourceCommissionId.

Auth: Admin API key.

POST /commissions/:id/reverse

Reverse an accrued or approved commission, for example after fraud review. Fires commission.reversed with reason: "manual". A paid commission can’t be reversed (409 not_reversible), and one whose payout is in flight answers 409 commission_in_transfer.

A recruiter’s share of the commission goes with it, reported in recruiterCommissions (omitted when there’s none):

{
"commission": { "id": "01HXX...", "status": "reversed", ... },
"recruiterCommissions": {
"reversed": [{ ... }],
"clawbacks": [{ "commissionId": "01HXX...", "partnerId": "01HXX...", "amount": "-5.00", "currency": "USD" }],
"heldInTransfer": []
}
}
  • reversed: an accrued or approved share, now reversed. Each fires its own commission.reversed.
  • clawbacks: a paid share stays paid and gets a refund_clawback adjustment for what the recruiter still holds, with no webhook. It’s recovered like any refund clawback.
  • heldInTransfer: ids of shares a payout is moving right now, left as they are. Once that payout settles, call this endpoint again on the same (already reversed) commission. It answers 200 with what it did to the share, and 409 not_reversible once there’s nothing left to do.

A recruiter commission can also be reversed on its own. That leaves the recruit’s commission as it is.

Auth: Admin API key.

Recruiter commissions

In a program that pays partners for the partners they recruit, each commission a recruit earns on an attributed conversion accrues a second commission for their recruiter: recruiterCommissionPercent of the recruit’s commission, rounded to the cent. None is written when the recruiter is revoked, the event is from before recruitedAt or past recruiterCommissionMonths, or the share rounds to nothing. Two levels only. A recruiter commission never pays a share of its own, and neither do bonuses.

A recruiter commission is an ordinary row in both commission lists:

  • partnerId is the recruiter.
  • attributionId is the recruit’s attribution: it was earned on the recruit’s sale. So a commission’s partner isn’t always its attribution’s partner.
  • sourceCommissionId is the recruit’s commission it’s a share of.
  • metadata is { "rateType": "recruiter", "recruiter": { "percent": 10, "recruitedPartnerId": "01HXX..." } }.
  • The list’s recruiter field is { sourceCommissionId, recruitedPartnerId, recruitedPartnerName, percent }. matureAt is null.

It follows the recruit’s commission through the ledger:

The recruit’s commission is…The recruiter’s share is…
Approved, by an admin or the holdback sweepApproved in the same transaction, whatever the recruiter’s own holdback.
Partially refundedCut by the same share, as any commission on the conversion is: reversed and replaced if unpaid (the replacement keeps sourceCommissionId), a refund_clawback if paid, held_in_transfer if a payout is moving it.
Fully refundedReversed, or a refund_clawback if already paid (held_in_transfer likewise).
Reversed by an adminReversed, or a refund_clawback if already paid (see POST /commissions/:id/reverse).

Its webhooks carry sourceCommissionId and recruitedPartnerId (see Webhooks). The recruits themselves are listed by GET /partners/:id/recruits.

Commission snapshot model

When a creator’s PartnershipRequest is approved (Network flow) OR a partner is created directly (admin invite with commissionSnapshot), the offering’s commission terms are stamped onto a PartnerCommission row alongside the Partner. Future commissions compute from the snapshot, not from the live program rule.

A PartnerCommission row is scoped by programId:

  • programId set: the partner’s terms for that program. A Network approval names the offering’s one program, so its snapshot is stored this way.
  • programId null: the partner’s partner-wide default, for every program without terms of its own.

This means:

  • A brand can edit a program’s commission rate or holdback after partners have signed up. Existing partners keep the rate they were approved under; only new partners get the new rate.

  • Each Commission row computes from the program the conversion came through:

    terms = PartnerCommission for (partner, this program)
    else PartnerCommission for (partner, programId null)
    rules = terms ? terms.commissionRules (or its single-rule columns) : Program.commissionRule
    holdback = coalesce(terms.holdbackDays, Program.holdbackDays)

    The winning row is used whole. Program terms never mix with the partner-wide default, and their blank holdback means the program’s, not the partner-wide one.

  • GET /partners/:id/commission-snapshot returns the partner-wide row, and GET /partners/:id/programs/:programId/commission returns a program’s (see partners).

  • Admin overrides via the Commission link on each partner row in Admin → Partners write a PartnerCommission row with source = 'amendment', either partner-wide or for one program.

Past commissions are never recomputed when the snapshot or program rule changes — only events accruing after the change use the new value.