Browse Product guides

Affiliate sales API

Use the sales API when a paid sale happens outside Plandalf checkout and outside the native Stripe source.

Report a sale

curl --fail-with-body -X POST "https://acme.plandalf.dev/api/v1/affiliate/sales" \
  -H "Authorization: Bearer $PLANDALF_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "samcart",
    "external_id": "order_10492",
    "occurred_at": "2026-09-25T09:30:00Z",
    "currency": "USD",
    "amount_cents": 10890,
    "tax_cents": 990,
    "discount_cents": 5000,
    "customer": { "email": "sam@example.com", "external_id": "cus_881" },
    "referral_token": "rt_01J9Z4K7...",
    "subscription_id": "sub_123",
    "billing_reason": "initial",
    "test": false
  }'

Limits: source is normalized to lowercase, may use letters, numbers, dots, underscores, colons and hyphens, and is at most 32 characters. external_id and customer external IDs are at most 191 characters, currency is exactly three letters, and referral_token or code is at most 64 characters.

Accepted billing_reason values are initial, trial_start, trial_conversion, renewal, one_off, upgrade, downgrade and proration.

occurred_at is optional. If you send it, it must be within the last 365 days and no more than the small clock-skew window into the future. Outside that window the API returns 422.

The commission basis is amount_cents - tax_cents. discount_cents is stored and traced; do not subtract it again if amount_cents is already the amount paid.

The response is 201 for a new sale and 200 for a replay:

{
  "id": "sale_01J9Z5B2...",
  "attribution": { "method": "token", "partner": { "code": "jane" } },
  "commission": {
    "status": "pending",
    "skip_reason": null,
    "amount_cents": 2970,
    "approves_at": "2026-10-25T09:30:00Z"
  },
  "refunded_cents": 0
}

The same source, external_id and test mode is idempotent. Replays return the existing sale even if the retry payload differs.

Sale fields:

FieldRequiredNotes
sourceyesNormalized lowercase. Same source must be used for later adjustments.
external_idyesYour sale/order/invoice ID. Idempotent with source and test.
currencyyesThree-letter currency code.
amount_centsyesInteger, zero or higher.
tax_centsnoInteger, zero or higher.
discount_centsnoInteger, zero or higher.
occurred_atnoISO timestamp in the accepted import window.
referral_tokennoToken from pf_rt or sdk.referral().
codenoPartner code when no token is available.
customer.emailnoUsed for trace and customer binding.
customer.external_idnoUsed for customer binding.
customer.payment_fingerprintnoUsed for customer binding and self-referral checks.
subscription_idnoYour subscription identifier for renewal/duration rules.
billing_reasonnoOne of the accepted billing reasons above.
testnoBoolean. Test and live sales do not mix.

Report a refund

curl --fail-with-body -X POST "https://acme.plandalf.dev/api/v1/affiliate/refunds" \
  -H "Authorization: Bearer $PLANDALF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "samcart",
    "sale_external_id": "order_10492",
    "external_id": "refund_311",
    "amount_cents": 5445,
    "occurred_at": "2026-09-28T12:00:00Z",
    "test": false
  }'

Each refund ID is processed once. Refunds are keyed by their own external_id, not the sale ID, so you can send multiple partial refunds for the same sale. Re-sending the same refund ID and payload is a no-op; reusing the same refund ID with a different amount returns 409.

If the matching sale has not arrived yet, the endpoint stores the refund as a pending source event and returns:

{ "status": "pending" }

Status is 202 for that pending adjustment. It is applied automatically when the sale arrives or when pending source events are swept. Include test: true for refunds against test sales.

Refund fields:

FieldRequiredNotes
sourceyesNormalized lowercase.
sale_external_idyesThe sale’s external_id.
external_idyesThe refund ID.
amount_centsyesInteger, at least 1.
occurred_atnoSame 365-day/future-skew window.
testnoMust match the sale mode.

Report a credit

Use credits for credit notes and non-refund adjustments that should reduce commission.

curl --fail-with-body -X POST "https://acme.plandalf.dev/api/v1/affiliate/credits" \
  -H "Authorization: Bearer $PLANDALF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "samcart",
    "sale_external_id": "order_10492",
    "external_id": "credit_91",
    "amount_cents": 3000,
    "tax_cents": 300,
    "commission_basis_cents": 2700
  }'

Credit fields are the refund fields plus optional tax_cents and commission_basis_cents. If you omit commission_basis_cents, Plandalf uses amount_cents - tax_cents. Credits sent before the sale return 202 and stay pending.

Report a dispute

Open a dispute:

curl --fail-with-body -X POST "https://acme.plandalf.dev/api/v1/affiliate/disputes" \
  -H "Authorization: Bearer $PLANDALF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "samcart",
    "sale_external_id": "order_10492",
    "external_id": "dp_21",
    "charge_id": "ch_123",
    "amount_cents": 10890,
    "currency": "USD",
    "status": "needs_response",
    "funds_withdrawn": false
  }'

Resolve it:

curl --fail-with-body -X POST "https://acme.plandalf.dev/api/v1/affiliate/disputes/dp_21/won" \
  -H "Authorization: Bearer $PLANDALF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "source": "samcart", "sale_external_id": "order_10492", "amount_cents": 10890 }'

Resolution endpoints are /won, /lost, /warning_closed and /warning-closed.

Dispute fields:

FieldRequiredNotes
sourceyesNormalized lowercase.
sale_external_idyesThe sale’s external_id.
external_idyesRequired in the create endpoint; route parameter supplies it for resolution endpoints.
charge_idnoProvider charge ID for trace/matching.
amount_centsyesInteger, at least 1.
currencynoThree-letter currency code.
statusnoneeds_response, under_review, warning_needs_response, warning_under_review, warning_closed, won or lost.
funds_withdrawnnoBoolean. Treats the dispute as money withdrawn.
occurred_atnoSame 365-day/future-skew window.
testnoMust match the sale mode.

Open disputes hold unpaid commission. Lost disputes reverse. Won and warning-closed disputes release the hold or restore a previous withdrawal.

Status codes

CodeMeaning
201Sale created.
200Sale replayed or adjustment applied.
202Adjustment accepted before the matching sale exists.
409Same source event identity was already recorded with a different payload.
422Request validation failed, including out-of-window occurred_at.

Planned lead endpoint

/api/v1/affiliate/leads and plandalf.lead() are planned. Do not use email-only signup matching as a live integration path.

Feature detail