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_codeconditionalRequired unless you pass coupon_code. The affiliate's referral code.
coupon_codeconditionalAlternative attribution path. Case-insensitive.
order_idyesYour system's unique order or subscription ID. Used for idempotency.
order_totalyesTotal revenue from this order/subscription, as a decimal.
currencynoISO 4217 code. Defaults to USD.
customer_emailnoUsed for fraud detection (e.g. catching self-referrals).
commission_ratenoOverride the affiliate's default rate. Numeric percent (e.g. 30 = 30%).
store_slugnoIf your account has multiple stores. Defaults to your first active store.
order_datenoISO 8601. Defaults to now.
statusnopending (default) or approved.
subscription_idnoFor 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:

  1. 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.
  2. In your Stripe webhook handler, listen to invoice.paid (not just subscription.created) and call AffRef on every paid invoice for that customer, passing the same subscription_id each time.
  3. 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>): the client-reference-id attribute
  • 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 same order_id twice (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
401missing_api_keyNo Bearer or X-API-Key header.
401invalid_api_keyKey not recognised.
402subscription_inactiveYour AffRef subscription has lapsed.
422no_storeYour account has no store. Create one in the dashboard.
422no_affiliate_matchThe affiliate_code / coupon_code doesn't match an active affiliate.
422validationMissing 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.

Ready to grow your affiliate program?

Start your 7-day free trial. Cancel anytime.

View Plans
Usually replies within an hour
Hey 👋 I'm Aaron, founder of AffRef. Drop your email and your question and I'll get back to you fast.
By chatting you agree to our privacy policy.
End this chat? History on this device clears.