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:
| Field | Required | Notes |
|---|---|---|
source | yes | Normalized lowercase. Same source must be used for later adjustments. |
external_id | yes | Your sale/order/invoice ID. Idempotent with source and test. |
currency | yes | Three-letter currency code. |
amount_cents | yes | Integer, zero or higher. |
tax_cents | no | Integer, zero or higher. |
discount_cents | no | Integer, zero or higher. |
occurred_at | no | ISO timestamp in the accepted import window. |
referral_token | no | Token from pf_rt or sdk.referral(). |
code | no | Partner code when no token is available. |
customer.email | no | Used for trace and customer binding. |
customer.external_id | no | Used for customer binding. |
customer.payment_fingerprint | no | Used for customer binding and self-referral checks. |
subscription_id | no | Your subscription identifier for renewal/duration rules. |
billing_reason | no | One of the accepted billing reasons above. |
test | no | Boolean. 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:
| Field | Required | Notes |
|---|---|---|
source | yes | Normalized lowercase. |
sale_external_id | yes | The sale’s external_id. |
external_id | yes | The refund ID. |
amount_cents | yes | Integer, at least 1. |
occurred_at | no | Same 365-day/future-skew window. |
test | no | Must 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:
| Field | Required | Notes |
|---|---|---|
source | yes | Normalized lowercase. |
sale_external_id | yes | The sale’s external_id. |
external_id | yes | Required in the create endpoint; route parameter supplies it for resolution endpoints. |
charge_id | no | Provider charge ID for trace/matching. |
amount_cents | yes | Integer, at least 1. |
currency | no | Three-letter currency code. |
status | no | needs_response, under_review, warning_needs_response, warning_under_review, warning_closed, won or lost. |
funds_withdrawn | no | Boolean. Treats the dispute as money withdrawn. |
occurred_at | no | Same 365-day/future-skew window. |
test | no | Must 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
| Code | Meaning |
|---|---|
201 | Sale created. |
200 | Sale replayed or adjustment applied. |
202 | Adjustment accepted before the matching sale exists. |
409 | Same source event identity was already recorded with a different payload. |
422 | Request 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.