Docs/Getting Started

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" }, "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.

Affiliate payout setup is handled separately from invite acceptance. Once a partner accepts, Ezra texts the affiliate a "complete payout setup within 7 days" follow-up to gather their payout details.

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