Overview
Brands often have existing affiliate relationships from before launching with Trcker — friends, past customers, agency contacts, creators they already work with on other platforms. Rather than waiting for them to apply through a public partner-application form, brands can invite them directly through Ezra:
> Brand says to Ezra: "Invite Mike at mike@gmail.com and Sarah at +15551234567."
Ezra calls Trcker's invite endpoint, generates one-time tokens, and delivers each invite via the recipient's preferred channel (SMS, email, iMessage). The affiliate opts in via a landing page, accepts, and a real partners row materializes — linked to the brand's offer, with a tracking link issued.
Critical: affiliates must opt in. Brands cannot unilaterally enroll someone who didn't consent. The pending_publishers table stores invitations until acceptance; only then does a real partner exist.
Endpoints
POST /api/public/publishers/invite
Service-key authenticated. Called by Ezra when a brand wants to invite affiliates they already know. Idempotent on (brandId, email) or (brandId, phone) for status='pending' rows — retries return the same token.
Request body:
`json
{
"brandId": "uuid",
"offerId": "uuid",
"invites": [
{ "name": "Mike Lifts", "email": "mike@gmail.com", "personalNote": "Hey Mike — want you on the program. Sarah" },
{ "name": "Sarah K", "phone": "+15551234567" }
],
"channelUsed": "sms",
"invitedByLabel": "Sarah Chen (brand)"
}
`
offerIdis optional. Defaults to the brand's first offer.- Each invite must have at least one of
emailorphone. phonemust be E.164 format (e.g.+15551234567).- Up to 200 invites per request.
personalNoteis shown on the invite landing page (max 500 chars).channelUsedandinvitedByLabelare recorded for audit but don't affect delivery — Ezra is responsible for delivering the invite.
Response (201 Created):
`json
{
"data": {
"brandId": "uuid",
"brandSlug": "bedrock-fitness",
"offerId": "uuid",
"created": 2,
"reused": 0,
"failed": 0,
"invites": [
{
"name": "Mike Lifts",
"email": "mike@gmail.com",
"phone": null,
"token": "x9k2q4abc...",
"inviteUrl": "https://trcker.io/affiliate-invite/x9k2q4abc...",
"reused": false
}
],
"errors": []
}
}
`
Ezra surfaces each inviteUrl to the relevant affiliate via the channel they prefer.
GET /api/public/publishers/invite/[token]
Public endpoint (no auth — token is the secret). Returns invite details for the landing page.
Response includes: brand name + domain, offer payout terms, personal note, the invitee's name (so they can confirm "yes, this is for me"). Does not reveal: brand internal config, fraud rules, other partners, brand API key.
Returns:
- 404 if token doesn't match
- 410 if expired (14-day TTL), cancelled, or already accepted
POST /api/public/publishers/accept
Public endpoint. The landing page POSTs here when the affiliate taps "Accept."
Request body:
`json
{
"token": "x9k2q4abc...",
"displayName": "Michael Lifts (correction)",
"email": "mike@example.com"
}
`
displayName and email are optional overrides if the affiliate wants to correct what the brand entered.
Response (201 Created):
`json
{
"data": {
"alreadyAccepted": false,
"reusedExistingPartner": false,
"partner": {
"id": "uuid",
"slug": "mike-lifts",
"name": "Mike Lifts",
"email": "mike@example.com"
},
"stripeConnectOnboardingUrl": null,
"trackingLinkPath": "/r/bedrock-fitness/mike-lifts",
"message": "Welcome aboard. Your tracking link is live."
}
}
`
Idempotent on double-tap: posting the same token twice returns the existing partner with alreadyAccepted: true.
Idempotent on (brand, email): if a partner with this email already exists for this brand (e.g. from /apply or an earlier invite), the new invite links to the existing partner instead of creating a duplicate. The response includes reusedExistingPartner: true so callers can distinguish "fresh signup" from "linked to existing." Mirrors the behavior of the /apply/[brandSlug] endpoint.
stripeConnectOnboardingUrl is currently null — Stripe Connect onboarding is generated as a follow-up flow (Sprint 1.5). For now, Ezra texts the affiliate a "complete payout setup within 7 days" follow-up.
The landing page (/affiliate-invite/[token])
Server-rendered Next.js page hosted on trcker.io. Renders:
- Brand name + domain at the top
- Offer name + payout summary (e.g. "$40 per first-time customer")
- The brand's personal note (if any), styled as a blockquote
- "What you get" bullet list (direct payouts, tracking link, free for affiliates, etc.)
- Form: confirm name (pre-filled from invite), confirm email if invite was phone-only
- "Accept and get my tracking link →" CTA
Renders gracefully when:
- Invite not found (/affiliate-invite/notarealtoken) — friendly "not found" page
- Invite expired — explains 14-day TTL, suggests asking brand to resend
- Invite cancelled — tells the affiliate the brand cancelled
- Already accepted — links to the brand's Trcker overview
How invites get delivered
Trcker just generates the tokens. Ezra is responsible for delivery:
| Affiliate has | Ezra sends invite via |
|---|---|
| Phone number (US iPhone) | iMessage with link + brief context |
| Phone number (other) | SMS via Twilio |
| Email only | Email from Ezra (Composio Gmail), from partnerships@trcker.io |
| Both | Whichever Ezra detects works first; can specify preference per-brand |
The affiliate's preferred channel is their choice — once they accept, they tell Ezra how they want to be reached going forward.
Lifecycle
| Status | Meaning | Transitions |
|---|---|---|
| pending | Invite sent, affiliate hasn't acted | → accepted (acceptance), → expired (TTL), → cancelled (brand action) |
| accepted | Affiliate accepted; real partners row exists | terminal (re-invite is a fresh row) |
| expired | 14 days passed without acceptance | terminal |
| cancelled | Brand pulled the invite before acceptance | terminal |
A nightly cron (Sprint 1.5) flips pending rows past expires_at to expired.
Audit trail
Every invite records:
- Who sent it (invited_by_label)
- When (created_at)
- Which channel (channel_used)
- Personal note (personal_note)
Available via brand admin dashboard at /brands/[slug]/affiliates/invites (Sprint 1.5).
Operational notes
- Token format: 22-char URL-safe base64 from 16 random bytes. Collision probability at 1B tokens: ~1.4e-13. Not signed (the token IS the secret).
- Token storage: plaintext in
pending_publishers.token, indexed for O(1) lookup. Single-use after acceptance. - Idempotency boundary: brand + email OR brand + phone. If a brand sends the same email a second time while the first is still
pending, returns the existing token. Doesn't span statuses (re-invite after expiration creates a fresh row). - Privacy: the GET
/invite/[token]endpoint reveals only what the invitee is entitled to know. Defense in depth — even if a token leaks, it doesn't expose brand internals.
Compliance
This flow is designed around the affiliate's explicit consent. Brand cannot enroll someone who didn't tap "Accept." This matches:
- FTC affiliate disclosure rules (the affiliate must consciously enroll before receiving a tracking link they could promote)
- Standard ToS for affiliate networks (Impact, ShareASale all require explicit affiliate signup)
- GDPR/CCPA — the
pending_publishersrow contains contact info collected with the brand's stated business purpose; deletable on request via standard data-rights flow