tellatribe

Enter the access code to read the project documents.

tellatribe Docs Status: All modules built and live · Oct 19: Creator Campaigns Part B shipped (briefs, applications, deals, verification → bounties, brand balance + Stripe funding, disputes, reports) on top of the tax-identity gate and annual statements · awaiting owner inputs: tellatribe Stripe account (payments + Connect + funding webhook), HealthyApp webhook secret, Ayrshare key

Reference · packages/sdk/README.md

@affiliate-coach/sdk

What a product installs to join tellatribe. Three calls (PRD §12), no client-side script, no pixel, no React. Zero runtime dependencies: fetch, AbortController, and Web Crypto come from Node 18+ / the Vercel runtimes. next is a peer only for the /next entry.

@affiliate-coach/sdk        AffiliateCoach, AffiliateCoachError, stripeCheckoutFields,
                            stripeCheckoutFormFields, validateDestination, cookie constants
@affiliate-coach/sdk/next   GET, createRedirectHandler, getClickId, getRefCode,
                            stripeCheckoutFields (re-exported)

Env read by the zero-config route: AFFILIATE_COACH_API_KEY (required), AFFILIATE_COACH_PRODUCT (the product slug), AFFILIATE_COACH_API_URL (optional; defaults to https://api.tellatribe.com).

1. The redirect route

// app/go/[code]/route.ts
export { GET } from '@affiliate-coach/sdk/next'

GET /go/JWHIST?to=/pricing&c=newsletter records the click server-side, sets two first-party cookies on the product's domain, and 302s to /pricing. The route lives on the product's domain so the cookies are first-party (PRD §6.2) and the click is written from the server, where an ad blocker cannot reach it.

Options, when the defaults are wrong for a product:

import { createRedirectHandler } from '@affiliate-coach/sdk/next'
export const GET = createRedirectHandler({
  cookieWindowDays: 90,          // the program's attribution window; default 90
  cookieDomain: 'example.com',   // only if app.example.com and example.com are separate hosts
  defaultDestination: '/',       // where a bad or missing `to` lands
  paramName: 'code',             // the dynamic segment's name
  onError: (err, ctx) => log(err, ctx),   // default: console.warn
})

HealthyApp note on the path. HealthyApp already serves app/r/[token]/page.tsx (a coach's public self-register link). Next refuses two dynamic segment names in one folder, so the affiliate route cannot be app/r/[code]/route.ts there. Mount it at app/go/[code]/route.ts (or any free folder); the link builder in the portal takes the prefix from the product record.

What the route guarantees

  • A dead click is never a dead link. Bad code, bad destination, API 404, API 500, timeout (800 ms, no retry), network failure, missing API key: the visitor is redirected every time.
  • ac_ref is set before the API is called. If the click API fails, ac_click is absent but ac_ref still carries the code, and the conversion attributes by refCode. The click is lost; the sale is not.
  • Open-redirect guard. to must be a same-origin path: starts with one /, never //, no \, no @, no scheme, ≤ 512 chars. Anything else falls back to defaultDestination. The Location header is built against the request's own origin.
  • A malformed code redirects with no cookie at all. A malformed campaign label is dropped, not fatal.
  • Cache-Control: no-store; Referrer-Policy: strict-origin-when-cross-origin.

Cookies

name value lifetime flags
ac_click click id cookieWindowDays (default 90) HttpOnly; Secure; SameSite=Lax; Path=/
ac_ref referral code same same

Both are HttpOnly. The only reader is the product's own server at checkout (getClickId() uses cookies() from next/headers), so nothing in the browser needs the value — and a value no script can read is one no injected script can harvest, replay, or overwrite to steer attribution. SameSite=Lax is the setting that survives a top-level navigation arriving from Instagram or a newsletter. A later click overwrites both cookies: last click wins (PRD §6.5).

2. At checkout

import { getClickId, stripeCheckoutFields } from '@affiliate-coach/sdk/next'

const attribution = await getClickId()           // { clickId, refCode } — either may be null
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  ...stripeCheckoutFields({ ...attribution, product: 'healthyapp' }),
  // metadata: { ac_click_id, ac_ref, ac_product }
  // subscription_data: { metadata: { same } }   ← what makes month 2+ attributable
  ...
})

If the product already passes its own metadata, merge instead of spreading: metadata: { ...own, ...fields.metadata }. Pass { mode: 'payment' } for a one-time session; Stripe rejects subscription_data there.

For products that call Stripe's REST API with form-encoded bodies — HealthyApp's lib/billing/checkout.ts writes "metadata[org_id]" this way — use the flat form:

import { stripeCheckoutFormFields } from '@affiliate-coach/sdk'
const form = { ...ownForm, ...stripeCheckoutFormFields({ ...attribution, product: 'healthyapp' }) }
// "metadata[ac_click_id]", "subscription_data[metadata][ac_click_id]", …

The SDK never sets client_reference_id. HealthyApp's Connect checkout (lib/billing/connect-checkout.ts) already uses it for the coach's client id. A field with two owners is how a tenant's client id ends up looked up as a click, so the carrier is metadata.ac_click_id; the API accepts a UUID client_reference_id only as a guarded fallback for products that set it themselves (BUILD-PLAN §2, track 01 §4.3).

3. Manual conversion (only when not on the Stripe webhook path)

import { AffiliateCoach } from '@affiliate-coach/sdk'

const ac = new AffiliateCoach({ apiKey: process.env.AFFILIATE_COACH_API_KEY!, product: 'healthyapp' })

const result = await ac.convert({
  clickId, refCode,                 // one of these; clickId preferred
  customerRef: 'cus_…',             // the merchant's stable customer id
  event: 'subscription_created',    // purchase | subscription_created | subscription_renewed
  amountCents: 9900,                // net of tax and discounts, > 0
  idempotencyKey: 'evt_…',          // required; replays are no-ops
  customerEmail: 'a@b.com',         // optional; SHA-256 hashed before it leaves the process
  occurredAt: new Date(),           // optional; default now
})

switch (result.outcome) {
  case 'recorded':     result.conversionId; result.commissionIds
  case 'duplicate':    result.conversionId
  case 'unattributed': result.reason           // outside_window | unknown_click | …
  case 'error':        log(result.error.code, result.error.status)   // never thrown
}

convert() never throws. It sits on the checkout and webhook path, and a product should log a lost commission rather than fail a paying customer. 5xx and network failures retry three times with jitter under the same Idempotency-Key; 4xx never retries. The result is { outcome: 'error', error: AffiliateCoachError } with the server's error.code when there was a response, or network_error / timeout / invalid_response when there was not.

recordClick() does throw (AffiliateCoachError with .code and .status); its only caller is the redirect handler, which catches and redirects regardless.

Rendered from packages/sdk/README.md. Edit the source, not this page.