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.
| Status | Meaning | What to do | Retry? |
|---|---|---|---|
400 | The request is invalid: a required field is missing or malformed, or an identifier resolved to nothing usable | Fix 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 409 | Not unchanged |
401 | No usable credential: the Authorization header is missing or malformed, the key or login token matches nothing, or the key was retired | Send a valid program API key, or run affitor login again for the account-session CLI routes | Not until the credential changes |
403 | The 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_BLOCKED | Use the key or account that owns the resource | No |
404 | The thing the request points at does not exist: a sale to refund, or a CLI login session | Check the identifier you sent | Not unchanged |
409 | The sale was already recorded under this transaction_id | Treat it as success: the sale exists | No |
410 | The endpoint is retired, or (POST /api/v1/track/click) the referral link is turned off: error.code is link_disabled and no click was recorded | For 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 on | No |
429 | Rate 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 retry | Yes, after the wait |
500 | Something failed on our side | Retry with backoff. If it persists, contact support with the request ID | Yes, 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.codeset toACCOUNT_BLOCKED. - A CLI
500carrieserror.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:
| Source | Where | Code |
|---|---|---|
GET /api/v1/programs/me/readiness | all of its errors | invalid_key on a 401, internal_error on a 500 |
| Rate limiting | every /api/v1/track/*, /api/v1/cli/* and readiness route | rate_limited |
| A retired API key | every endpoint that key authenticates | api_key_rotation_required |
POST /api/v1/track/click | a 410 for an inactive referral link | link_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)
| Status | Error message | Condition | Fix |
|---|---|---|---|
401 | Missing 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 |
401 | Missing API token | Header present but the token is empty after trimming (sale only; refund answers Invalid API token) | Check for trailing spaces or an empty env variable |
401 | Invalid API token | The token matches no program: a wrong key, a replaced key, or a key from a different program | Compare 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:
| Header | Value |
|---|---|
Deprecation | @<Unix timestamp in seconds> of when the key was marked for rotation, e.g. Deprecation: @1789776000 (RFC 9745) |
Sunset | HTTP-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:
| Status | Error code | Meaning | Fix |
|---|---|---|---|
401 | api_key_rotation_required | The key passed its rotation deadline and no longer authenticates requests | Create 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
| Status | Error message | Meaning | Fix |
|---|---|---|---|
400 | affiliate_url is required | Body did not include affiliate_url | Pass the full URL that contains the ?aff= parameter |
400 | Invalid affiliate_url | URL could not be parsed by new URL() | Ensure the value is a fully-qualified URL (includes scheme) |
400 | No aff parameter found in affiliate_url | URL was valid but had no aff query parameter | Only call this endpoint when ?aff= is present in the URL |
400 | Referral link not found | No referral link matched the aff value | Verify the partner link exists and short_link matches the aff value |
400 | Invalid referral link configuration | Link exists but has no linked partner or program | Contact Affitor support — the referral link record is incomplete |
Track lead
| Status | Error message | Meaning | Fix |
|---|---|---|---|
400 | click_id or customer_key is required | Neither identifier was sent in the body | Pass the click_id from the affitor_click_id cookie, or the customer_key set at signup |
400 | Customer not found. Provide a valid click_id or customer_key. | No customer record matched either identifier | The click was not tracked, the cookie was cleared, or the customer_key was never recorded — ensure init() and signup() ran successfully |
400 | Customer does not belong to this program | Customer was found but is attributed to a different program (server mode only) | Use the API key for the program that originated the click |
400 | Customer status cannot be updated to lead. Current status: <status> | Lead service rejected the status transition | A lead was already recorded for this customer, or the customer is at a later funnel stage — this is usually safe to ignore |
Track sale
| Status | Error message | Meaning | Fix |
|---|---|---|---|
400 | transaction_id is required and must be a string | Field missing or not a string type | Always pass transaction_id as a string |
400 | amount_cents is required and must be a positive integer | Field missing, zero, negative, or wrong type | Pass sale amount in smallest currency unit (e.g. 4900 for $49.00 USD) as a positive integer |
400 | Customer not found. Provide customer_key or click_id. | Neither identifier resolved a customer | Ensure lead tracking ran first and the customer record exists |
400 | No affiliate attribution found for this customer in this program | Customer exists but is attributed to a different program | Use the API key matching the program where the click originated |
400 | No partner-program relationship found for this customer | Customer has no linked partner-program junction | The customer record is missing a partner relationship — check that the original click was tracked correctly |
400 | partnerId required to create commission | Customer resolved but has no affiliate_partner relation | Internal data inconsistency — contact Affitor support |
Track refund
| Status | Error message | Meaning | Fix |
|---|---|---|---|
400 | transaction_id is required | Body did not include transaction_id | Pass the same transaction_id used when recording the original sale |
Forbidden errors (403)
| Status | Error message | Meaning | Fix |
|---|---|---|---|
403 | program_id does not match the API key | A 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 programs | Send 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)
| Status | Error message | Meaning | Fix |
|---|---|---|---|
409 | Duplicate transaction_id. This sale has already been recorded. | A sale event with this processor_event_id already exists | This 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)
| Status | Error message | Meaning | Fix |
|---|---|---|---|
404 | Sale not found for transaction_id | Refund was attempted but no sale with that transaction_id exists in the program | Verify 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.
| Endpoint | Limit per minute | Counted per |
|---|---|---|
POST /api/v1/track/click | 60 | IP |
POST /api/v1/track/lead | 30 | IP |
POST /api/v1/track/sale and POST /api/v1/track/refund | 120 combined | API key |
GET /api/v1/programs/me/readiness | 30 | API key |
/api/v1/cli/* | 60 | API key |
Every response on these routes carries the current budget, not just the ones that are refused:
| Header | Value |
|---|---|
X-RateLimit-Limit | The limit for this bucket |
X-RateLimit-Remaining | Requests left in the current window |
X-RateLimit-Reset | Unix timestamp in seconds when the window resets |
Retry-After | Seconds 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.
| Status | Error message | Meaning | Fix |
|---|---|---|---|
500 | Click tracking failed | The click handler failed unexpectedly | Retry with backoff |
500 | Lead tracking failed | The lead handler failed unexpectedly | Retry 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 |
500 | Sale tracking failed | The sale handler failed unexpectedly | Retry with backoff and the same transaction_id. If the first attempt recorded the sale, the retry answers 409, so you cannot record it twice |
500 | Refund tracking failed | The refund handler failed unexpectedly | Retry with backoff. If it persists, contact support with the X-Request-ID |
500 | Failed to create commission | The sale event was written, but its commission was not created | Do not retry: the transaction_id is consumed and a retry answers 409. Contact support with the transaction_id and the X-Request-ID |
500 | Failed to compute readiness. Retry shortly. | GET /api/v1/programs/me/readiness failed. Typed envelope, code internal_error | Wait 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 —
initandprogramstake the login token fromaffitor login. Fix a401by logging in again. Regenerating a program key does not help. - Program API key —
statusandtest-eventtake the program's API key. Fix a401as in authentication errors.
Login (/api/v1/cli/auth/*)
These routes run the browser login that affitor login starts. They take no credential.
| Endpoint | Status | Error message | Meaning | Fix |
|---|---|---|---|---|
POST /auth/start | 500 | Failed to start auth session (request_id: <id>) | The login session could not be created | Run affitor login again |
POST /auth/callback | 400 | Missing required fields: state, id_token | The body lacked the session state or the sign-in id_token | Complete sign-in from the link affitor login opened |
POST /auth/callback | 404 | Session not found or already used | No pending login session has that state | Run affitor login again for a fresh link |
POST /auth/callback | 400 | Session expired | The login link is older than 5 minutes | Run affitor login again and finish within 5 minutes |
POST /auth/callback | 403 | Your account has been suspended. | ACCOUNT_BLOCKED | Contact Affitor support |
POST /auth/callback | 500 | Auth callback failed (request_id: <id>) | Sign-in could not be completed | Run affitor login again |
GET /auth/poll | 400 | Missing state parameter | The state query parameter was not sent | Poll with the state returned by /auth/start |
GET /auth/poll | 404 | Session not found | No login session has that state | Run affitor login again |
GET /auth/poll | 400 | Session already consumed | The login token was already handed out once | Run affitor login again. The token is returned only on the first poll after sign-in |
GET /auth/poll | 500 | Failed to poll auth session (request_id: <id>) | The poll failed on our side | Poll 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)
| Endpoint | Status | Error message | Meaning | Fix |
|---|---|---|---|---|
POST /init | 401 | Authentication required — run `affitor login` first | No Bearer login token was sent | Run affitor login |
POST /init | 401 | Invalid or expired token — run `affitor login` again | The login token is invalid or expired | Run affitor login again |
POST /init | 401 | User not found — run `affitor login` again | The token's account no longer exists | Run affitor login again |
POST /init | 403 | No advertiser found for this account — run `affitor login` again | The account has no brand profile yet | Run affitor login again; logging in creates one |
POST /init | 403 | advertiser_id does not match the authenticated account | The body named another account's advertiser_id | Leave advertiser_id out. It is taken from your login |
POST /init | 400 | Missing required fields: name, domain, commission_type, commission_rate | One of the four fields is missing | Send all four |
POST /init | 400 | Invalid commission_type: <value>. Valid: percent, fixed, recurring_percent, recurring_fixed | commission_type is not one of the four values | Use one of the listed values |
POST /init | 400 | commission_rate must be a positive number | commission_rate is not a number, or is zero or negative | Send a positive number, not a string |
POST /init | 500 | Failed to create program (request_id: <id>) | Program creation failed on our side | Run affitor programs before retrying, in case the program was created |
GET /programs | 401 | Missing Authorization header. Run `affitor login` first. | No Bearer login token was sent | Run affitor login |
GET /programs | 401 | Invalid or expired token. Run `affitor login` to refresh. | The login token is invalid or expired | Run affitor login again |
GET /programs | 401 | User not found | The token's account no longer exists | Run affitor login again |
GET /programs | 500 | Failed to list programs (request_id: <id>) | Listing failed on our side | Retry with backoff |
Program-key routes (status, test-event)
| Endpoint | Status | Error message | Meaning | Fix |
|---|---|---|---|---|
| both | 401 | Missing 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 |
| both | 401 | Missing API token | The token is empty after Bearer | Check for an empty env variable |
| both | 401 | Invalid 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 program | Do as the message says. Running affitor init does not help: it creates a second program |
| both | 401 | api_key_rotation_required | The key is retired | Create a new key |
GET /status | 500 | Failed to get status (request_id: <id>) | Status lookup failed on our side | Retry with backoff |
POST /test-event | 400 | Invalid event_type: <value>. Valid: click, lead, sale | Single-event mode got a missing or unknown event_type | Send click, lead or sale, or {"type": "chain"} for the full chain |
POST /test-event | 429 | Too many synthetic chains (max 10/program/hour). | The chain cap was hit. Typed envelope, code rate_limited | Wait retry_after_seconds; see rate limits |
POST /test-event | 500 | Failed to run synthetic chain (request_id: <id>) | The chain run failed on our side | Retry with backoff |
POST /test-event | 500 | Failed to process test event (request_id: <id>) | The test event failed on our side | Retry with backoff |
Retired endpoint
| Endpoint | Status | Error message | Fix |
|---|---|---|---|
POST /stripe-connect | 410 | This 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
Secureflag 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:
- Check that your site's domain is listed in the allowed origins on the Affitor program settings page.
- Browser-mode tracking calls (
/track/click,/track/leadwithout 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. - In development, use
localhostexplicitly rather than127.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:
| Scenario | Behavior |
|---|---|
| User clears cookies before signup | Attribution 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 click | Last-touch attribution: the SDK detects a new aff value, fires a new click, and overwrites the cookie |
| Signup fires after cookie expiry | click_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 expired | Sale 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_idtwice returns409 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>whentransaction_idis 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_idis 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 successfullyDisable 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"
// }