Errors & Troubleshooting

All API error codes, their causes, and how to fix common integration problems.

Beta — The Affitor SDKs and CLI are in active development. The documented happy-path works; edge cases may change. Report issues on GitHub.

This page covers every error response the tracking, readiness and CLI endpoints can return, and practical fixes for the most common integration issues.


HTTP status codes

Start from the status code. It tells you whose problem the request is and whether sending it again can help.

StatusMeaningWhat to doRetry?
400The request is invalid: a required field is missing or malformed, or an identifier resolved to nothing usableFix the request using error.message, then send it again. Exception: after partnerId required to create commission the sale is already stored, so a resend returns 409Not unchanged
401No usable credential: the Authorization header is missing or malformed, the key or login token matches nothing, or the key was retiredSend a valid program API key, or run affitor login again for the account-session CLI routesNot until the credential changes
403The credential is valid but not for this request: a program_id that belongs to another program, or an advertiser_id that is not yours. The CLI login callback also answers 403 ACCOUNT_BLOCKEDUse the key or account that owns the resourceNo
404The thing the request points at does not exist: a sale to refund, or a CLI login sessionCheck the identifier you sentNot unchanged
409The sale was already recorded under this transaction_idTreat it as success: the sale existsNo
410The endpoint is retired, or (POST /api/v1/track/click) the referral link is turned off: error.code is link_disabled and no click was recordedFor a retired endpoint, use the replacement named in error.message. For link_disabled, stop sending clicks for that link; it works again once the partner or brand turns it back onNo
429Rate limited. The per-minute limit records nothing; the verification-run cap may already have prepared its test fixture (see Rate limits)Wait Retry-After seconds, then retryYes, after the wait
500Something failed on our sideRetry with backoff. If it persists, contact support with the request IDYes, with backoff (see server errors for the two cases to check first)

Error response shape

error is always an object, never a plain string. Interpolating body.error straight into a message prints [object Object] — read a field off it.

There are two envelopes. Which one you get depends on what rejected the request, not only on which endpoint you called — the tracking endpoints can return either.

The platform envelope

Validation, auth, and conflict errors raised by the tracking and CLI endpoints themselves come back like this. Read error.message:

{
  "data": null,
  "error": {
    "status": 400,
    "name": "BadRequestError",
    "message": "transaction_id is required and must be a string",
    "details": {}
  }
}

data is null on every error in this envelope. error.status repeats the HTTP status, and error.name is the error class for that status — BadRequestError (400), UnauthorizedError (401), ForbiddenError (403), NotFoundError (404), ConflictError (409), GoneError (410), InternalServerError (500). error.details is an empty object unless noted below.

Most errors in this envelope carry no stable machine code, so match on the HTTP status and, where you need the specific cause, on the error.message strings listed below. There are two exceptions, both in error.details:

  • A suspended account signing in to the CLI gets error.details.code set to ACCOUNT_BLOCKED.
  • A CLI 500 carries error.details.request_id — see request IDs.

The typed envelope

Four things answer with a typed envelope instead. It has no data key, and it carries a stable error.code you can branch on:

{
  "error": {
    "code": "invalid_key",
    "message": "Invalid API token."
  }
}

You get it from:

