convex-affiliates
v3.0.6
Published
A comprehensive affiliate marketing component for Convex with flexible attribution tracking and NET-15/30/60/90 payout scheduling.
Maintainers
Readme
Convex Affiliates
A comprehensive affiliate marketing component for Convex with flexible attribution tracking, configurable commission structures, and seamless Stripe integration.
Features
- Flexible Attribution Tracking - Configurable storage (localStorage, cookie, or both) with URL parameter-based attribution
- Stripe Integration - Easy webhook handlers for automatic commission creation
- Flexible Commission Structures - Percentage or fixed, with tiered and product-specific rates
- Campaign Management - Multiple campaigns with different terms
- NET-0/15/30/60/90 Scheduling - Configurable payout terms with automatic due date calculation
- Manual Payout Recording - Record payouts made via PayPal, bank transfer, or other methods
- Pure Data Layer - Component handles data, your app handles integrations
Installation
npm install convex-affiliatesPeer Dependencies
npm install convexQuick Start
1. Configure the Component
Create or update convex/convex.config.ts:
import { defineApp } from "convex/server";
import affiliates from "convex-affiliates/convex.config";
const app = defineApp();
app.use(affiliates);
export default app;2. Generate the API file
npx convex-affiliates initThis creates convex/affiliates.ts with all exports pre-configured. Edit the file to set your auth callback and commission defaults.
import { components } from "./_generated/api";
import { createAffiliateApi } from "convex-affiliates";
const affiliates = createAffiliateApi(components.affiliates, {
defaultCommissionValue: 20,
defaultPayoutTerm: "NET-30",
baseUrl: process.env.BASE_URL ?? "https://yourapp.com",
auth: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
return identity.subject;
},
});
// Export only the functions you need
export const { trackClick, validateCode } = affiliates;
export const { register, getAffiliate, getPortalData, listCommissions,
listPayouts, listReferrals, generateLink, attributeSignup } = affiliates;
export const { adminDashboard, adminListAffiliates, adminApproveAffiliate,
adminRejectAffiliate, adminSuspendAffiliate, adminListCampaigns,
adminCreateCampaign } = affiliates;3. Deploy
npx convex deploy4. Add Lifecycle Hooks (Optional)
The affiliate API supports type-safe hooks for lifecycle events. Use these to send emails, trigger webhooks, or integrate with other systems.
import { components } from "./_generated/api";
import { createAffiliateApi } from "convex-affiliates";
const affiliates = createAffiliateApi(components.affiliates, {
// ... other config ...
hooks: {
"affiliate.registered": async (data) => {
// data: { affiliateId, affiliateCode, affiliateEmail, affiliateUserId }
await sendEmail(data.affiliateEmail, "Welcome to our affiliate program!");
},
"affiliate.approved": async (data) => {
// data: { affiliateId, affiliateCode, affiliateEmail, affiliateUserId }
await sendEmail(data.affiliateEmail, "Your application has been approved!");
},
"affiliate.rejected": async (data) => {
await sendEmail(data.affiliateEmail, "Unfortunately, your application was not approved.");
},
"affiliate.suspended": async (data) => {
await sendEmail(data.affiliateEmail, "Your affiliate account has been suspended.");
},
},
});Available Hooks
| Hook | Typed Data | Fields |
|------|-----------|--------|
| affiliate.registered | AffiliateRegisteredData | affiliateId, affiliateCode, affiliateEmail, affiliateUserId |
| affiliate.approved | AffiliateStatusChangeData | affiliateId, affiliateCode, affiliateEmail, affiliateUserId |
| affiliate.rejected | AffiliateStatusChangeData | affiliateId, affiliateCode, affiliateEmail, affiliateUserId |
| affiliate.suspended | AffiliateStatusChangeData | affiliateId, affiliateCode, affiliateEmail, affiliateUserId |
| commission.created | CommissionCreatedData | commissionId, affiliateId, affiliateCode, commissionAmountCents, currency |
| commission.reversed | CommissionReversedData | commissionId, affiliateId, commissionAmountCents |
Stripe Integration with Hooks
For commission events via Stripe webhooks, pass hooks to the Stripe handlers:
import { getAffiliateStripeHandlers } from "convex-affiliates";
export const stripeHandlers = getAffiliateStripeHandlers(
components.affiliates,
{
hooks: {
"commission.created": async (data) => {
// data: { commissionId, affiliateId, affiliateCode, commissionAmountCents, currency }
await notifyAffiliate(data.affiliateId, `You earned $${(data.commissionAmountCents / 100).toFixed(2)}!`);
},
"commission.reversed": async (data) => {
await notifyAffiliate(data.affiliateId, "A commission was reversed due to a refund.");
},
},
}
);Error Handling
Hooks are wrapped in try/catch - if a hook throws an error, the mutation still succeeds. Errors are logged to console. This ensures hook failures don't break critical operations like registrations or approvals.
Usage Guide
Calling Functions from Your App
All exported functions are ready to use from your frontend:
import { useMutation, useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
function AffiliatePortal() {
// Query affiliate data
const portal = useQuery(api.affiliates.getPortalData);
const commissions = useQuery(api.affiliates.listCommissions, { limit: 10 });
// Register as affiliate
const register = useMutation(api.affiliates.register);
const handleRegister = async () => {
await register({
email: "[email protected]",
displayName: "My Brand",
});
};
// ...
}Referral Tracking
Track clicks when visitors land on your site with an affiliate code:
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { useEffect } from "react";
function ReferralTracker() {
const trackClick = useMutation(api.affiliates.trackClick);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get("ref") || params.get("via");
if (code) {
trackClick({ affiliateCode: code, landingPage: window.location.pathname });
}
}, []);
return null;
}Attribution (Manual Integration)
Attribute signups to affiliates after user registration. In your auth hook or signup handler:
// convex/users.ts
import { internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
export const onUserCreated = internalMutation({
args: { userId: v.string(), referralCode: v.optional(v.string()) },
handler: async (ctx, args) => {
if (args.referralCode) {
// attributeSignup is already exported from your affiliates.ts
await ctx.runMutation(internal.affiliates.attributeSignup, {
userId: args.userId,
affiliateCode: args.referralCode,
});
}
},
});Better Auth Plugin
For projects using Better Auth, we provide dedicated plugins that handle referral tracking and attribution automatically.
Quick Start
// Server: convex/auth.ts
import { affiliatePlugin } from "convex-affiliates/better-auth";
import { components } from "./_generated/api";
export const createAuth = (ctx) => {
return betterAuth({
database: authComponent.adapter(ctx),
plugins: [
affiliatePlugin(ctx, components.affiliates),
],
});
};
// Client: lib/auth-client.ts
import { affiliateClientPlugin } from "convex-affiliates/better-auth/client";
export const authClient = createAuthClient({
plugins: [
affiliateClientPlugin(),
],
});That's it! The plugins handle everything automatically.
How It Works
- Visitor arrives with
?ref=CODEin the URL - Client plugin detects the code and stores it (localStorage + cookie)
- User signs up via Better Auth
- Client plugin injects
referralIdandreferralCodeinto signup request - Server plugin reads referral data and calls component's
attributeSignup - Referral is linked to the new user automatically
Server Plugin Options
affiliatePlugin(ctx, components.affiliates, {
// Custom field names (optional)
fieldNames: {
referralId: "referralId",
referralCode: "referralCode",
},
// Cookie names for SSR support (optional)
cookieName: "affiliate_code",
referralIdCookieName: "affiliate_referral_id",
// Callbacks (optional)
onAttributionSuccess: async ({ userId, affiliateCode }) => {
console.log(`Attributed ${userId} to ${affiliateCode}`);
},
onAttributionFailure: async ({ userId, reason }) => {
console.log(`Attribution failed: ${reason}`);
},
});Client Plugin Options
import { ConvexHttpClient } from "convex/browser";
import { api } from "../convex/_generated/api";
const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
affiliateClientPlugin({
// All options have sensible defaults
storage: "both", // "localStorage" | "cookie" | "both"
cookieDurationDays: 30,
paramName: "ref", // URL param: ?ref=CODE
subIdParamName: "sub", // URL param: ?sub=campaign-1
autoTrack: true, // Auto-detect from URL
clearOnSignup: true, // Clear after successful signup
// Automatic click tracking (recommended)
// Tracks clicks when a referral is detected from URL
trackClick: (args) => convex.mutation(api.affiliates.trackClick, args),
});The trackClick option automatically tracks referral clicks when detected from URL parameters. The mutation receives:
affiliateCode: The affiliate code from the URLlandingPage: The full URL where the visitor landedsubId: Optional sub-tracking ID (if?sub=...is present)
Client Plugin Actions
// Get stored referral
const referral = authClient.affiliate.getStoredReferral();
// Check if referred
if (authClient.affiliate.hasReferral()) {
// Show "Referred by partner" badge
}
// Manual tracking
await authClient.affiliate.trackReferral("PARTNER20");
// Clear referral data
authClient.affiliate.clearReferral();Stripe Plugin
For projects using @convex-dev/stripe, we provide a plugin that automatically handles affiliate tracking for payments.
Quick Start
Step 1: Configure Webhooks
Use withAffiliates to add automatic commission tracking to Stripe webhooks:
// convex/http.ts
import { httpRouter } from "convex/server";
import { registerRoutes } from "@convex-dev/stripe";
import { withAffiliates } from "convex-affiliates/stripe";
import { components } from "./_generated/api";
const http = httpRouter();
// Register Stripe routes with affiliate tracking
registerRoutes(http, components.stripe, withAffiliates(components.affiliates));
export default http;Step 2: Create Checkout Sessions
Use getAffiliateMetadata to get affiliate data for checkout. You must use the Stripe SDK directly and pass client_reference_id for commission tracking to work:
// convex/payments.ts
import { action } from "./_generated/server";
import { v } from "convex/values";
import { getAffiliateMetadata } from "convex-affiliates/stripe";
import { components } from "./_generated/api";
import Stripe from "stripe";
const stripeSDK = new Stripe(process.env.STRIPE_SECRET_KEY!);
export const createCheckout = action({
args: { priceId: v.string() },
handler: async (ctx, { priceId }) => {
// Get affiliate data: { userId, affiliate_code? }
const { userId, ...metadata } = await getAffiliateMetadata(ctx, components.affiliates);
const session = await stripeSDK.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.SITE_URL}/success`,
cancel_url: `${process.env.SITE_URL}/cancel`,
client_reference_id: userId, // REQUIRED for commission tracking!
metadata, // { affiliate_code?: string }
});
return session.url;
},
});The plugin automatically handles:
invoice.paid→ Creates commission for the affiliatecharge.refunded→ Reverses commission on refundcheckout.session.completed→ Links Stripe customer to affiliate
Complete: Better Auth + Stripe
For the full affiliate flow (signup attribution + payment tracking), see our detailed integration guide: docs/better-auth-stripe-integration.md
The complete flow:
- User clicks
yoursite.com?ref=PARTNER20 - Better Auth client plugin stores the code and tracks the click
- User signs up via Better Auth → automatically attributed to affiliate
- User checks out →
getAffiliateMetadata()provides affiliate data - Payment succeeds → webhook creates commission for affiliate
- Refund happens → webhook reverses commission
With Callbacks
Get notified when affiliate events occur:
// convex/http.ts
registerRoutes(http, components.stripe, withAffiliates(components.affiliates, {
onCommissionCreated: async (data) => {
// data: { commissionId, affiliateId, affiliateCode, amountCents, currency }
await notifyAffiliate(data.affiliateId, `You earned $${data.amountCents / 100}!`);
},
onCommissionReversed: async (data) => {
// data: { commissionId, affiliateId, amountCents, reason }
await notifyAffiliate(data.affiliateId, "A commission was reversed.");
},
onCustomerLinked: async (data) => {
// data: { stripeCustomerId, userId, affiliateCode }
console.log(`Customer ${data.stripeCustomerId} linked to affiliate`);
},
}));With Your Own Event Handlers
If you need custom logic alongside affiliate tracking, both handlers run (affiliate first, then yours):
registerRoutes(http, components.stripe, withAffiliates(components.affiliates, {
events: {
// Your handler runs AFTER affiliate commission is created
"invoice.paid": async (ctx, event) => {
await sendSlackNotification(event);
},
},
}));Client-Side Utilities
For client-side storage (if not using Better Auth), use the client utilities:
import {
getStoredReferral,
storeReferral,
hasStoredReferral,
clearStoredReferral,
} from "convex-affiliates/stripe/client";
// Check if user was referred
if (hasStoredReferral()) {
const referral = getStoredReferral();
console.log(`Referred by: ${referral?.affiliateCode}`);
}
// Manually store referral (if not using Better Auth client plugin)
const params = new URLSearchParams(window.location.search);
const code = params.get("ref");
if (code) {
storeReferral({ affiliateCode: code });
}
// Clear after purchase
clearStoredReferral();Legacy: Standalone Handlers
If you're not using @convex-dev/stripe, you can still use the standalone handlers:
import { getAffiliateStripeHandlers } from "convex-affiliates";
// Get handlers for manual integration
const handlers = getAffiliateStripeHandlers(components.affiliates, {
hooks: {
"commission.created": async (data) => { /* ... */ },
},
});Or use the standalone webhook handler with built-in signature verification:
// convex/http.ts
import { httpRouter } from "convex/server";
import { createAffiliateApi } from "convex-affiliates";
import { components } from "./_generated/api";
const http = httpRouter();
const affiliates = createAffiliateApi(components.affiliates, {
auth: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
return identity.subject;
},
});
http.route({
path: "/webhooks/stripe",
method: "POST",
handler: affiliates.createStripeWebhookHandler({
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
}),
});
export default http;Recording Payouts
Record payouts when you pay affiliates (via PayPal, bank transfer, etc.):
// In your admin panel or payout handler
await ctx.runMutation(components.affiliates.payouts.record, {
affiliateId: affiliate._id,
amountCents: 5000, // $50.00
currency: "usd",
method: "paypal", // or "bank_transfer", "manual", "other"
notes: "Monthly payout for December 2024",
});Portal Data
Query affiliate dashboard data from your frontend:
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
function Dashboard() {
const portal = useQuery(api.affiliates.getPortalData);
if (!portal) return <div>Loading...</div>;
return (
<div>
<h1>Welcome, {portal.affiliate.displayName}</h1>
<p>Your code: {portal.affiliate.code}</p>
<p>Total earnings: ${portal.affiliate.stats.paidCommissionsCents / 100}</p>
</div>
);
}Admin Functions
All admin functions are exported and check authorization via your isAdmin callback:
import { useQuery, useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
function AdminDashboard() {
const dashboard = useQuery(api.affiliates.adminDashboard);
const affiliates = useQuery(api.affiliates.adminListAffiliates, { status: "pending" });
const approve = useMutation(api.affiliates.adminApproveAffiliate);
return (
<div>
<h1>Admin Dashboard</h1>
<p>Total affiliates: {dashboard?.totalAffiliates}</p>
<p>Pending approval: {affiliates?.length}</p>
{affiliates?.map((aff) => (
<button key={aff._id} onClick={() => approve({ affiliateId: aff._id })}>
Approve {aff.displayName}
</button>
))}
</div>
);
}React Integration
Since all functions are exported directly from your convex/affiliates.ts, use standard Convex hooks:
Track Referrals on Page Load
// components/ReferralTracker.tsx
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { useEffect, useRef } from "react";
export function ReferralTracker() {
const trackClick = useMutation(api.affiliates.trackClick);
const tracked = useRef(false);
useEffect(() => {
if (tracked.current) return;
const params = new URLSearchParams(window.location.search);
const code = params.get("ref") || params.get("via");
if (code) {
tracked.current = true;
trackClick({
affiliateCode: code,
landingPage: window.location.pathname,
});
}
}, [trackClick]);
return null;
}Storage Configuration
By default, referral data is stored in localStorage. You can switch to cookies (useful for cross-subdomain tracking or server-side access) or use both:
import { createAffiliateHooks } from "convex-affiliates/react";
import { api } from "../convex/_generated/api";
// Cookie storage with cross-subdomain support
const hooks = createAffiliateHooks(api.affiliates, {
storage: "cookie",
cookieOptions: {
domain: ".example.com", // shared across subdomains
maxAge: 30 * 24 * 60 * 60, // 30 days (default)
secure: true, // HTTPS only (default)
sameSite: "lax", // default
},
});
// Dual-write: writes to both, reads cookie-first
const hooks = createAffiliateHooks(api.affiliates, { storage: "both" });The same config can be passed to useTrackReferralOnLoad and useStoredReferral standalone hooks.
Affiliate Portal Example
// components/AffiliatePortal.tsx
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
export function AffiliatePortal() {
const portal = useQuery(api.affiliates.getPortalData);
const commissions = useQuery(api.affiliates.listCommissions, { limit: 10 });
if (!portal) return <div>Loading...</div>;
const formatCents = (cents: number) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
return (
<div>
<h1>Welcome, {portal.affiliate.displayName}</h1>
<p>Your code: <code>{portal.affiliate.code}</code></p>
<div className="stats">
<div>Clicks: {portal.affiliate.stats.totalClicks}</div>
<div>Signups: {portal.affiliate.stats.totalSignups}</div>
<div>Conversions: {portal.affiliate.stats.totalConversions}</div>
<div>Pending: {formatCents(portal.affiliate.stats.pendingCommissionsCents)}</div>
<div>Paid: {formatCents(portal.affiliate.stats.paidCommissionsCents)}</div>
</div>
<h2>Recent Commissions</h2>
{commissions?.page.map((c) => (
<div key={c._id}>
{formatCents(c.commissionAmountCents)} - {c.status}
</div>
))}
</div>
);
}Link Generator
import { useState } from "react";
export function LinkGenerator({ code, baseUrl }: { code: string; baseUrl: string }) {
const [copied, setCopied] = useState(false);
const link = `${baseUrl}?ref=${code}`;
const copy = () => {
navigator.clipboard.writeText(link);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div>
<input value={link} readOnly />
<button onClick={copy}>{copied ? "Copied!" : "Copy"}</button>
</div>
);
}API Reference
createAffiliateApi
function createAffiliateApi(
component: ComponentApi,
config: AffiliateApiConfig
): AffiliateApi;Returns an object with ready-to-export Convex functions:
| Function | Type | Auth | Description |
|----------|------|------|-------------|
| trackClick | mutation | public | Track affiliate link click |
| validateCode | query | public | Validate affiliate code |
| register | mutation | user | Register as affiliate |
| getAffiliate | query | user | Get current user's affiliate |
| getPortalData | query | user | Get dashboard data |
| listCommissions | query | user | List user's commissions |
| listPayouts | query | user | List user's payouts |
| listReferrals | query | user | List user's referrals |
| generateLink | query | user | Generate affiliate link |
| attributeSignup | mutation | user | Attribute signup to referral |
| adminDashboard | query | admin | Admin dashboard stats |
| adminListAffiliates | query | admin | List all affiliates |
| adminTopAffiliates | query | admin | Top performing affiliates |
| adminApproveAffiliate | mutation | admin | Approve affiliate |
| adminRejectAffiliate | mutation | admin | Reject affiliate |
| adminSuspendAffiliate | mutation | admin | Suspend affiliate |
| adminListCampaigns | query | admin | List campaigns |
| adminCreateCampaign | mutation | admin | Create campaign |
Component Mutations (for webhook handlers)
Call these directly via components.affiliates.*:
| Function | Description |
|----------|-------------|
| commissions.createFromInvoice | Create commission from Stripe invoice data |
| commissions.reverseByCharge | Reverse commission on refund |
| referrals.linkStripeCustomer | Link Stripe customer to affiliate referral |
| payouts.record | Record a manual payout |
Configuration
interface AffiliateApiConfig {
// Commission defaults
defaultCommissionType?: "percentage" | "fixed";
defaultCommissionValue?: number; // Percentage (0-100) or cents for fixed
defaultPayoutTerm?: "NET-0" | "NET-15" | "NET-30" | "NET-60" | "NET-90";
minPayoutCents?: number;
defaultCookieDurationDays?: number;
// URLs
baseUrl?: string;
// Authentication callback (required for user functions)
auth: (ctx: { auth: Auth }) => Promise<string>;
// Admin authorization callback (optional, defaults to allowing all)
isAdmin?: (ctx: { auth: Auth }) => Promise<boolean>;
}Campaign Options
interface Campaign {
name: string;
slug: string;
description?: string;
commissionType: "percentage" | "fixed";
commissionValue: number;
payoutTerm: "NET-0" | "NET-15" | "NET-30" | "NET-60" | "NET-90";
cookieDurationDays: number;
// Commission duration for subscriptions
commissionDuration?: "lifetime" | "max_payments" | "max_months";
commissionDurationValue?: number;
// Product restrictions (Stripe product IDs)
allowedProducts?: string[];
excludedProducts?: string[];
}Payout Methods
When recording payouts, use one of these methods:
manual- Generic manual payoutbank_transfer- Bank/wire transferpaypal- PayPal paymentother- Other payment method
Architecture
This component follows a pure data layer pattern:
┌─────────────────────────────────────────────────────────────────┐
│ HOST APP │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Stripe Webhook Handler (your code) │ │
│ │ - Verify webhook signatures │ │
│ │ - Parse events │ │
│ │ - Call component mutations │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ AFFILIATES COMPONENT (Pure Data) │ │
│ │ │ │
│ │ Internal mutations for host app to call: │ │
│ │ - commissions.createFromInvoice(invoiceData) │ │
│ │ - commissions.reverseByCharge(chargeId) │ │
│ │ - referrals.linkStripeCustomer(customerId, code) │ │
│ │ - payouts.record(affiliateId, amount, method) │ │
│ │ │ │
│ │ NO Stripe SDK, NO Node.js runtime dependencies │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘This design:
- Keeps the component lightweight and portable
- Gives you full control over webhook handling and verification
- Works with any payment processor (not just Stripe)
- Allows flexible payout methods (PayPal, bank transfer, crypto, etc.)
Fraud Prevention
The component includes comprehensive fraud prevention measures to protect your affiliate program:
Self-Referral Protection
Affiliates cannot earn commissions on their own purchases. This is enforced at multiple levels:
- Signup Attribution:
attributeSignupandattributeSignupByCodeblock attempts where the signing-up user matches the affiliate's userId - Stripe Customer Linking:
linkStripeCustomerblocks self-referral when linking customers to affiliates - Commission Creation:
createFromInvoicerejects commissions where the referral's userId matches the affiliate
Attribution Security
- First-Touch Attribution: Once a user is attributed to an affiliate, they cannot be re-attributed to a different affiliate (prevents affiliate code switching)
- Authenticated Attribution Only: Affiliate code attribution via
linkStripeCustomerrequires a userId - guest checkout cannot use affiliate codes to prevent anonymous self-referral - Webhook Attribution Disabled: The
createFromInvoicewebhook handler does not create new referrals via affiliate codes - all attribution must happen through the authenticated frontend flow (trackClick→attributeSignup→linkStripeCustomer)
Click Velocity Limiting
IP-based rate limiting prevents click fraud using @convex-dev/rate-limiter:
// Configurable per campaign (default: 10 clicks per IP per hour)
maxClicksPerIpPerHour: 10Silent Rejection
All fraud prevention checks silently reject suspicious activity (returning null or { success: false }) without throwing errors. This prevents attackers from learning detection logic through error messages.
Troubleshooting
TypeScript type errors with components.affiliates
If you get type errors when passing components.affiliates to createAffiliateApi, you can use a type assertion:
import { createAffiliateApi, ComponentApi } from "convex-affiliates";
const affiliates = createAffiliateApi(
components.affiliates as unknown as ComponentApi,
{ ... }
);This may be needed if your TypeScript configuration differs from the component's.
Authentication with different providers
The auth callback receives the full Convex context, but the type only shows { auth: Auth }. The recommended pattern works with any Convex-compatible auth provider:
auth: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
return identity.subject; // User ID from JWT token
},For Better Auth or other providers, the identity.subject contains the user ID from your auth provider's JWT token.
Common Recipes
Complete Referral Flow (Track → Store → Attribute)
Using the built-in React hooks with configurable storage:
import { createAffiliateHooks, useTrackReferralOnLoad, useStoredReferral } from "convex-affiliates/react";
import { api } from "../convex/_generated/api";
// Choose storage mode: "localStorage" (default), "cookie", or "both"
const storageConfig = {
storage: "cookie" as const,
cookieOptions: { domain: ".example.com", maxAge: 30 * 24 * 60 * 60 },
};
const hooks = createAffiliateHooks(api.affiliates, storageConfig);
// 1. Track referral on landing — automatically reads ?ref= and stores the referral ID
function ReferralTracker() {
const trackReferral = hooks.useTrackReferral();
useTrackReferralOnLoad(trackReferral, storageConfig);
return null;
}
// 2. Attribute after signup (in your auth callback)
function useAttributeOnSignup(userId: string) {
const { referralId, code, clear } = useStoredReferral(storageConfig);
const attributeSignup = useMutation(api.affiliates.attributeSignup);
useEffect(() => {
if (!userId || (!referralId && !code)) return;
attributeSignup({
userId,
referralId: referralId ?? undefined,
referralCode: code ?? undefined,
}).then(() => clear());
}, [userId]);
}Two-Sided Rewards (Affiliate Coupon Codes)
Two-sided rewards let affiliates offer discounts to referred customers (e.g., "Get 10% off with code JOHN20") while earning their commission. This creates a win-win: customers get a discount, affiliates get credit for the sale.
How it works:
- You configure a discount at the campaign level (all affiliates in that campaign offer the same discount)
- When a referred customer checks out, you query for their discount
- You apply the discount to their Stripe checkout session
Step 1: Create a Stripe Coupon (Optional but Recommended)
If you want Stripe to handle the discount calculation and display, create a coupon in Stripe first:
# Using Stripe CLI
stripe coupons create \
--percent-off=10 \
--duration=once \
--id="AFFILIATE_10_PERCENT"
# Or for a fixed amount discount
stripe coupons create \
--amount-off=500 \
--currency=usd \
--duration=once \
--id="AFFILIATE_5_OFF"Or create via the Stripe Dashboard → Products → Coupons → Create coupon.
Note: The coupon ID (e.g.,
AFFILIATE_10_PERCENT) is what you'll store in the campaign configuration.
Step 2: Configure Campaign with Discount Settings
When creating or updating a campaign, include the discount configuration:
// Creating a new campaign with discount
await ctx.runMutation(api.affiliates.adminCreateCampaign, {
name: "Partner Program",
slug: "partners",
commissionType: "percentage",
commissionValue: 20, // Affiliates earn 20% commission
// Two-sided rewards configuration
refereeDiscountType: "percentage", // "percentage" or "fixed"
refereeDiscountValue: 10, // 10% off for referred customers
refereeStripeCouponId: "AFFILIATE_10_PERCENT", // Optional: pre-created Stripe coupon
});
// Or for a fixed discount
await ctx.runMutation(api.affiliates.adminCreateCampaign, {
name: "Influencer Program",
slug: "influencers",
commissionType: "fixed",
commissionValue: 500, // Affiliates earn $5.00 per sale
// $5 off for referred customers
refereeDiscountType: "fixed",
refereeDiscountValue: 500, // 500 cents = $5.00
refereeStripeCouponId: "AFFILIATE_5_OFF",
});Discount fields:
| Field | Type | Description |
|-------|------|-------------|
| refereeDiscountType | "percentage" | "fixed" | How the discount is calculated |
| refereeDiscountValue | number | Percentage (0-100) or cents for fixed |
| refereeStripeCouponId | string (optional) | Pre-created Stripe coupon ID |
Step 3: Apply Discount at Checkout
With @convex-dev/stripe (Recommended)
If you're using @convex-dev/stripe, here's the complete flow:
// convex/checkout.ts
import { mutation } from "./_generated/server";
import { api, components } from "./_generated/api";
import { v } from "convex/values";
export const createCheckoutSession = mutation({
args: {
priceId: v.string(),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const userId = identity.subject;
// 1. Get discount for referred customer
const discount = await ctx.runQuery(api.affiliates.getRefereeDiscount, {
userId,
});
// 2. Build checkout session config
const sessionConfig: Parameters<typeof components.stripe.checkout.createSession>[1] = {
line_items: [{ price: args.priceId, quantity: 1 }],
mode: "payment",
success_url: `${process.env.BASE_URL}/success`,
cancel_url: `${process.env.BASE_URL}/cancel`,
};
// 3. Apply discount if available
if (discount?.stripeCouponId) {
// Use the pre-configured Stripe coupon
sessionConfig.discounts = [{ coupon: discount.stripeCouponId }];
}
// 4. Create the checkout session
const session = await ctx.runAction(components.stripe.checkout.createSession, sessionConfig);
return { url: session.url };
},
});Without a Stripe Coupon (Manual Calculation)
If you prefer to calculate discounts manually without a pre-configured Stripe coupon:
// convex/checkout.ts
export const createCheckoutSession = mutation({
args: {
priceId: v.string(),
subtotalCents: v.number(),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const userId = identity.subject;
// 1. Get discount for referred customer
const discount = await ctx.runQuery(api.affiliates.getRefereeDiscount, {
userId,
});
// 2. Calculate discount amount
let discountAmountCents = 0;
if (discount) {
discountAmountCents = discount.discountType === "percentage"
? Math.round((args.subtotalCents * discount.discountValue) / 100)
: discount.discountValue;
}
// 3. Create a one-time coupon in Stripe (if discount applies)
let couponId: string | undefined;
if (discountAmountCents > 0) {
const coupon = await stripe.coupons.create({
amount_off: discountAmountCents,
currency: "usd",
duration: "once",
name: `Referral discount from ${discount?.affiliateDisplayName || discount?.affiliateCode}`,
});
couponId = coupon.id;
}
// 4. Create checkout session with discount
const session = await stripe.checkout.sessions.create({
line_items: [{ price: args.priceId, quantity: 1 }],
mode: "payment",
success_url: `${process.env.BASE_URL}/success`,
cancel_url: `${process.env.BASE_URL}/cancel`,
...(couponId && { discounts: [{ coupon: couponId }] }),
});
return { url: session.url };
},
});Displaying the Discount to Users
Show users their available discount before checkout:
// components/CheckoutButton.tsx
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
export function CheckoutButton({ userId }: { userId: string }) {
const discount = useQuery(api.affiliates.getRefereeDiscount, { userId });
return (
<div>
{discount && (
<div className="discount-banner">
{discount.discountType === "percentage"
? `${discount.discountValue}% off`
: `$${(discount.discountValue / 100).toFixed(2)} off`}
{" "}with code {discount.affiliateCode}!
</div>
)}
<button onClick={handleCheckout}>
Proceed to Checkout
</button>
</div>
);
}getRefereeDiscount Response
The getRefereeDiscount query returns:
{
discountType: "percentage" | "fixed",
discountValue: number, // Percentage (0-100) or cents
stripeCouponId?: string, // Pre-configured Stripe coupon ID
affiliateCode: string, // e.g., "JOHN20"
affiliateDisplayName?: string, // e.g., "John's Deals"
}
// Returns null if no discount is availableYou can query by any of these parameters:
userId- The referred customer's user IDreferralId- The referral tracking ID from localStorageaffiliateCode- The affiliate's code directly
Troubleshooting Discounts
Discount returns null:
| Issue | Solution |
|-------|----------|
| Affiliate not approved | Ensure affiliate status is "approved" via adminApproveAffiliate |
| Campaign inactive | Ensure campaign isActive is true |
| No discount configured | Set refereeDiscountType and refereeDiscountValue on the campaign |
| Referral expired | Discount expires after cookieDurationDays (default: 30 days) |
| User not attributed | Ensure attributeSignup was called after the user signed up |
Stripe coupon not applying:
| Issue | Solution |
|-------|----------|
| Invalid coupon ID | Verify the coupon exists in Stripe Dashboard → Products → Coupons |
| Coupon expired | Check the coupon's redeem_by date in Stripe |
| Coupon restrictions | Check if the coupon has product/price restrictions in Stripe |
| Wrong mode | Ensure coupon duration matches checkout mode (one-time vs subscription) |
Debug checklist:
// 1. Check if user has a referral
const referral = await ctx.runQuery(api.affiliates.getRefereeDiscount, { userId });
console.log("Referral discount:", referral);
// 2. If null, check the user's referral directly
const referrals = await ctx.runQuery(components.affiliates.referrals.listByUser, { userId });
console.log("User referrals:", referrals);
// 3. Check the affiliate's status
const affiliate = await ctx.runQuery(components.affiliates.affiliates.getByCode, {
code: "AFFILIATE_CODE"
});
console.log("Affiliate:", affiliate?.status);
// 4. Check the campaign's discount config
const campaign = await ctx.runQuery(components.affiliates.campaigns.get, {
campaignId: affiliate?.campaignId
});
console.log("Campaign discount:", {
type: campaign?.refereeDiscountType,
value: campaign?.refereeDiscountValue,
couponId: campaign?.refereeStripeCouponId,
});Batch Admin Operations
// Approve all pending affiliates
const pending = await ctx.runQuery(api.affiliates.adminListAffiliates, {
status: "pending",
});
for (const affiliate of pending) {
await adminApproveAffiliate({ affiliateId: affiliate._id });
}Local Development
# Clone and install
git clone https://github.com/your-org/convex-affiliates
cd convex-affiliates
npm install
# Run development (backend + example app)
npm run dev
# Run tests
npm run test
# Build (automatically cleans dist/ first)
npm run buildLicense
Apache-2.0
Contributing
See CONTRIBUTING.md for development setup and guidelines.
