@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_refis set before the API is called. If the click API fails,ac_clickis absent butac_refstill carries the code, and the conversion attributes byrefCode. The click is lost; the sale is not.- Open-redirect guard.
tomust be a same-origin path: starts with one/, never//, no\, no@, no scheme, ≤ 512 chars. Anything else falls back todefaultDestination. TheLocationheader is built against the request's own origin. - A malformed
coderedirects 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.