SourceWhereCode
GET /api/v1/programs/me/readinessall of its errorsinvalid_key on a 401, internal_error on a 500
Rate limitingevery /api/v1/track/*, /api/v1/cli/* and readiness routerate_limited
A retired API keyevery endpoint that key authenticatesapi_key_rotation_required
POST /api/v1/track/clicka 410 for an inactive referral linklink_disabled (the body also keeps the older top-level success: false and message)

Only code and message are always present. The rest varies with the case: a rate limit adds status and retry_after_seconds, a retired key adds hint and docs_url, link_disabled adds hint, and a readiness internal_error adds retry_after_seconds.

Handling both

Read error.code first and fall back to error.message when it is absent:

const body = await response.json();
const detail = body.error?.code
  ? `${body.error.code}: ${body.error.message}`
  : body.error?.message;

Request IDs

Responses from the tracking, readiness and CLI endpoints carry an X-Request-ID header, except GET /api/v1/cli/status: its 200, 401 and 429 have no request ID, and only its 500 does.

On a 500 from a CLI endpoint the same ID is also in the body, both at the end of error.message and in error.details.request_id. Tracking and readiness 500s carry it only in the header.

When you contact support about a failed request, include the X-Request-ID. It is how we find the log line for your request; the error message itself is deliberately generic.


Error reference

Authentication errors (401)

StatusError messageConditionFix
401Missing or malformed Authorization header. Expected: Bearer <token>Authorization header missing or does not start with Bearer Add Authorization: Bearer YOUR_PROGRAM_API_KEY to the request
401Missing API tokenHeader present but the token is empty after trimming (sale only; refund answers Invalid API token)Check for trailing spaces or an empty env variable
401Invalid API tokenThe token matches no program: a wrong key, a replaced key, or a key from a different programCompare what you are sending against the full key you stored when it was issued. If you no longer hold that value, ask a workspace owner to regenerate the key and deploy the new one — the dashboard only ever shows it masked afterwards

The rows above apply to the endpoints that require a Bearer token: POST /api/v1/track/sale and POST /api/v1/track/refund.

{
  "data": null,
  "error": {
    "status": 401,
    "name": "UnauthorizedError",
    "message": "Invalid API token",
    "details": {}
  }
}

GET /api/v1/programs/me/readiness fails the same three checks with the typed envelope instead, always with error.code set to invalid_key. Its messages end with a period: Missing or malformed Authorization header. Expected: Bearer <api_token>., Missing API token. and Invalid API token.

The CLI's program-key routes word these differently; see CLI endpoint errors.

POST /api/v1/track/lead accepts a Bearer token for server mode but does not require one, so it answers differently. A missing header — or a header whose token is empty after Bearer — falls back to browser mode: the lead is recorded from the click_id rather than refused. Only a token that matches no program returns 401 there.

The success body is identical in both modes, so a 2xx on its own does not prove your key was read. The one exception is a program configured with lead_auth: server_required, where a browser-mode lead comes back with "provisional": true.

Such a lead settles when your backend sends the server-side lead. A qualifying Stripe payment webhook can also settle it, through the same path the lead endpoint uses, but only when Affitor can resolve the customer from that event — one-off payments through checkout.session.completed, and subscriptions through invoice.payment_succeeded, because subscription checkouts are deliberately left to the invoice event. Send the server-side lead rather than relying on a payment to settle it.


api_key_rotation_required

Affitor can mark a specific program API key for mandatory rotation — for example, after the key was exposed outside your control. A marked key keeps authenticating requests until its rotation deadline, so tracking is never interrupted, but every response signals the change so you or your agent can act ahead of time.

Before the deadline, requests with a marked key still succeed and pick up three standard headers:

HeaderValue
Deprecation@<Unix timestamp in seconds> of when the key was marked for rotation, e.g. Deprecation: @1789776000 (RFC 9745)
SunsetHTTP-date of the rotation deadline, after which the key stops working (RFC 8594)
Link<https://affitor.com/docs/api-reference/errors#api_key_rotation_required>; rel="deprecation"

The JSON body also gains a warnings array. No existing field is removed or renamed.

warnings sits at the top level of the response body. On endpoints that wrap their payload in data, it is a sibling of data, not a field inside it, so read it before you unwrap:

{
  "success": true,
  "warnings": [
    {
      "code": "api_key_rotation_required",
      "message": "This API key must be replaced by 2026-10-03.",
      "hint": "Create a new key in Affitor → Settings → API Key, then update AFFITOR_API_KEY on your server.",
      "rotate_by": "2026-10-03T00:00:00.000Z",
      "docs_url": "https://affitor.com/docs/api-reference/errors#api_key_rotation_required"
    }
  ]
}

After the deadline, the key is retired and every request with it returns:

StatusError codeMeaningFix
401api_key_rotation_requiredThe key passed its rotation deadline and no longer authenticates requestsCreate a new key and update your integration
{
  "error": {
    "code": "api_key_rotation_required",
    "message": "This API key was retired on 2026-10-03.",
    "hint": "Create a new key in Affitor → Settings → API Key.",
    "docs_url": "https://affitor.com/docs/api-reference/errors#api_key_rotation_required"
  }
}

This body is the typed envelope: no data key, and an error.code to branch on.

Fix: In your Affitor dashboard, go to Settings → API Key and regenerate the key, then update AFFITOR_API_KEY on your server with the new value. See Rotate your API key for the full walkthrough.


Validation errors (400)

Track click

StatusError messageMeaningFix
400affiliate_url is requiredBody did not include affiliate_urlPass the full URL that contains the ?aff= parameter
400Invalid affiliate_urlURL could not be parsed by new URL()Ensure the value is a fully-qualified URL (includes scheme)
400No aff parameter found in affiliate_urlURL was valid but had no aff query parameterOnly call this endpoint when ?aff= is present in the URL
400Referral link not foundNo referral link matched the aff valueVerify the partner link exists and short_link matches the aff value
400Invalid referral link configurationLink exists but has no linked partner or programContact Affitor support — the referral link record is incomplete

Track lead

StatusError messageMeaningFix
400click_id or customer_key is requiredNeither identifier was sent in the bodyPass the click_id from the affitor_click_id cookie, or the customer_key set at signup
400Customer not found. Provide a valid click_id or customer_key.No customer record matched either identifierThe click was not tracked, the cookie was cleared, or the customer_key was never recorded — ensure init() and signup() ran successfully
400Customer does not belong to this programCustomer was found but is attributed to a different program (server mode only)Use the API key for the program that originated the click
400Customer status cannot be updated to lead. Current status: <status>Lead service rejected the status transitionA lead was already recorded for this customer, or the customer is at a later funnel stage — this is usually safe to ignore

Track sale

StatusError messageMeaningFix
400transaction_id is required and must be a stringField missing or not a string typeAlways pass transaction_id as a string
400amount_cents is required and must be a positive integerField missing, zero, negative, or wrong typePass sale amount in smallest currency unit (e.g. 4900 for $49.00 USD) as a positive integer
400Customer not found. Provide customer_key or click_id.Neither identifier resolved a customerEnsure lead tracking ran first and the customer record exists
400No affiliate attribution found for this customer in this programCustomer exists but is attributed to a different programUse the API key matching the program where the click originated
400No partner-program relationship found for this customerCustomer has no linked partner-program junctionThe customer record is missing a partner relationship — check that the original click was tracked correctly
400partnerId required to create commissionCustomer resolved but has no affiliate_partner relationInternal data inconsistency — contact Affitor support

Track refund

StatusError messageMeaningFix
400transaction_id is requiredBody did not include transaction_idPass the same transaction_id used when recording the original sale

Forbidden errors (403)

StatusError messageMeaningFix
403program_id does not match the API keyA test-mode click or lead (additional_data.test_mode) sent both a Bearer key and an additional_data.program_id, and the two name different programsSend the program_id of the program the key belongs to, or leave program_id out and let the key decide

This applies to POST /api/v1/track/click and POST /api/v1/track/lead in test mode only. A browser-mode test event with no key is not checked this way.

{
  "data": null,
  "error": {
    "status": 403,
    "name": "ForbiddenError",
    "message": "program_id does not match the API key",
    "details": {}
  }
}

Conflict errors (409)

StatusError messageMeaningFix
409Duplicate transaction_id. This sale has already been recorded.A sale event with this processor_event_id already existsThis is the intended duplicate-guard behavior — do not retry with the same transaction_id. If the original was a test, check if you need to clear test data.

Duplicate detection is keyed on processor_event_id (mapped from transaction_id). The check is scoped globally, not per-program.


Not found errors (404)

StatusError messageMeaningFix
404Sale not found for transaction_idRefund was attempted but no sale with that transaction_id exists in the programVerify the transaction_id matches exactly what was sent to /track/sale and that the Bearer token is for the same program

Rate limits (429)

Every /api/v1/ endpoint below is rate limited per minute. The click and lead endpoints are bucketed by client IP, because they are called without a key; the rest are bucketed by API key, falling back to IP when no key is sent.

EndpointLimit per minuteCounted per
POST /api/v1/track/click60IP
POST /api/v1/track/lead30IP
POST /api/v1/track/sale and POST /api/v1/track/refund120 combinedAPI key
GET /api/v1/programs/me/readiness30API key
/api/v1/cli/*60API key

Every response on these routes carries the current budget, not just the ones that are refused:

HeaderValue
X-RateLimit-LimitThe limit for this bucket
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-ResetUnix timestamp in seconds when the window resets
Retry-AfterSeconds to wait. Sent only on a 429

Over the limit, the request is refused with the typed envelope — no data key:

{
  "error": {
    "status": 429,
    "code": "rate_limited",
    "message": "Too many requests.",
    "retry_after_seconds": 42
  }
}

Wait retry_after_seconds (or read Retry-After) before retrying. The request is refused before it reaches the endpoint, so nothing was recorded and the retry is safe.

The synthetic verification chain has a second, separate cap of 10 runs per program per hour. It reports the same rate_limited code on POST /api/v1/cli/test-event, with its own retry_after_seconds. That one is checked inside the endpoint rather than in front of it, so the "nothing was recorded" guarantee above does not carry over: the chain's test fixture is prepared before the cap is consulted. No chain runs, but the fixture records for your program may have been created or refreshed.


Server errors (500)

A 500 means the request failed on our side. The message is deliberately generic — the detail goes to our logs under the response's X-Request-ID, so include that ID when you contact support.

StatusError messageMeaningFix
500Click tracking failedThe click handler failed unexpectedlyRetry with backoff
500Lead tracking failedThe lead handler failed unexpectedlyRetry with backoff. If the first attempt did record the lead, the retry can answer 400 Customer status cannot be updated to lead, which is safe to ignore
500Sale tracking failedThe sale handler failed unexpectedlyRetry with backoff and the same transaction_id. If the first attempt recorded the sale, the retry answers 409, so you cannot record it twice
500Refund tracking failedThe refund handler failed unexpectedlyRetry with backoff. If it persists, contact support with the X-Request-ID
500Failed to create commissionThe sale event was written, but its commission was not createdDo not retry: the transaction_id is consumed and a retry answers 409. Contact support with the transaction_id and the X-Request-ID
500Failed to compute readiness. Retry shortly.GET /api/v1/programs/me/readiness failed. Typed envelope, code internal_errorWait retry_after_seconds (10), then poll again
{
  "data": null,
  "error": {
    "status": 500,
    "name": "InternalServerError",
    "message": "Sale tracking failed",
    "details": {}
  }
}

The readiness 500 uses the typed envelope:

{
  "error": {
    "code": "internal_error",
    "message": "Failed to compute readiness. Retry shortly.",
    "retry_after_seconds": 10
  }
}

Metric update failures (step 10 in the sale flow) are logged as warnings and do not cause a 500 — the sale and commission are still created.

For 500s from the CLI endpoints, see CLI endpoint errors.


CLI endpoint errors

The Affitor CLI talks to the /api/v1/cli/* endpoints. They answer in the platform envelope, except the rate limits and a retired key, which use the typed one. This section is about what the API returns; for the codes the CLI itself prints with --json, see CLI commands.

The routes authenticate in two different ways, and the fix for a 401 depends on which:

  • Account session — init and programs take the login token from affitor login. Fix a 401 by logging in again. Regenerating a program key does not help.
  • Program API key — status and test-event take the program's API key. Fix a 401 as in authentication errors.

Login (/api/v1/cli/auth/*)

These routes run the browser login that affitor login starts. They take no credential.

EndpointStatusError messageMeaningFix
POST /auth/start500Failed to start auth session (request_id: <id>)The login session could not be createdRun affitor login again
POST /auth/callback400Missing required fields: state, id_tokenThe body lacked the session state or the sign-in id_tokenComplete sign-in from the link affitor login opened
POST /auth/callback404Session not found or already usedNo pending login session has that stateRun affitor login again for a fresh link
POST /auth/callback400Session expiredThe login link is older than 5 minutesRun affitor login again and finish within 5 minutes
POST /auth/callback403Your account has been suspended.ACCOUNT_BLOCKEDContact Affitor support
POST /auth/callback500Auth callback failed (request_id: <id>)Sign-in could not be completedRun affitor login again
GET /auth/poll400Missing state parameterThe state query parameter was not sentPoll with the state returned by /auth/start
GET /auth/poll404Session not foundNo login session has that stateRun affitor login again
GET /auth/poll400Session already consumedThe login token was already handed out onceRun affitor login again. The token is returned only on the first poll after sign-in
GET /auth/poll500Failed to poll auth session (request_id: <id>)The poll failed on our sidePoll again

An expired session is not an error on /auth/poll: it answers 200 with "data": { "status": "expired" }.

ACCOUNT_BLOCKED

POST /api/v1/cli/auth/callback refuses to log in a suspended account. It is the one platform-envelope error with a stable code, in error.details.code:

{
  "data": null,
  "error": {
    "status": 403,
    "name": "ForbiddenError",
    "message": "Your account has been suspended.",
    "details": {
      "code": "ACCOUNT_BLOCKED"
    }
  }
}

Cause: the Affitor account you signed in with is suspended. Fix: retrying or signing in again will not help. Contact Affitor support.

Account-session routes (init, programs)

EndpointStatusError messageMeaningFix
POST /init401Authentication required — run `affitor login` firstNo Bearer login token was sentRun affitor login
POST /init401Invalid or expired token — run `affitor login` againThe login token is invalid or expiredRun affitor login again
POST /init401User not found — run `affitor login` againThe token's account no longer existsRun affitor login again
POST /init403No advertiser found for this account — run `affitor login` againThe account has no brand profile yetRun affitor login again; logging in creates one
POST /init403advertiser_id does not match the authenticated accountThe body named another account's advertiser_idLeave advertiser_id out. It is taken from your login
POST /init400Missing required fields: name, domain, commission_type, commission_rateOne of the four fields is missingSend all four
POST /init400Invalid commission_type: <value>. Valid: percent, fixed, recurring_percent, recurring_fixedcommission_type is not one of the four valuesUse one of the listed values
POST /init400commission_rate must be a positive numbercommission_rate is not a number, or is zero or negativeSend a positive number, not a string
POST /init500Failed to create program (request_id: <id>)Program creation failed on our sideRun affitor programs before retrying, in case the program was created
GET /programs401Missing Authorization header. Run `affitor login` first.No Bearer login token was sentRun affitor login
GET /programs401Invalid or expired token. Run `affitor login` to refresh.The login token is invalid or expiredRun affitor login again
GET /programs401User not foundThe token's account no longer existsRun affitor login again
GET /programs500Failed to list programs (request_id: <id>)Listing failed on our sideRetry with backoff

Program-key routes (status, test-event)

EndpointStatusError messageMeaningFix
both401Missing or malformed Authorization header. Expected: Bearer <api_token>The Authorization header is missing or does not start with Bearer Send Authorization: Bearer YOUR_PROGRAM_API_KEY
both401Missing API tokenThe token is empty after Bearer Check for an empty env variable
both401Invalid API key. To get a working key for the program you already have, ask a workspace owner of that program to open Affitor → Settings → API Key and click Regenerate, then replace the key you are sending with the new one.The key matches no programDo as the message says. Running affitor init does not help: it creates a second program
both401api_key_rotation_requiredThe key is retiredCreate a new key
GET /status500Failed to get status (request_id: <id>)Status lookup failed on our sideRetry with backoff
POST /test-event400Invalid event_type: <value>. Valid: click, lead, saleSingle-event mode got a missing or unknown event_typeSend click, lead or sale, or {"type": "chain"} for the full chain
POST /test-event429Too many synthetic chains (max 10/program/hour).The chain cap was hit. Typed envelope, code rate_limitedWait retry_after_seconds; see rate limits
POST /test-event500Failed to run synthetic chain (request_id: <id>)The chain run failed on our sideRetry with backoff
POST /test-event500Failed to process test event (request_id: <id>)The test event failed on our sideRetry with backoff

Retired endpoint

EndpointStatusError messageFix
POST /stripe-connect410This endpoint is retired. Connect Stripe from the Affitor dashboard: open the program, go to Settings, and use Connect Stripe.Connect Stripe from the dashboard

A CLI 500 puts the request ID in the message and in details:

{
  "data": null,
  "error": {
    "status": 500,
    "name": "InternalServerError",
    "message": "Failed to get status (request_id: 9f2c4e1a7b3d5f60a8c2e4b6d8f0a1c3)",
    "details": {
      "request_id": "9f2c4e1a7b3d5f60a8c2e4b6d8f0a1c3"
    }
  }
}

Troubleshooting

Cookies blocked by the browser

The Affitor browser SDK stores the click ID in a first-party cookie named affitor_click_id (SameSite=Lax; Secure on HTTPS). Attribution is lost when:

  • The user has a browser extension or privacy setting that blocks first-party cookies.
  • The page is served over HTTP in production (the Secure flag is only set on HTTPS).
  • Safari ITP aggressively expires cookies set via JavaScript within 7 days for domains classified as trackers.

Fix: The SDK auto-detects the root domain and sets the cookie there (e.g. .example.com) to share attribution across subdomains. For custom domains or cross-domain attribution, pass cookieDomain explicitly:

init({ programId: 123, cookieDomain: '.example.com' });

There is no server-side fallback for blocked cookies — if the cookie is missing when signup() fires, pass customer_key alone and accept that the lead will be unattributed.


CORS

The tracking API (api.affitor.com) must allow your site's origin for browser-mode requests. If you see CORS errors:

  1. Check that your site's domain is listed in the allowed origins on the Affitor program settings page.
  2. Browser-mode tracking calls (/track/click, /track/lead without a Bearer token) are the only routes intended for direct browser fetch. Server-mode routes (/track/sale, /track/refund) should only be called from your backend — they do not need CORS.
  3. In development, use localhost explicitly rather than 127.0.0.1 (or vice versa) — the two are treated as different origins.

Attribution window edge cases

The attribution window defaults to 60 days and is set per program (cookie_window_days). The actual value is returned in the click response:

{
  "success": true,
  "click_id": "cust_42_1712345678901",
  "cookie_window_days": 60
}

The SDK applies this value as the cookie expiry. Edge cases:

ScenarioBehavior
User clears cookies before signupAttribution is lost; lead and sale will be unattributed unless you pass customer_key server-side
User visits with a different partner's link after the first clickLast-touch attribution: the SDK detects a new aff value, fires a new click, and overwrites the cookie
Signup fires after cookie expiryclick_id from cookie is null; lead is still recorded (unattributed) if customer_key is provided
Sale fires with a customer_key for a customer whose window expiredSale is recorded and attribution is resolved from the stored customer record, not the cookie — the window only governs cookie persistence

Duplicate transaction_id handling

The sale endpoint uses transaction_id as a globally unique idempotency key stored as processor_event_id. The rules:

  • Sending the same transaction_id twice returns 409 Conflict — the second request is a no-op.
  • The 409 is not an error to retry. Your integration should treat it as "already recorded" and proceed.
  • Test-mode sales use test_txn_<timestamp> when transaction_id is omitted, so test records are never duplicates unless you pass the same value explicitly.
  • If a sale was recorded but commission creation failed (500), the transaction_id is still consumed. Contact Affitor support to investigate the commission state before re-submitting under a new ID.

Recommended pattern:

const response = await fetch('https://api.affitor.com/api/v1/track/sale', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${PROGRAM_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ transaction_id: chargeId, ... }),
});

if (response.status === 409) {
  // Already recorded — safe to continue
  return;
}

if (!response.ok) {
  // Unexpected error — log and alert
  const body = await response.json();
  // `error` is always an object. `code` is present only on the typed envelope
  // (rate_limited on a 429, api_key_rotation_required on a retired key);
  // the platform envelope carries a message and no code.
  const detail = body.error?.code
    ? `${body.error.code}: ${body.error.message}`
    : body.error?.message;
  throw new Error(`Affitor error ${response.status}: ${detail}`);
}

SDK debug: true for verbose logging

Pass debug: true to init() to enable verbose console output. This sets AffitorInitOptions.debug on the tracker instance.

import { init } from '@affitor/sdk';

init({
  programId: 123,
  debug: true,
});

With debug: true, the SDK logs to the browser console using [Affitor] as the prefix for every info and warn level message — including initialization, program ID resolution, click tracking, and signup calls. Errors (console.error) are always logged regardless of the debug flag.

[Affitor] Affitor SDK initialized: { programId: 123, debug: true }
[Affitor] Using program ID: 123
[Affitor] Click tracked, click_id saved: cust_42_1712345678901
[Affitor] Signup tracked successfully

Disable in production. The debug flag is intended for development and integration testing only. Leave it off in production builds to avoid leaking internal state to end users.

To check the current tracker state at any point:

import { getData } from '@affitor/sdk';

console.log(getData());
// {
//   clickId: "cust_42_1712345678901",
//   programId: 123,
//   hasAttribution: true,
//   affiliateUrl: "https://example.com/?aff=abc123"
// }
© 2026 Affitor