Developer Docs
AffRef API v1
A small JSON API for tracking referrals from any app — SaaS on Stripe, Next.js storefronts, headless commerce, anything that can fire an HTTP request. Same data model behind the scenes as the Shopify integration. Drop in, ship in 10 minutes.
If you're a Shopify merchant, you don't need this — install our official Shopify App in one click and the tracker, order webhooks, and refund webhooks all wire up automatically. The API is for everyone else: indie SaaS founders, Next.js shops, custom-coded ecommerce, anything where you control the conversion event server-side.
Base URL
https://app.affref.com/api/v1
Authentication — two keys
Each merchant account has two keys. Use the right one for the job. Grab both from your billing settings.
| Key | Use from | Can call |
|---|---|---|
| aff_pub_… | Browser / client-side JS (safe to expose) | POST /clicks only |
| aff_live_… | Backend / server-side only (never expose) | All endpoints, including /conversions and /me |
Pass either key as a Bearer token:
Authorization: Bearer aff_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
An X-API-Key header is also accepted if Bearer is awkward for your stack.
Why two keys?
Because the cheapest, easiest way to install affiliate tracking is to drop a <script> tag into your site (see "Drop-in tracking script" below). That script needs an API key — but if it were your secret key, anyone could view-source, steal it, and create fake conversions worth real commission money. The publishable key solves this: it's safe to expose, but it's scoped to clicks-only so an attacker with the publishable key can at worst record some spurious clicks. Conversions (the part that costs you money) always require the secret key from your backend.
Drop-in tracking script (browser)
The fastest install. One line of HTML in your site's <head>. Auto-captures ?ref= from the URL, stores it in a first-party cookie, and records a click — all server-to-server-safe because the script uses your publishable key.
<script src="https://app.affref.com/track.js?key=aff_pub_..." async></script>
Once the script loads, the persisted referral code is available as:
window.AffRef.getReferral() // returns the affiliate_code from cookie/localStorage
In your Stripe checkout creation, read the cookie server-side and pass it as subscription_data.metadata.affiliate_code so your webhook can fire a conversion later.
Verify your key
curl https://app.affref.com/api/v1/me \ -H "Authorization: Bearer aff_live_..."
Response:
{
"data": {
"user_id": 1,
"email": "[email protected]",
"plan": "starter",
"subscription_status": "active",
"stores": [
{ "slug": "myapp", "name": "MyApp", "is_active": true }
]
}
}
Recording a conversion
The big one. Fire this from your server when a referred user pays, signs up, or completes whatever event you commission on. Idempotent on order_id — calling twice with the same id returns the original conversion, doesn't create duplicates.
POST /api/v1/conversions
curl https://app.affref.com/api/v1/conversions \
-H "Authorization: Bearer aff_live_..." \
-H "Content-Type: application/json" \
-d '{
"affiliate_code": "SARAH",
"order_id": "sub_1ABC456XYZ",
"order_total": 49.00,
"currency": "USD",
"customer_email": "[email protected]"
}'
Response (201 Created):
{
"data": {
"id": 42,
"order_id": "sub_1ABC456XYZ",
"order_total": 49.00,
"currency": "USD",
"commission_amount": 14.70,
"commission_rate": 30.0,
"status": "pending",
"affiliate_id": 7,
"attribution_source": "link",
"created_at": "2026-10-13T07:32:21+00:00"
}
}
Fields
| Field | Required | Notes |
|---|---|---|
| affiliate_code | conditional | Required unless you pass coupon_code. The affiliate's referral code. |
| coupon_code | conditional | Alternative attribution path. Case-insensitive. |
| order_id | yes | Your system's unique order or subscription ID. Used for idempotency. |
| order_total | yes | Total revenue from this order/subscription, as a decimal. |
| currency | no | ISO 4217 code. Defaults to USD. |
| customer_email | no | Used for fraud detection (e.g. catching self-referrals). |
| commission_rate | no | Override the affiliate's default rate. Numeric percent (e.g. 30 = 30%). |
| store_slug | no | If your account has multiple stores. Defaults to your first active store. |
| order_date | no | ISO 8601. Defaults to now. |
| status | no | pending (default) or approved. |
| subscription_id | no | For recurring commissions. Pass the Stripe / LS subscription ID (sub_xxx) on every invoice.paid. If the affiliate has commission_type=recurring, AffRef fires a new conversion on each renewal up to the configured duration. If commission_type=one_time, only the first payment counts. |
Recurring commissions for SaaS
By default each call to POST /api/v1/conversions is idempotent on order_id — one conversion per call. For SaaS that pays affiliates a recurring percentage of monthly revenue (e.g. 20% for the first 12 months), you need to:
- Set the affiliate's commission model to recurring + a duration in months (or leave duration empty for lifetime). Brand-level default at
Brands → Edit → Commission model; per-affiliate override on the affiliate's edit page. - In your Stripe webhook handler, listen to
invoice.paid(not justsubscription.created) and call AffRef on every paid invoice for that customer, passing the samesubscription_ideach time. - AffRef counts how many invoices have already created conversions for that subscription and stops firing once you hit the duration cap. If the affiliate is set to one_time, only the first invoice creates a conversion; subsequent calls return
skipped: true.
Where does the affiliate code come from?
If you have our shopify.js / affref.js tracker script installed on the marketing page that leads to your Stripe checkout, you don't have to do anything — it auto-injects the affiliate code into:
- Stripe Pricing Tables (
<stripe-pricing-table>): theclient-reference-idattribute - Stripe Buy Buttons (
<stripe-buy-button>): same attribute - Direct Stripe Checkout / Payment Link URLs in
<a href>: appended as?client_reference_id=CODE
Stripe propagates client_reference_id through to the resulting checkout.session.completed event, the customer, and every subsequent invoice.paid. Your webhook just reads it back. No metadata fiddling, no cookie reading on the server.
For server-side checkout sessions (when you create the session yourself), call window.AffRef.getReferral() in JS before the redirect, or read the affref_* cookie on your server and pass it as client_reference_id when creating the Stripe Checkout Session.
Webhook handler
// Node.js example - fire on every invoice.paid
case 'invoice.paid': {
const invoice = event.data.object;
// Read the affiliate code Stripe propagated for you via client_reference_id.
// Falls back to metadata for legacy integrations that still set it manually.
const affiliateCode = invoice.subscription_details?.metadata?.client_reference_id
|| invoice.customer_details?.metadata?.client_reference_id
|| invoice.subscription_details?.metadata?.affiliate_code
|| invoice.customer_details?.metadata?.affiliate_code;
// For checkout.session.completed events, use event.data.object.client_reference_id directly.
if (!affiliateCode || !invoice.subscription) return;
await fetch('https://app.affref.com/api/v1/conversions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.AFFREF_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
affiliate_code: affiliateCode,
order_id: invoice.id, // unique per invoice
subscription_id: invoice.subscription, // <-- same on every renewal
order_total: invoice.amount_paid / 100,
currency: invoice.currency.toUpperCase(),
customer_email: invoice.customer_email,
}),
});
}
Possible 200 responses:
{ "data": {...} }— new conversion created.{ "skipped": true, "reason": "subscription_already_counted_one_time" }— one_time affiliate; you already got the first payment.{ "skipped": true, "reason": "recurring_duration_exceeded" }— recurring window finished; no more commissions on this sub.{ "data": {...}, "idempotent": true }— you posted the sameorder_idtwice (e.g. webhook retry); existing record returned.
Express (Node.js)
// app.js - Express endpoint that receives Stripe webhooks
import express from 'express';
import Stripe from 'stripe';
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) { return res.status(400).send(`Webhook Error: ${err.message}`); }
if (event.type === 'invoice.paid') {
const invoice = event.data.object;
// Stripe stores client_reference_id on the subscription's metadata
// when the sub was created from a Checkout Session that had one.
const sub = invoice.subscription
? await stripe.subscriptions.retrieve(invoice.subscription)
: null;
const affiliateCode = sub?.metadata?.client_reference_id
|| invoice.customer_details?.metadata?.client_reference_id;
if (affiliateCode && invoice.subscription) {
await fetch('https://app.affref.com/api/v1/conversions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AFFREF_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
affiliate_code: affiliateCode,
order_id: invoice.id,
subscription_id: invoice.subscription,
order_total: invoice.amount_paid / 100,
currency: invoice.currency.toUpperCase(),
customer_email: invoice.customer_email,
}),
});
}
}
res.json({ received: true });
}
);
Next.js (App Router)
// app/api/stripe-webhook/route.ts
import { NextRequest } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err: any) {
return new Response(`Webhook error: ${err.message}`, { status: 400 });
}
if (event.type === 'invoice.paid') {
const invoice = event.data.object as Stripe.Invoice;
const sub = invoice.subscription
? await stripe.subscriptions.retrieve(invoice.subscription as string)
: null;
const affiliateCode =
sub?.metadata?.client_reference_id ||
(invoice as any).customer_details?.metadata?.client_reference_id;
if (affiliateCode && invoice.subscription) {
await fetch('https://app.affref.com/api/v1/conversions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AFFREF_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
affiliate_code: affiliateCode,
order_id: invoice.id,
subscription_id: invoice.subscription,
order_total: (invoice.amount_paid || 0) / 100,
currency: invoice.currency.toUpperCase(),
customer_email: invoice.customer_email,
}),
});
}
}
return Response.json({ received: true });
}
For Next.js: set runtime = 'nodejs' (not Edge) so the Stripe SDK works, and disable body parsing for the route so signature verification gets the raw bytes.
Laravel (PHP)
// routes/web.php
Route::post('/webhooks/stripe', [StripeWebhookController::class, 'handle']);
// app/Http/Middleware/VerifyCsrfToken.php - exclude the route
protected $except = ['webhooks/*'];
// app/Http/Controllers/StripeWebhookController.php
public function handle(Request $request)
{
\Stripe\Stripe::setApiKey(config('services.stripe.secret'));
try {
$event = \Stripe\Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret')
);
} catch (\Exception $e) {
return response('Invalid', 400);
}
if ($event->type === 'invoice.paid') {
$invoice = $event->data->object;
$sub = $invoice->subscription
? \Stripe\Subscription::retrieve($invoice->subscription)
: null;
$code = $sub?->metadata['client_reference_id']
?? $invoice->customer_details?->metadata['client_reference_id']
?? null;
if ($code && $invoice->subscription) {
Http::withToken(env('AFFREF_API_KEY'))
->post('https://app.affref.com/api/v1/conversions', [
'affiliate_code' => $code,
'order_id' => $invoice->id,
'subscription_id' => $invoice->subscription,
'order_total' => $invoice->amount_paid / 100,
'currency' => strtoupper($invoice->currency),
'customer_email' => $invoice->customer_email,
]);
}
}
return response()->json(['received' => true]);
}
Flask (Python)
# app.py — Flask endpoint that receives Stripe webhooks
import os, requests, stripe
from flask import Flask, request
stripe.api_key = os.environ['STRIPE_SECRET_KEY']
app = Flask(__name__)
@app.route('/webhooks/stripe', methods=['POST'])
def stripe_webhook():
payload = request.get_data()
sig = request.headers.get('Stripe-Signature')
try:
event = stripe.Webhook.construct_event(
payload, sig, os.environ['STRIPE_WEBHOOK_SECRET']
)
except (ValueError, stripe.error.SignatureVerificationError):
return 'Invalid', 400
if event['type'] == 'invoice.paid':
invoice = event['data']['object']
sub = stripe.Subscription.retrieve(invoice['subscription']) if invoice.get('subscription') else None
code = (
(sub.metadata.get('client_reference_id') if sub else None)
or invoice.get('customer_details', {}).get('metadata', {}).get('client_reference_id')
)
if code and invoice.get('subscription'):
requests.post(
'https://app.affref.com/api/v1/conversions',
headers={
'Authorization': f"Bearer {os.environ['AFFREF_API_KEY']}",
'Content-Type': 'application/json',
},
json={
'affiliate_code': code,
'order_id': invoice['id'],
'subscription_id': invoice['subscription'],
'order_total': invoice['amount_paid'] / 100,
'currency': invoice['currency'].upper(),
'customer_email': invoice.get('customer_email'),
},
)
return {'received': True}, 200
Django is the same shape — put this inside a CSRF-exempt view in urls.py + views.py. The only thing that changes is the framework wrapper.
Rails (Ruby)
# config/routes.rb
post '/webhooks/stripe', to: 'stripe_webhooks#create'
# app/controllers/stripe_webhooks_controller.rb
require 'net/http'
require 'json'
class StripeWebhooksController < ActionController::API
skip_before_action :verify_authenticity_token, raise: false
def create
payload = request.body.read
sig = request.headers['Stripe-Signature']
begin
event = Stripe::Webhook.construct_event(
payload, sig, ENV.fetch('STRIPE_WEBHOOK_SECRET')
)
rescue JSON::ParserError, Stripe::SignatureVerificationError
return head :bad_request
end
if event.type == 'invoice.paid'
invoice = event.data.object
sub = invoice.subscription ? Stripe::Subscription.retrieve(invoice.subscription) : nil
code = sub&.metadata&.[]('client_reference_id') ||
invoice.customer_details&.metadata&.[]('client_reference_id')
if code && invoice.subscription
uri = URI('https://app.affref.com/api/v1/conversions')
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{ENV.fetch('AFFREF_API_KEY')}"
req['Content-Type'] = 'application/json'
req.body = {
affiliate_code: code,
order_id: invoice.id,
subscription_id: invoice.subscription,
order_total: invoice.amount_paid / 100.0,
currency: invoice.currency.upcase,
customer_email: invoice.customer_email
}.to_json
http.request(req)
end
end
end
render json: { received: true }
end
end
Common gotcha across all five languages: Stripe puts client_reference_id on the Checkout Session (top-level), but NOT automatically on the resulting Subscription or Customer. The cleanest pattern: in your checkout.session.completed handler, copy session.client_reference_id onto subscription.metadata.client_reference_id so subsequent invoice.paid events can read it. The examples above retrieve the subscription each time, which works without that step.
Recording a click (optional)
If you want click-level analytics in addition to conversions. For most SaaS use-cases you can skip this — the conversion API alone is enough to compute commissions.
POST /api/v1/clicks
curl https://app.affref.com/api/v1/clicks \
-H "Authorization: Bearer aff_live_..." \
-H "Content-Type: application/json" \
-d '{
"affiliate_code": "SARAH",
"landing_url": "https://myapp.com/?ref=SARAH",
"referrer": "https://twitter.com/",
"country": "US"
}'
Integration patterns
Stripe SaaS — one-time commission on customer.subscription.created
For one-time-only commission models (affiliate gets paid once on first sub creation, not on each renewal), fire on customer.subscription.created instead of invoice.paid. Stash the affiliate code on the Stripe customer's metadata when they sign up. For recurring commissions, use the invoice.paid pattern shown above — passing subscription_id on each renewal so AffRef can count payments and stop firing once the duration cap is hit.
// Node.js example - one-time commission only
case 'customer.subscription.created': {
const sub = event.data.object;
const affiliateCode = sub.metadata.affiliate_code; // stashed at signup
if (affiliateCode) {
await fetch('https://app.affref.com/api/v1/conversions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.AFFREF_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
affiliate_code: affiliateCode,
order_id: sub.id,
order_total: sub.items.data[0].price.unit_amount / 100,
currency: sub.currency.toUpperCase(),
customer_email: sub.customer_email,
// NOTE: no subscription_id here. This is intentional — without it,
// recurring duration tracking won't kick in. Use this pattern only
// when your affiliate program pays a flat one-time commission.
}),
});
}
break;
}
Next.js — capture ?ref= on landing, attach to Stripe at checkout
Drop a tiny piece of client JS that reads ?ref=ABC123 from the URL, stores it in a first-party cookie, then passes it as Stripe customer metadata when the user signs up.
// app/layout.tsx (or _app.tsx)
useEffect(() => {
const ref = new URL(window.location.href).searchParams.get('ref');
if (ref) {
document.cookie = `affref=${ref}; path=/; max-age=${60*60*24*30}; SameSite=Lax`;
}
}, []);
// On checkout creation, server-side:
const cookieStore = await cookies();
const affiliateCode = cookieStore.get('affref')?.value;
await stripe.checkout.sessions.create({
// ...
subscription_data: {
metadata: { affiliate_code: affiliateCode },
},
});
"Vibe-coded" / one-off apps
For the simplest possible integration — even simpler than the Stripe pattern — just fire one HTTP POST from your backend when whatever-counts-as-a-conversion happens. The whole integration can be five lines of code:
await fetch('https://app.affref.com/api/v1/conversions', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + AFFREF_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
affiliate_code: user.referredByCode,
order_id: user.id,
order_total: 49,
}),
});
Errors
JSON responses with an error machine-readable code and a human-readable message:
| Status | Error code | Meaning |
|---|---|---|
| 401 | missing_api_key | No Bearer or X-API-Key header. |
| 401 | invalid_api_key | Key not recognised. |
| 402 | subscription_inactive | Your AffRef subscription has lapsed. |
| 422 | no_store | Your account has no store. Create one in the dashboard. |
| 422 | no_affiliate_match | The affiliate_code / coupon_code doesn't match an active affiliate. |
| 422 | validation | Missing or malformed fields. Laravel returns standard 422 with details. |
Rate limits
60 requests per minute per API key during beta. If you legitimately need more, email [email protected] and we'll bump you.
Versioning
Everything is under /api/v1. Breaking changes will get a new prefix (/api/v2) with at least 6 months of overlap. We won't quietly break v1.
Questions?
Email [email protected]. If you find the docs unclear or hit a sharp edge, tell us — this API is new and we want to make it good.