Sandbox environment — test data only, nothing here is real.

Building an integration with an LLM or coding agent? Point it at the raw markdown source: /api.md.

API

A RESTful API for managing your affiliate program: associates, referral links, clicks, conversions, and the payouts (chargebacks, withdrawals) that follow from them.

Organisations and Programs

An Organisation is your login — the email you use to sign in to the dashboard, via a passwordless emailed sign-in link.

A Program is a single affiliate program's configuration: its destination URL, commission rules, currency, cookie/payout windows, and its own API key.

One Organisation can run multiple Programs (e.g. one per product or site), and each Program's API key only ever sees that Program's own associates, links, clicks, and conversions — never another Program's, even under the same Organisation.

Signing up creates one Organisation and its first Program together.

Add more Programs from the dashboard at any time, each with its own independent API credentials.

Authentication

Endpoints under /api/* are authenticated with HTTP Basic Auth, using a program's API key as the username and API secret as the password:

Authorization: Basic base64(API_KEY:API_SECRET)

With curl, pass -u API_KEY:API_SECRET and it will encode this for you. A program's API key and secret are shown once when it's created (at signup, or when adding a new program from the dashboard) — or regenerated from that program's Credentials page.

Requests without valid credentials return 401:

{ "error": "Invalid or missing credentials" }

Sandbox environment

https://sandbox.affiliately.io is a separate deployment of the exact same application — identical behavior to production, but backed by its own database. Point an integration at it while you're building or testing, without touching real associates, links, clicks, or conversions.

  • Every endpoint in this document works the same way on both hosts — swap affiliately.io for sandbox.affiliately.io in the URL and nothing else changes.
  • Sandbox credentials are separate from production. A production API key/secret will not work against the sandbox host, and vice versa.
  • Sandbox is the right place to exercise flows that create real tracking data as a side effect — e.g. repeatedly hitting GET /r/:code — since none of it is real.

Manage a program

Requires auth, using the program's own API key. Lets a program manage its own configuration — not the organisation's login, which has no API-key-driven equivalent and is managed from the dashboard only.

An API key/secret only ever identifies one program, so you don't need to already know your program's idGET /api/programs/me looks it up for you:

curl -u API_KEY:API_SECRET https://affiliately.io/api/programs/me

Once you have the id from that (or from the dashboard), the same record is also reachable at /api/programs/:id — useful if you've cached it and don't want the extra lookup — and that's the form PATCH/DELETE use:

curl -u API_KEY:API_SECRET https://affiliately.io/api/programs/6

curl -u API_KEY:API_SECRET \
  -X PATCH https://affiliately.io/api/programs/6 \
  -H "Content-Type: application/json" \
  -d '{ "commissionRules": { "billing": "one-off", "calculation": "flat", "amount": 120 } }'

curl -u API_KEY:API_SECRET -X DELETE https://affiliately.io/api/programs/6

PATCH accepts any of name, destinationUrl, commissionRules, currency, cookieDurationDays, payoutHoldDays, reservePct, reserveHoldDays, attributionModel — and only the fields you send are changed, so a PATCH with just { "name": "..." } is fine and won't touch anything else. DELETE is a soft delete, like everything else in this API, and only ever affects the program identified in the URL — never the organisation or its other programs.

reservePct (0–100, default 0) and reserveHoldDays (default 0) configure an optional second payout tranche on top of payoutHoldDays — see Cleared, reserved, and withdrawn for how the two combine.

attributionModel is first-touch or last-touch (default). It decides which associate gets credit when a customer clicks more than one referral link before completing an order:

  • last-touch — the most recent click wins.
  • first-touch — the original click wins, even if the customer clicked other links afterwards.

This is a declared intent, not something Affiliately enforces. Since /r/:code doesn't set a cookie (see Test a referral link), Affiliately never sees which afly value the customer's browser is actually carrying — only whichever one your backend sends to POST /api/conversions. Implementing attributionModel means writing your own cookie-setting logic to match: for last-touch, always overwrite your cookie with the newest afly; for first-touch, only set it if one isn't already there. This field exists so that's a documented, discoverable setting instead of tribal knowledge scattered across your codebase.

Commission rules

commissionRules is a single JSON object describing how much an associate earns, and when. Every conversion (and every renewal — see Subscriptions) reads this to work out its payout, so it's worth understanding the shape up front. It's a discriminated object — which fields are required or forbidden depends on billing and calculation:

Field Type Required Notes
billing string yes one-off or subscription
recurrence string only if billing is subscription once (commission on the first payment only) or every-cycle (commission on renewals too). Forbidden when billing is one-off
cycleLimit integer no, and only when recurrence is every-cycle Caps commission to the first N renewals; omit or send null for uncapped. Forbidden otherwise
calculation string yes flat or percentage
amount number only if calculation is flat The flat commission amount, in the program's currency. Forbidden when calculation is percentage
pct number only if calculation is percentage 0–100. Applied to orderValue (see Conversions). Forbidden when calculation is flat

Three example shapes:

Flat one-off — a fixed fee per sale:

{ "billing": "one-off", "calculation": "flat", "amount": 25 }

Percentage one-off — 10% of the order value, one-time:

{ "billing": "one-off", "calculation": "percentage", "pct": 10 }

Subscription — paid every renewal, capped at 12 cycles:

{ "billing": "subscription", "recurrence": "every-cycle", "cycleLimit": 12, "calculation": "flat", "amount": 15 }

Sending a field that doesn't apply to your combination (e.g. pct alongside calculation: "flat", or recurrence alongside billing: "one-off") is rejected with 400.

Create an associate

Registers a new associate (affiliate) under your program.

curl -u API_KEY:API_SECRET \
  -X POST https://affiliately.io/api/associates \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Jane Doe",
    "email": "jane@example.com",
    "metadata": { "externalId": "usr_9f2a", "tier": "gold" }
  }'
Field Type Required Notes
name string yes
email string yes
status string no pending (default) or approved — see below
metadata object no Arbitrary JSON for your own use — stored and returned as-is, never read or acted on by the API

Security note: metadata is stored and returned in plaintext — there's no encryption at rest and no masking in responses. If you're storing sensitive values here (bank details, government IDs, etc.), encrypt them yourself before sending and decrypt them on your side after fetching. Affiliately never reads, validates, or acts on metadata contents, so anything you don't protect yourself is kept exactly as sent.

An associate's status is one of:

  • pending — awaiting your review. The default if omitted, so associates are held for approval unless you opt in to approved.
  • approved — pre-approved and live immediately.
  • rejected — reviewed and declined; never went live.
  • suspended — was live, now pulled from the program (e.g. for policy violations, or on their own request).

rejected and suspended cannot be set on creation — only reachable via update. Every status is reversible via update (e.g. rejectedapproved on reconsideration, suspendedapproved to reinstate).

Response — 201 Created:

{
  "id": 16,
  "programId": 23,
  "name": "Jane Doe",
  "email": "jane@example.com",
  "status": "pending",
  "metadata": { "externalId": "usr_9f2a", "tier": "gold" },
  "createdAt": "2026-07-07T20:07:16.754Z",
  "updatedAt": "2026-07-07T20:07:16.754Z"
}

List / get associates — requires auth, list filters by email:

curl -u API_KEY:API_SECRET "https://affiliately.io/api/associates?email=jane@example.com"

Useful for looking up a specific associate by email without fetching every associate and filtering client-side.

Get an associate's available balance — requires auth. A read-only preview of what a withdrawal would pay out right now, without creating one:

curl -u API_KEY:API_SECRET https://affiliately.io/api/associates/17/balance
{ "associateId": 17, "currency": "GBP", "balance": 159 }

balance is computed live, the same way POST /api/withdrawals computes it — cleared amounts across all of this associate's conversions, minus chargebacks, minus anything already allocated to another withdrawal (including one that's still pending, since that balance is spoken for until it's settled or cancelled). Calling this endpoint has no side effects and can be polled freely. 404 if id doesn't belong to your program.

Update an associate

Requires auth. Approve or reject a pending associate, suspend one, or change any other field:

curl -u API_KEY:API_SECRET \
  -X PATCH https://affiliately.io/api/associates/16 \
  -H "Content-Type: application/json" \
  -d '{ "status": "approved" }'

Accepts any of name, email, status (pending, approved, rejected, or suspended), metadata.

Delete an associate — requires auth, soft delete:

curl -u API_KEY:API_SECRET -X DELETE https://affiliately.io/api/associates/16

Response — 204 No Content. This doesn't touch the associate's existing links, clicks, or conversions — it only removes the associate record itself from listings.

Create a link

Generates a trackable referral link for one of your associates. code is what shows up in the actual URL a customer clicks (https://affiliately.io/r/CODE) — omit it to get a random one, or supply your own vanity code.

curl -u API_KEY:API_SECRET \
  -X POST https://affiliately.io/api/links \
  -H "Content-Type: application/json" \
  -d '{
    "associateId": 17,
    "code": "SUMMER10",
    "discountPct": 20
  }'
Field Type Required Notes
associateId integer yes Must belong to your program
code string no Alphanumeric, dashes, and underscores, 3–32 chars. Auto-generated if omitted. Must be unique
discountPct integer no 0–100, default 0. Share of the flat commission passed on as a customer discount instead of associate payout
destinationUrl string no Overrides the program's default destination URL for this link

Response — 201 Created:

{
  "id": 16,
  "associateId": 17,
  "programId": 24,
  "code": "SUMMER10",
  "discountPct": 20,
  "createdAt": "2026-07-07T20:10:41.800Z",
  "updatedAt": "2026-07-07T20:10:41.800Z"
}

Error responses:

Status Cause
400 associateId doesn't belong to your program (or doesn't exist)
409 The code you supplied is already in use (auto-generated codes retry until unique, so this only happens with an explicit code)
{ "error": "associateId does not reference an existing associate" }

List / get links — requires auth, list filters by associateId:

curl -u API_KEY:API_SECRET "https://affiliately.io/api/links?associateId=17"
curl -u API_KEY:API_SECRET https://affiliately.io/api/links/16

Update a link — requires auth:

curl -u API_KEY:API_SECRET \
  -X PATCH https://affiliately.io/api/links/16 \
  -H "Content-Type: application/json" \
  -d '{ "discountPct": 15 }'

Accepts discountPct and/or destinationUrlcode and associateId are fixed at creation and can't be changed. Send destinationUrl: null to clear a link-level override and fall back to the program's default again.

Delete a link — requires auth, soft delete:

curl -u API_KEY:API_SECRET -X DELETE https://affiliately.io/api/links/16

Response — 204 No Content. The link's code stops resolving via GET /r/:code, but existing clicks and conversions already recorded against it are unaffected.

Test a referral link

GET /r/:code is the public, unauthenticated endpoint a customer's browser actually hits when they click a referral link. It logs a click and redirects to the destination URL, appending the click's token as an afly query param. You can inspect this with curl.

Affiliately doesn't set a cookie of its own — the afly query param on the redirect is the only signal it hands you. It's on you to persist that value as the customer moves from the landing page to checkout (e.g. your own first-party cookie, session, or localStorage) so it's still available when you call checkout or report a conversion.

By default curl does not follow redirects, so -i (include headers) is enough to see the raw 302 response — the Location it would have sent a browser to:

curl -i https://affiliately.io/r/SUMMER10
HTTP/1.1 302 Found
Location: https://your-destination.example.com/?afly=7403952b-4b3e-40e5-aac0-11084b5cfcf1
...

To capture just the redirect target (e.g. for scripting a test), use -w:

curl -o /dev/null -w "status: %{http_code}\nredirected to: %{redirect_url}\n" \
  https://affiliately.io/r/SUMMER10

Two things worth knowing:

  • -L follows the redirect instead of just showing it — useful to confirm the destination page actually loads, but combine it with -i if you still want to see the 302 and its headers along the way.
  • This isn't idempotent. Every request logs a new Click and mints a fresh afly, exactly like a real visitor clicking the link — so repeated test runs create real tracking data, not just a preview.

Apply the discount at checkout

Once afly is on the cart/checkout page (carried over from the redirect's ?afly= query param — it's on you to persist it that far, see above), get a quote for the discount to apply before the order is placed. This is a read, not a report — it doesn't touch Click/Conversion data, so you can call it as many times as the cart re-renders.

curl -u API_KEY:API_SECRET "https://affiliately.io/api/checkout?aflyToken=7403952b-4b3e-40e5-aac0-11084b5cfcf1"
Query param Type Required Notes
aflyToken string yes The afly value from the referral redirect
orderValue number only if your program's calculation is percentage The order's total value, before any discount — the base the percentage is applied to
curl -u API_KEY:API_SECRET "https://affiliately.io/api/checkout?aflyToken=7403952b-4b3e-40e5-aac0-11084b5cfcf1&orderValue=200"

Response — 200 OK. The commission is first resolved from your program's commissionRules — either the flat amount, or pct% of orderValue — then split by the link's discountPct. This is the same two-step calculation POST /api/conversions repeats once the order is placed (see Conversions). You apply customerDiscount to the cart; associatePayout is informational, for display if you want it; currency is your program's currency (e.g. GBP/USD/EUR) — commissionAmount/customerDiscount/associatePayout mean nothing without it:

{
  "linkId": 16,
  "associateId": 17,
  "code": "SUMMER10",
  "discountPct": 20,
  "commissionAmount": 100,
  "currency": "GBP",
  "customerDiscount": 20,
  "associatePayout": 80
}

404 if aflyToken doesn't match a click for your program, or if the click's attribution window (cookieDurationDays) has already expired — treat either case as "no discount applies":

{ "error": "No click found for that aflyToken" }

400 if your program's calculation is percentage and orderValue was omitted:

{ "error": "orderValue is required to quote a percentage-based commission" }

Clicks

GET /r/:code (above) is how a real visitor's browser generates a click. The /api/clicks endpoints are for managing that data directly — POST is a raw, unauthenticated way to log a click without an actual browser redirect (e.g. from a mobile app or server-side integration); GET/DELETE are authenticated and scoped to your own program's links.

Create a click — public, no auth required:

curl -X POST https://affiliately.io/api/clicks \
  -H "Content-Type: application/json" \
  -d '{ "linkId": 17 }'
Field Type Required Notes
linkId integer yes
aflyToken string no UUID. Auto-generated if omitted
ipAddress string no Defaults to the requester's IP
userAgent string no Defaults to the request's User-Agent header

Response — 201 Created:

{
  "id": 42,
  "linkId": 17,
  "aflyToken": "600aaeb9-6115-4fbc-93d2-3ec216ec4a53",
  "ipAddress": "203.0.113.10",
  "userAgent": "curl/8.7.1",
  "createdAt": "2026-07-07T20:19:50.935Z"
}

List clicks — requires auth, filter by linkId and/or aflyToken:

curl -u API_KEY:API_SECRET "https://affiliately.io/api/clicks?linkId=17"
[
  {
    "id": 42,
    "linkId": 17,
    "aflyToken": "600aaeb9-6115-4fbc-93d2-3ec216ec4a53",
    "ipAddress": "203.0.113.10",
    "userAgent": "curl/8.7.1",
    "createdAt": "2026-07-07T20:19:50.000Z",
    "deletedAt": null
  }
]

Get one click — requires auth:

curl -u API_KEY:API_SECRET https://affiliately.io/api/clicks/42

Delete a click — requires auth. Clicks use soft deletes, so this hides the row from the API rather than erasing it:

curl -u API_KEY:API_SECRET -X DELETE https://affiliately.io/api/clicks/42

Response — 204 No Content.

Conversions

Reported by your own backend when an order completes, using the afly value your frontend picked up from the redirect and persisted through to checkout (see Test a referral link). The API looks up the matching click, attributes it to the right associate/link, and computes the payout split — you never send amounts yourself.

Report a conversion — requires auth:

curl -u API_KEY:API_SECRET \
  -X POST https://affiliately.io/api/conversions \
  -H "Content-Type: application/json" \
  -d '{
    "aflyToken": "9d2a19ed-54d7-486d-ad19-7561eaff4e02",
    "orderReference": "ORDER-1042"
  }'
Field Type Required Notes
aflyToken string yes The afly value from the referral redirect
orderReference string yes Your own order/invoice id. Must be unique per program
orderValue number only if your program's calculation is percentage The order's total value, before any discount — the base the percentage is applied to

Response — 201 Created. commissionAmount is resolved from your program's commissionRules — either the flat amount, or pct% of orderValue — then customerDiscount and associatePayout are split from that according to the link's discountPct:

{
  "id": 38,
  "programId": 26,
  "linkId": 18,
  "aflyToken": "9d2a19ed-54d7-486d-ad19-7561eaff4e02",
  "orderReference": "ORDER-1042",
  "commissionAmount": 100,
  "currency": "GBP",
  "associatePayout": 80,
  "reservedAmount": 16,
  "customerDiscount": 20,
  "status": "pending",
  "clearedAt": null,
  "reservedClearedAt": null,
  "createdAt": "2026-07-07T20:24:10.745Z",
  "updatedAt": "2026-07-07T20:24:10.745Z"
}

currency is captured from your program at the moment the conversion is reported and stays fixed from then on — same as commissionAmount — so a later change to your program's currency never relabels a historical payout. reservedAmount is captured the same way, from your program's reservePct at that moment — see Cleared, reserved, and withdrawn for what it means and how clearedAt/reservedClearedAt get set.

If your program's commissionRules.billing is subscription, this call is also the signup moment: it creates a Subscription behind the scenes and stamps the conversion with subscriptionId and cycleNumber: 1. See Subscriptions for how renewal payments are reported from here on.

Error responses:

Status Cause
400 No click found for that aflyToken
400 Your program's calculation is percentage and orderValue was omitted
409 orderReference has already been reported for this program
410 The click's attribution window has expired (past the program's cookieDurationDays)

List / get conversions — requires auth, list filters by linkId, status, and/or orderReference:

curl -u API_KEY:API_SECRET "https://affiliately.io/api/conversions?status=pending"
curl -u API_KEY:API_SECRET "https://affiliately.io/api/conversions?orderReference=ORDER-1042"
curl -u API_KEY:API_SECRET https://affiliately.io/api/conversions/38

orderReference is how you go from your own order id to the conversion's internal id — useful before a PATCH (e.g. canceling one during the hold period), since that endpoint only accepts the id.

Pagination — opt-in. Without page or limit, you get every matching conversion as a plain JSON array, exactly as shown above. Pass either one and the response shape changes to an object with pagination metadata instead:

curl -u API_KEY:API_SECRET "https://affiliately.io/api/conversions?page=2&limit=25"
{
  "conversions": [ { "id": 38, "orderReference": "ORDER-1042", "...": "..." } ],
  "page": 2,
  "totalPages": 5,
  "total": 112
}
Query param Type Required Notes
page integer no Default 1. Out-of-range values fall back to 1
limit integer no Default 50, capped at 200. Invalid values fall back to 50

Filters (linkId, status, orderReference, readyForPayment) combine with pagination normally — total/totalPages reflect the filtered set, not every conversion in the program. Results are ordered newest-first (createdAt descending) once paginated; the unpaginated array has no guaranteed order.

Find conversions ready to be paidreadyForPayment=true returns pending/approved conversions that have cleared (clearedAt is set — see Cleared, reserved, and withdrawn):

curl -u API_KEY:API_SECRET "https://affiliately.io/api/conversions?readyForPayment=true"

This ignores status if both are passed together — readyForPayment already implies pending or approved. Note this reflects the first tranche only: if your program uses a reservePct reserve, a conversion can show up here while part of its payout (reservedAmount) is still held back separately.

Update status — requires auth. This is how you move a conversion through pendingapproved, or mark it rejected:

curl -u API_KEY:API_SECRET \
  -X PATCH https://affiliately.io/api/conversions/38 \
  -H "Content-Type: application/json" \
  -d '{ "status": "approved" }'

approved is purely informational — nothing in the API requires it. In practice, you'd set it whenever your own backend considers the sale verified (e.g. payment/fraud checks clear), to distinguish "reviewed and legitimate" from "still unreviewed" while the conversion clears.

status only accepts pending, approved, or rejected here — paid is not settable by this endpoint. It's set automatically by the API once a withdrawal fully covers a conversion's available balance. Sending paid returns 400:

{ "error": "\"status\" must be one of [pending, approved, rejected]" }

Once paid, it's final. Any further PATCH or DELETE on a conversion the API has marked paid returns 409:

{ "error": "This conversion has already been paid and cannot be modified" }

Delete a conversion — requires auth, soft delete, blocked once paid (see above):

curl -u API_KEY:API_SECRET -X DELETE https://affiliately.io/api/conversions/38

Cancel a conversion during the hold period

If a customer cancels before a conversion has been paid, look it up by your own order id, then mark it rejected:

curl -u API_KEY:API_SECRET "https://affiliately.io/api/conversions?orderReference=ORDER-1042"
curl -u API_KEY:API_SECRET \
  -X PATCH https://affiliately.io/api/conversions/38 \
  -H "Content-Type: application/json" \
  -d '{ "status": "rejected" }'

This works at any point before the conversion is paidrejected isn't gated by payoutHoldDays or clearedAt, so it doesn't matter whether the cancellation happens on day 1 or day 13 of the hold window.

Subscriptions

Only relevant if your program's commissionRules.billing is subscription. A Subscription tracks one associate's recurring customer across billing cycles, so renewal payments can be credited without a fresh referral click — which won't exist by the time a renewal invoice comes due; the attribution window has long since expired.

The first payment still goes through POST /api/conversions, exactly as documented in Conversions — it's the signup moment, with a real aflyToken from the original click. For a subscription-billed program, that call also creates the Subscription for you and stamps the conversion with subscriptionId and cycleNumber: 1:

{
  "id": 40,
  "programId": 26,
  "linkId": 18,
  "subscriptionId": 5,
  "cycleNumber": 1,
  "aflyToken": "9d2a19ed-54d7-486d-ad19-7561eaff4e02",
  "orderReference": "ORDER-1042",
  "commissionAmount": 15,
  "currency": "GBP",
  "associatePayout": 15,
  "reservedAmount": 0,
  "customerDiscount": 0,
  "status": "pending",
  "clearedAt": null,
  "reservedClearedAt": null,
  "createdAt": "2026-07-07T20:24:10.745Z",
  "updatedAt": "2026-07-07T20:24:10.745Z"
}

Report a renewal — requires auth. There's no aflyToken for a renewal — you identify the subscription by its own id instead:

curl -u API_KEY:API_SECRET \
  -X POST https://affiliately.io/api/subscriptions/5/renewals \
  -H "Content-Type: application/json" \
  -d '{ "orderReference": "ORDER-1042-CYCLE-2", "orderValue": 50 }'
Field Type Required Notes
orderReference string yes Your own order/invoice id for this cycle. Must be unique per program
orderValue number only if your program's calculation is percentage Same rule as POST /api/conversions

Response — 201 Created, same shape as a conversion, with cycleNumber incremented from whatever this subscription is currently at. Whether this particular cycle actually earns commission depends on commissionRules:

  • recurrence: "once" — only cycle 1 (the original conversion) ever pays. Every renewal after that is still recorded here, for your own order history, but with commissionAmount, customerDiscount, and associatePayout all 0.
  • recurrence: "every-cycle", no cycleLimit — every renewal pays, indefinitely.
  • recurrence: "every-cycle", with a cycleLimit — renewals pay until the cycle count passes the limit, then 0 from then on (same zeroed-out shape as above).

A zeroed-out renewal is still a normal 201 — it's not an error, it's simply a cycle your commissionRules say shouldn't pay.

Error responses:

Status Cause
400 Your program's calculation is percentage and orderValue was omitted
404 No subscription with that id for your program
409 That orderReference has already been reported for this program
409 The subscription has been cancelled

Cancel a subscription — requires auth. Stops it from accepting further renewals:

curl -u API_KEY:API_SECRET -X POST https://affiliately.io/api/subscriptions/5/cancel

Response — 200 OK, the updated Subscription. Any renewal reported against a cancelled subscription from then on returns 409.

List / get subscriptions — requires auth, list filters by linkId and/or status:

curl -u API_KEY:API_SECRET "https://affiliately.io/api/subscriptions?status=active"
curl -u API_KEY:API_SECRET https://affiliately.io/api/subscriptions/5
{
  "id": 5,
  "programId": 26,
  "linkId": 18,
  "aflyToken": "9d2a19ed-54d7-486d-ad19-7561eaff4e02",
  "status": "active",
  "createdAt": "2026-07-07T20:24:10.000Z",
  "updatedAt": "2026-07-07T20:24:10.000Z"
}

Cleared, reserved, and withdrawn: the payout lifecycle

A conversion's associatePayout doesn't become withdrawable the moment it's reported — it moves through your program's hold period(s) first, and then has to actually be paid out via a withdrawal. Three program fields control the timing, and two conversion fields (plus status) record where a given conversion currently stands.

Program configuration:

Field Type Default Notes
payoutHoldDays integer 14 Days after a conversion is created before the bulk of its payout clears — your cancellation/returns window
reservePct integer 0 0–100. The percentage of associatePayout held back for a second, longer hold — e.g. for chargeback-prone businesses that want extra runway beyond the standard hold. 0 means no second tranche: the whole payout clears after payoutHoldDays
reserveHoldDays integer 0 Extra days the reserved slice is held, counted from when the first tranche clears (not from the conversion's creation date)

Both are set the same way as any other program setting — see Manage a program.

Conversion fields that track this:

Field Type Notes
reservedAmount number associatePayout × reservePct at the moment the conversion was created. Fixed for that conversion even if you change reservePct on the program afterwards
clearedAt datetime or null Set automatically once payoutHoldDays has passed since the conversion was created. null means none of the payout is available yet
reservedClearedAt datetime or null Set automatically once reserveHoldDays has passed since clearedAt. null means reservedAmount isn't available yet, even if the rest of the payout already is

Both timestamps are set by a background job that runs on an hourly schedule, not the instant the hold period ends — don't expect them to flip to-the-second.

How status fits in: you drive a conversion through pendingapproved, or divert it to rejected, same as before (see Update status). paid, however, is no longer something you set — the API sets it automatically once a withdrawal has fully covered whatever's currently available on that conversion. A conversion can sit at approved for a long time with a fully cleared, fully available balance simply because nobody has withdrawn it yet — status reflects money actually paid out, not eligibility.

Chargebacks reduce how much of a conversion is available to withdraw — without changing associatePayout, reservedAmount, or either clearing timestamp. See Chargebacks.

Available balance, at any point, is: whichever of the two tranches has cleared (associatePayout − reservedAmount once clearedAt is set, plus reservedAmount once reservedClearedAt is set too) minus that conversion's chargebacks minus whatever's already been withdrawn against it. You never compute this yourself — GET /api/associates/:id/balance gives you a read-only total across all of an associate's conversions, and POST /api/withdrawals applies the same calculation when it actually pays it out.

Chargebacks

Records that some or all of a conversion's payout was reversed after the fact (customer dispute, refund, fraud) — reducing how much of it is available to withdraw. Chargebacks are immutable events: to fix a mistaken one, delete it and create a new one rather than trying to edit it in place.

Create a chargeback — requires auth:

curl -u API_KEY:API_SECRET \
  -X POST https://affiliately.io/api/chargebacks \
  -H "Content-Type: application/json" \
  -d '{
    "conversionId": 38,
    "amount": 20,
    "reason": "Customer refund"
  }'
Field Type Required Notes
conversionId integer yes Must belong to your program
amount number yes Must be greater than 0
reason string no Free text, up to 255 characters

Response — 201 Created:

{
  "id": 4,
  "conversionId": 38,
  "amount": 20,
  "reason": "Customer refund",
  "createdAt": "2026-08-04T14:01:08.417Z",
  "updatedAt": "2026-08-04T14:01:08.417Z"
}

Error responses:

Status Cause
404 conversionId doesn't belong to your program (or doesn't exist)
409 This chargeback, added to any prior ones on the same conversion, would exceed its associatePayout
{ "error": "Chargeback of 20 would exceed the conversion's associate payout of 15 (already charged back: 0)" }

List / get chargebacks — requires auth, list filters by conversionId:

curl -u API_KEY:API_SECRET "https://affiliately.io/api/chargebacks?conversionId=38"
curl -u API_KEY:API_SECRET https://affiliately.io/api/chargebacks/4

Delete a chargeback — requires auth, soft delete. Frees up that amount again (it stops counting against the conversion's available balance):

curl -u API_KEY:API_SECRET -X DELETE https://affiliately.io/api/chargebacks/4

Response — 204 No Content.

Withdrawals

Records an actual payout to an associate. A withdrawal draws from that associate's available balance across all of their conversions — you don't pick which conversions fund it; the API allocates automatically, oldest-cleared first, and records exactly which conversions (and how much of each) went into it. To check what's available before committing to a payout, use GET /api/associates/:id/balance — it's a read-only preview of the same calculation.

Create a withdrawal — requires auth. Omit amount to withdraw the associate's entire available balance:

curl -u API_KEY:API_SECRET \
  -X POST https://affiliately.io/api/withdrawals \
  -H "Content-Type: application/json" \
  -d '{ "associateId": 17 }'

Or withdraw a specific amount, leaving the rest available for later:

curl -u API_KEY:API_SECRET \
  -X POST https://affiliately.io/api/withdrawals \
  -H "Content-Type: application/json" \
  -d '{ "associateId": 17, "amount": 50 }'
Field Type Required Notes
associateId integer yes Must belong to your program
amount number no Must not exceed the associate's available balance. Omit to withdraw everything available

Response — 201 Created, status starts at pending:

{
  "id": 9,
  "associateId": 17,
  "amount": 50,
  "currency": "GBP",
  "status": "pending",
  "reference": null,
  "requestedAt": "2026-08-04T14:16:46.411Z",
  "paidAt": null,
  "createdAt": "2026-08-04T14:16:46.411Z",
  "updatedAt": "2026-08-04T14:16:46.411Z"
}

Error responses:

Status Cause
404 associateId doesn't belong to your program (or doesn't exist)
409 The associate has no available balance to withdraw
409 amount exceeds the associate's available balance
{ "error": "Requested amount 200 exceeds available balance of 159" }

List / get withdrawals — requires auth, list filters by associateId and/or status. Getting a single withdrawal includes its allocations — the per-conversion breakdown of what funded it:

curl -u API_KEY:API_SECRET "https://affiliately.io/api/withdrawals?associateId=17"
curl -u API_KEY:API_SECRET https://affiliately.io/api/withdrawals/9
{
  "id": 9,
  "associateId": 17,
  "amount": 50,
  "currency": "GBP",
  "status": "pending",
  "reference": null,
  "requestedAt": "2026-08-04T14:16:46.000Z",
  "paidAt": null,
  "createdAt": "2026-08-04T14:16:46.000Z",
  "updatedAt": "2026-08-04T14:16:46.000Z",
  "allocations": [
    {
      "id": 12,
      "withdrawalId": 9,
      "conversionId": 38,
      "amount": 50,
      "created_at": "2026-08-04T14:16:46.000Z"
    }
  ]
}

Settle a withdrawal — requires auth. Only a pending withdrawal can be updated:

curl -u API_KEY:API_SECRET \
  -X PATCH https://affiliately.io/api/withdrawals/9 \
  -H "Content-Type: application/json" \
  -d '{ "status": "paid", "reference": "bank-transfer-8842" }'
Field Type Required Notes
status string yes paid or failed
reference string no Your own payment reference — bank transfer id, Stripe transfer id, etc.
  • paid stamps paidAt and is permanent. Any conversion this withdrawal fully covers (no remaining available balance) has its status automatically set to paid — see the payout lifecycle.
  • failed releases the allocated balance back to the pool — those conversions become available to withdraw again, whether in a retry or a different withdrawal.

Once settled (paid or failed), further PATCH returns 409:

{ "error": "This withdrawal has already been settled and cannot be modified" }

Cancel a withdrawal — requires auth. Only a pending withdrawal can be cancelled; behaves like failed above (releases the balance) but soft-deletes the withdrawal itself:

curl -u API_KEY:API_SECRET -X DELETE https://affiliately.io/api/withdrawals/9

Response — 204 No Content. 409 if the withdrawal isn't pending:

{ "error": "Only a pending withdrawal can be cancelled" }