Home/Docs/Inviting Known Affiliates
Docs

Inviting Known Affiliates

Brands can invite affiliates they already know directly via Ezra (paste contact info, CSV import). Trcker generates an invite token, the affiliate opts in via /affiliate-invite/[token], and a real partner record materializes only after consent.

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)" } `

  • offerId is optional. Defaults to the brand's first offer.
  • Each invite must have at least one of email or phone.
  • phone must be E.164 format (e.g. +15551234567).
  • Up to 200 invites per request.
  • personalNote is shown on the invite landing page (max 500 chars).
  • channelUsed and invitedByLabel are 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_publishers row contains contact info collected with the brand's stated business purpose; deletable on request via standard data-rights flow