Overview
When an affiliate accepts an invite to a brand's program, they need a way to receive payouts. Trcker uses Stripe Connect Express accounts:
- Stripe owns all KYC, identity verification, and bank linking — we never see SSN, bank info, or tax forms
- Affiliates onboard via Stripe's hosted flow — one tap from Ezra, ~90 seconds end-to-end
- Brand transfers funds directly to the affiliate's connected Stripe account (we don't custody funds — affiliates are paid by the brand, not by us)
- Tax reporting (1099-NEC for US payouts ≥ $600/year) handled by Stripe
Onboarding flow
Step 1 — Affiliate accepts invite
(See Inviting Known Affiliates) Once they tap "Accept" on the landing page, a real partners row materializes for them.
Step 2 — Ezra requests an onboarding link
`
POST /api/public/publishers/stripe-onboard
Authorization: Bearer
{
"partnerId": "uuid",
"country": "US",
"returnUrl": "https://textezra.com/setup-done",
"refreshUrl": "https://textezra.com/setup-resume"
}
`
- On first call: Trcker creates a Stripe Express account, stores its ID on the partner row, generates a fresh Account Link
- On subsequent calls: reuses the existing account, generates a fresh Account Link (Stripe's Account Links expire after ~15 minutes)
countrydefaults toUS. Affiliates in other countries: pass the ISO 3166-1 alpha-2 code (e.g.GB,CA)returnUrlandrefreshUrlare optional — defaults to Trcker-hosted confirmation pages if Ezra doesn't override
Response (201 Created on first call, 200 OK on reuse):
`json
{
"data": {
"onboardingUrl": "https://connect.stripe.com/setup/abc123",
"stripeAccountId": "acct_1234ABCD",
"createdAccount": true,
"expiresAt": 1715000900
}
}
`
Step 3 — Affiliate completes onboarding
Ezra texts them: "Tap to complete payout setup, takes about 90 seconds — connect.stripe.com/setup/abc123"
The affiliate enters their details on Stripe's hosted flow (legal name, DOB, bank account or debit card, SSN last 4). Stripe handles KYC.
Step 4 — Stripe fires account.updated webhook
Once the affiliate finishes, Stripe sends an account.updated event to:
`
POST /api/webhooks/stripe
Stripe-Signature: t=`
Trcker:
1. Verifies the Stripe signature (HMAC-SHA256 over ${timestamp}.${rawBody}, with 5-minute replay tolerance)
2. Re-fetches the account from Stripe (defense in depth — webhook payloads can be stale)
3. Updates the partner row's stripe_onboarding_complete, stripe_charges_enabled, stripe_payouts_enabled flags
Step 5 — Brand can now pay out
A partner is ready for payouts when:
`sql
stripe_onboarding_complete = true
AND stripe_payouts_enabled = true
AND stripe_account_id IS NOT NULL
`
The payouts engine (existing Trcker infrastructure) checks these before initiating a Transfer to the affiliate's Stripe account.
Schema
Migration 0038 adds these columns to partners:
| Column | Type | Purpose |
|---|---|---|
| stripe_account_id | varchar(64), nullable, unique | Stripe Express account ID (acct_*). Null until first onboarding call. |
| stripe_onboarding_complete | boolean, default false | Mirrors Stripe's details_submitted |
| stripe_charges_enabled | boolean, default false | Mirrors Stripe's charges_enabled |
| stripe_payouts_enabled | boolean, default false | Mirrors Stripe's payouts_enabled |
| stripe_onboarded_at | timestamptz, nullable | First time details_submitted flipped true |
Required env vars
Set in Vercel before deploying:
| Var | Source | Purpose |
|---|---|---|
| STRIPE_SECRET_KEY | Stripe Dashboard → Developers → API keys | Server-side Stripe API auth |
| STRIPE_WEBHOOK_SECRET | Stripe Dashboard → Developers → Webhooks → endpoint signing secret | Signature verification |
| EZRA_SERVICE_KEY | (already set by PR #212) | Onboarding endpoint auth |
After deploy: create the webhook endpoint in Stripe Dashboard at:
`
https://trcker.io/api/webhooks/stripe
`
Subscribe to event: account.updated. Copy the signing secret into STRIPE_WEBHOOK_SECRET.
Why Express (not Custom or Standard)?
- Standard accounts: affiliate has their own Stripe account, separate dashboard, full Stripe relationship. Too heavy for affiliates.
- Custom accounts: we own all of Stripe's UI — we'd build the onboarding form, handle compliance, etc. Heavy compliance lift, defeats the point.
- Express (chosen): Stripe handles the onboarding UI + KYC + dashboard, but presents it as part of our flow. Affiliate sees a co-branded Stripe page, comes back to us when done. Sweet spot.
Operational notes
- Webhook signature replay protection: 5-minute tolerance. Events older than that are rejected.
- Defense in depth on webhook: we re-fetch the account from Stripe rather than trusting the payload. Catches race conditions where the webhook arrives before the API state settles.
- Account-link expiry: ~15 minutes. If the affiliate doesn't finish in time, Ezra can regenerate via the same endpoint — same account, fresh link.
- Country support: Stripe Connect Express supports 40+ countries. For unsupported countries, Trcker should fall back to Wise transfers (Sprint 1.5+).
- Test vs live: Stripe's test mode (
sk_test_keys) createsacct_test_accounts. These can't actually receive transfers. Production needs live keys. - Account deletion: if a brand archives the partner, we should also delete the Stripe Connect account via
DELETE /v1/accounts/:id. Sprint 1.5+.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| 503 on /stripe-onboard | STRIPE_SECRET_KEY env var missing | Set in Vercel |
| 401 on webhook | Signature mismatch — wrong STRIPE_WEBHOOK_SECRET | Check Stripe Dashboard → Webhooks → endpoint → signing secret |
| Webhook 200 but partner_not_found | Stripe account exists but no Trcker partner has that stripe_account_id | Usually a test/live key mismatch. Confirm the account was created with the same Stripe mode |
| account.updated fires repeatedly with same state | Normal — Stripe emits these on most account changes, even no-op | Idempotent; safe to handle multiple times |
| Affiliate finishes onboarding but stripe_payouts_enabled stays false | Stripe is still reviewing their account (can take minutes to hours for some countries) | Wait — webhook will fire again when they enable |