Affiliates
Run your affiliate and customer referral program from code: add partners, hand them links, record referrals from your server, sign partners in to their portal, and read the commission ledger and payouts.
Base URL: https://api.ranla.ai
Set up the program first in the dashboard (Affiliates): turn it on, connect Stripe and pick a commission. The API works on that program. For the browser side (links, checkout, signups) see the affiliate tracking guide.
Authentication
Authorization: Bearer rnl_…Every affiliate endpoint needs a full access key. A sending key gets 403.
Resources
| Resource | Endpoints |
|---|---|
| Partners | GET/POST /affiliates · GET/PATCH/DELETE /affiliates/{id} · POST /affiliates/{id}/approve · /reject · /suspend |
| Links | GET/POST /affiliates/{id}/links · DELETE /affiliates/{id}/links/{link_id} |
| Portal sign-in (SSO) | POST /affiliates/{id}/login-link |
| Campaigns | GET/POST /affiliate-campaigns · GET/PATCH /affiliate-campaigns/{id} |
| Referrals | GET/POST /affiliate-referrals · GET /affiliate-referrals/{id} |
| Commissions | GET /affiliate-commissions · GET/PATCH /affiliate-commissions/{id} |
| Payouts | GET /affiliate-payouts · GET /affiliate-payouts/{id} · POST /affiliate-payouts/{id}/mark-paid |
Money is always in minor units (amount_cents), with a lowercase currency. Rates are basis points: 2000 is 20%.
Payout runs are not approved over the API. Approving a run moves money, so it stays with the account owner in the dashboard (or the approval card in Slack). The API can read payouts and mark manual ones paid once their run is approved.
Lists and pagination
List endpoints return newest first:
{ "data": [ … ], "has_more": true, "next_cursor": "eyJ0Ijoi…" }Pass limit (1–100, default 25) and cursor=<next_cursor> for the next page. Closed-set filters take a comma-separated list: ?status=active,pending.
Errors
The standard error body. Validation errors name each bad field under details.fields:
{
"error": {
"message": "\"jane\" is already taken",
"code": "conflict",
"details": { "fields": { "token": "Already taken" } }
}
}| Status | code |
When |
|---|---|---|
| 400 | validation_error |
A field is missing or invalid, or an action does not fit the partner's status |
| 401 | unauthorized |
Missing or invalid key |
| 403 | forbidden |
Sending key, or the action is not allowed (deleting a paid partner, a primary link) |
| 404 | not_found |
Not in this account, or the program is not set up yet |
| 409 | conflict |
Email already a partner, token taken, or the row changed in the meantime |
Partners
The partner object
{
"object": "affiliate",
"id": "5b0c…",
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Doe",
"company": null,
"website": null,
"country": "US",
"status": "active",
"source": "api",
"campaign_id": "a41e…",
"parent_affiliate_id": null,
"tags": ["customer"],
"payout_method": "paypal",
"payout_ready": true,
"approved_at": "2026-09-25T10:00:00.000Z",
"links": [
{
"object": "affiliate_link",
"id": "9f2d…",
"affiliate_id": "5b0c…",
"token": "jane",
"url": "https://yourapp.com/?via=jane",
"destination_url": null,
"label": null,
"is_primary": true,
"created_at": "2026-09-25T10:00:00.000Z"
}
],
"created_at": "2026-09-25T10:00:00.000Z",
"updated_at": "2026-09-25T10:00:00.000Z"
}status is one of invited, pending, active, suspended, rejected. Single-partner responses list every link; lists carry the primary link only.
Create a partner
POST /affiliates
| Field | Type | Notes |
|---|---|---|
email |
string | Required. One partner per email. |
first_name, last_name, company, website, country |
string | Optional. country is a 2-letter code. |
campaign_id |
string | Campaign id or name. The default campaign when omitted. |
token |
string | Their link token (?via=jane). Kept exactly, or 409 when taken. Derived from the name when omitted. |
status |
string | active (default), pending (needs approval) or invited. |
paypal_email |
string | Where PayPal payouts go. |
tags |
string[] | Up to 20. |
notes |
string | Private to your team. |
send_invite |
boolean | Send the welcome email (default true). Set false when your app shows the link itself. |
Customer referral programs. Turn signed-in customers into referrers from inside your app — create the partner the first time they open your "Refer a friend" page and show them their link:
import { Ranla } from '@supersend/ranla'
const client = new Ranla(process.env.RANLA_API_KEY!)
async function referralLinkFor(user: { email: string; name: string }) {
const found = await client.affiliates.list({ email: user.email })
const partner =
found.data[0] ??
(await client.affiliates.create({
email: user.email,
first_name: user.name,
tags: ['customer'],
send_invite: false,
})).affiliate
return partner.links?.[0]?.url
}curl -X POST https://api.ranla.ai/affiliates \
-H "Authorization: Bearer $RANLA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","first_name":"Jane","token":"jane","send_invite":false}'201 → { "affiliate": { … } }
List, get, update, delete
GET /affiliates?status=active&campaign_id=…&email=…—emailis an exact lookup.GET /affiliates/{id}PATCH /affiliates/{id}—first_name,last_name,company,website,country,notes,tags,campaign_id(new commissions use the new campaign's rate),parent_affiliate_id,postback_url,email_opt_out,commission_override({ "commission_type": "percent", "commission_bps": 3000 }, ornullto clear), andtoken(renames the primary link; the old link stops attributing).DELETE /affiliates/{id}— refused with403once the partner has been paid. Suspend them instead.
Approve, reject, suspend
curl -X POST https://api.ranla.ai/affiliates/$ID/approve \
-H "Authorization: Bearer $RANLA_API_KEY"| Endpoint | From → to | Body |
|---|---|---|
POST /affiliates/{id}/approve |
pending, invited, rejected → active |
send_email (default true) |
POST /affiliates/{id}/reject |
pending, invited → rejected |
reason, send_email |
POST /affiliates/{id}/suspend |
active, pending, invited → suspended |
reason |
A suspended partner's links stop attributing and payouts skip them.
Links
GET /affiliates/{id}/linksPOST /affiliates/{id}/links—{ "token": "jane-youtube", "destination_url": "/pricing", "label": "YouTube" }.destination_urlis a path or a page on your website. Up to 50 links per partner.DELETE /affiliates/{id}/links/{link_id}— the primary link cannot be deleted.
Sign partners in to the portal (SSO)
POST /affiliates/{id}/login-link
{ "object": "affiliate_login_link", "url": "https://acme.partners.ranla.ai/auth/callback?token=…", "expires_at": "2026-09-25T10:01:00.000Z" }The link signs the partner in to your partner portal once and expires after one minute. Mint it when a signed-in user clicks "Referral dashboard" in your app and redirect straight away — never email or store it:
app.get('/referrals/dashboard', requireUser, async (req, res) => {
const { url } = await client.affiliates.loginLink(req.user.affiliateId)
res.redirect(url)
})Rejected partners cannot sign in (403).
Campaigns
A campaign is a commission plan. Every partner belongs to one; the default campaign takes new partners.
{
"object": "affiliate_campaign",
"id": "a41e…",
"name": "Standard",
"is_default": true,
"visibility": "public",
"auto_approve": true,
"commission_type": "percent",
"commission_bps": 3000,
"commission_flat_cents": 0,
"commission_duration": "recurring",
"recurring_months": 12,
"max_commission_cents": null,
"tiers": [{ "min_paid_customers": 10, "commission_bps": 4000, "commission_flat_cents": null }],
"product_rates": [],
"second_tier_bps": 0,
"lead_bounty_cents": 0,
"cookie_days": 60,
"hold_days": 30,
"archived_at": null,
"created_at": "…",
"updated_at": "…"
}GET /affiliate-campaigns?include_archived=true— not paged; each row addsaffiliate_count.POST /affiliate-campaigns—nameis required; passmake_default: trueto make it the default.GET /affiliate-campaigns/{id},PATCH /affiliate-campaigns/{id}— new rates apply to new commissions only.
recurring_months: null pays for the customer's lifetime. hold_days is the refund window before a commission is earned. tiers raise the rate once a partner has brought that many paying customers. second_tier_bps pays a partner's recruiter a share of the same sale.
Referrals
A referral is a person a partner sent you. It starts as a lead and becomes active when they pay (trialing, cancelled, refunded and rejected follow the customer).
{
"object": "affiliate_referral",
"id": "c7a0…",
"affiliate_id": "5b0c…",
"status": "lead",
"source": "api",
"email": "[email protected]",
"stripe_customer_id": "cus_Q…",
"sub_id": null,
"blocked_reason": null,
"revenue_cents": 0,
"currency": "usd",
"converted_at": null,
"expires_at": "2026-11-24T10:00:00.000Z",
"created_at": "…",
"updated_at": "…"
}GET /affiliate-referrals filters: status, source, affiliate_id, email, created_after, created_before.
Server-side referral tracking
POST /affiliate-referrals attributes a person to a partner without the browser snippet — for example from a signup handler that received a referral code in a mobile app, an invite flow, or your own ?ref= handling.
| Field | Notes |
|---|---|
affiliate_id or token |
Required, one of them. token is the partner's link token. |
email |
Required. The referred person. |
stripe_customer_id |
Optional. Ties their future Stripe payments to this referral straight away. |
sub_id |
Optional tag for reporting. |
curl -X POST https://api.ranla.ai/affiliate-referrals \
-H "Authorization: Bearer $RANLA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"token":"jane","email":"[email protected]","stripe_customer_id":"cus_Q1"}'{ "referral": { "object": "affiliate_referral", "id": "c7a0…", "status": "lead", … }, "created": true }201withcreated: truefor a new referral.200withcreated: falsewhen the person was already referred — the first partner to claim a person keeps them.- A self-referral is recorded but flagged (
blocked_reason: "self_referral") and never earns. 404when no active partner has that id or token;409while the program is not live.
When their Stripe customer pays, commissions are written for the partner automatically.
Commissions
One ledger line per paid invoice (and per lead bounty or recruiter share).
{
"object": "affiliate_commission",
"id": "e19b…",
"affiliate_id": "5b0c…",
"referral_id": "c7a0…",
"tier": 1,
"status": "pending",
"amount_cents": 1470,
"sale_amount_cents": 4900,
"currency": "usd",
"description": null,
"stripe_invoice_id": "in_1Q…",
"stripe_charge_id": "ch_3Q…",
"earns_at": "2026-10-25T10:00:00.000Z",
"earned_at": null,
"paid_at": null,
"reversed_at": null,
"reverse_reason": null,
"payout_id": null,
"created_at": "…",
"updated_at": "…"
}status moves pending → earned (after the hold) → paid, or to reversed. A refund or dispute during the hold reverses it on its own.
GET /affiliate-commissionsfilters:status,affiliate_id,referral_id,currency,created_after,created_before.GET /affiliate-commissions/{id}PATCH /affiliate-commissions/{id}with{ "status": "reversed", "reason": "fraud", "note": "Card testing" }— the only change the API makes.reasonismanual(default) orfraud. Paid commissions, and commissions already in a payout, cannot be reversed (403).
Payouts
{
"object": "affiliate_payout",
"id": "d3f4…",
"affiliate_id": "5b0c…",
"run_id": "71aa…",
"amount_cents": 9800,
"currency": "usd",
"method": "wise",
"status": "processing",
"stripe_transfer_id": null,
"external_reference": null,
"failure_reason": null,
"paid_at": null,
"created_at": "…",
"updated_at": "…"
}GET /affiliate-payoutsfilters:status,affiliate_id,run_id.GET /affiliate-payouts/{id}POST /affiliate-payouts/{id}/mark-paidwith{ "reference": "TX-2231" }— forpaypal,wise,bankandotherpayouts you sent yourself. The run must already be approved (409otherwise). Stripe payouts settle on their own (403).
Webhooks
Affiliate events go to the same webhook endpoints as email events, signed the same way. Subscribe to them by name, or leave events empty to receive everything.
| Event | When |
|---|---|
affiliate.created |
A partner joined, was invited, imported or created over the API |
affiliate.approved |
A partner became active |
affiliate.updated |
A partner's profile, campaign, payout method or status changed |
referral.created |
A new referral (lead) was recorded |
referral.converted |
A referral became a paying customer |
commission.created |
A commission was written (pending) |
commission.earned |
Its hold ended |
commission.reversed |
It was reversed (refund, dispute or by hand) |
commission.paid |
It was paid in a payout |
payout.created |
A payout was drafted in a run |
payout.paid |
A payout went out |
payout.failed |
A payout failed; its commissions return to the next run |
Every payload has the same envelope. data.event_id is unique per event — use it to de-duplicate retries. data.affiliate_id names the partner, and the object the event is about rides along when there is one (affiliate, referral, commission) or by id (referral_id, commission_id, payout_id). Fetch it from the API when you need its latest state.
{
"type": "affiliate.approved",
"created_at": "2026-09-25T10:00:00.000Z",
"data": {
"event_id": "0b5e7d4c-…",
"program_id": "3c1f…",
"affiliate_id": "5b0c…",
"affiliate": {
"object": "affiliate",
"id": "5b0c…",
"email": "[email protected]",
"status": "active",
"campaign_id": "a41e…",
"…": "…"
}
}
}A common use: commission.earned to grant account credit instead of cash in a customer referral program.
Verify signatures
Exactly as for email events — read the raw body and check the SuperSendTX-Signature header with your endpoint's secret:
import { verifyWebhookSignature } from '@supersend/ranla'
const ok = verifyWebhookSignature(secret, rawBody, request.headers.get('supersendtx-signature'))
if (!ok) return new Response('Bad signature', { status: 400 })
const event = JSON.parse(rawBody)
if (event.type === 'commission.earned') {
// …
}See Webhooks → Signature verification for the manual check.
Per-partner postbacks
Set postback_url on a partner (PATCH /affiliates/{id}) and that partner's own referral.* and commission.* events are also sent to their URL as JSON POSTs, retried up to three times.
SDK
import { Ranla } from '@supersend/ranla'
const client = new Ranla(process.env.RANLA_API_KEY!)
const { affiliate } = await client.affiliates.create({ email: '[email protected]', token: 'jane' })
await client.affiliates.links.create(affiliate.id, { token: 'jane-yt', destination_url: '/pricing' })
await client.affiliates.approve(affiliate.id)
const { url } = await client.affiliates.loginLink(affiliate.id)
await client.affiliateReferrals.create({ token: 'jane', email: '[email protected]' })
const { data: earned } = await client.affiliateCommissions.list({ status: 'earned' })
await client.affiliateCommissions.reverse(earned[0].id, { reason: 'fraud' })
const { data: payouts } = await client.affiliatePayouts.list({ status: 'processing' })
await client.affiliatePayouts.markPaid(payouts[0].id, { reference: 'TX-2231' })
const { data: campaigns } = await client.affiliateCampaigns.list()CLI
supersendtx affiliates list --status active
supersendtx affiliates get <affiliate-id>
supersendtx affiliates create --email [email protected] --first-name Jane --token jane --no-invite
supersendtx referrals create --email [email protected] --token jane