Overview

The FundedNext Affiliate API is a service-to-service REST API that connects an external order/billing system (today: FundedNext's Laravel backend; tomorrow: any partner integrating with the affiliate platform) to the attribution + commission engine.

There are two directions:

  • Inbound — events you push to us (order completed, refunded, account reset) so we can write commission entries. Plus a lookup endpoint (affiliate check).
  • Outbound — calls we make to you (coupon CRUD, plan catalog, promoter sync, customer verification). You need to expose these endpoints from your backend.

Every call is HMAC-signed in both directions with a separate secret per direction. All money values move as integer USD cents.

Base URL for inbound (your code → ours): https://partner-api.fundednext.net/api/v1
For outbound (our code → yours): you configure the base URL via the FN_BACKEND_URL environment variable on our side.

Authentication

Every server-to-server request is signed with HMAC-SHA256. There's no session, no API key in a header — the signature is everything. Two secrets exist, one per direction:

  • FN_INBOUND_HMAC_SECRET — used by FN (sender) and the affiliate service (verifier) for inbound calls.
  • FN_OUTBOUND_HMAC_SECRET — used by the affiliate service (sender) and FN (verifier) for outbound calls.

Required headers

X-Affiliate-Signature: t=<unix_seconds>,v1=<hex_signature>,n=<nonce_uuid>
X-Affiliate-Source:    fn-backend
Idempotency-Key:       <uuid>
Content-Type:          application/json

Computing the signature

The signed payload is `${t}.${n}.${sha256_hex(body)}`, where:

  • t — unix seconds when the request was generated
  • n — request nonce (UUID v4)
  • sha256_hex(body) — SHA-256 hex digest of the exact request body bytes

HMAC the payload with the shared secret, hex-encode the result. Pseudocode:

payload   = `${t}.${n}.${sha256_hex(body)}`
signature = hmac_sha256(secret, payload).hex()

X-Affiliate-Signature: t=${t},v1=${signature},n=${n}

Replay protection

  • Requests with t more than 300 seconds off wall-clock are rejected with 401 stale_timestamp.
  • Each n (nonce) is dedupe-cached for 10 minutes on the receiver side; a replayed nonce returns 401 nonce_replayed.

Reference implementation

// Node.js — request signing
import { createHash, createHmac, randomUUID } from "node:crypto";

function sign(body, secret) {
  const t = Math.floor(Date.now() / 1000);
  const n = randomUUID();
  const bodyHash = createHash("sha256").update(body).digest("hex");
  const payload = `${t}.${n}.${bodyHash}`;
  const sig = createHmac("sha256", secret).update(payload).digest("hex");
  return {
    "X-Affiliate-Signature": `t=${t},v1=${sig},n=${n}`,
    "X-Affiliate-Source": "fn-backend",
    "Idempotency-Key": randomUUID(),
    "Content-Type": "application/json",
  };
}

HTTP status codes

CodeMeaning
200Success. Body has the result.
400Validation failure. The body or query is malformed; response.error names the issue.
401Signature invalid, timestamp stale, or nonce replayed. Don't retry — fix the signing.
403Authenticated but unauthorized for this resource. Rare on inbound; typical on partner-side mistakes.
404Endpoint or referenced entity not found.
409Idempotency-Key conflict — same key, different body. Don't retry without a new key.
429Rate-limited. Retry after the duration in `Retry-After` (seconds).
500Server error. Safe to retry with the same Idempotency-Key after a backoff.

Error shape

Every error response uses the same envelope:

{
  "error": "stale_timestamp",
  "message": "Request timestamp is 412s old; max age is 300s.",
  "details": { ... }    // optional, error-specific
}

Common errorcodes you'll see:

  • invalid_signature — HMAC didn't verify
  • stale_timestamp — > 300s skew
  • nonce_replayed — same nonce seen recently
  • invalid_json — body wasn't parseable JSON
  • invalid_payload — schema validation failed (see details.issues)

Idempotency

Every mutating call requires an Idempotency-Key header (UUID v4 recommended). The same key + the same body returns the original response and does not write twice.

  • Order/refund/reset events are additionally idempotent on their event_id field (we use a unique index on source_event_id × affiliate_id × level). So even without an Idempotency-Key, the same event_id never writes a duplicate commission event.
  • Generate a new key per call. Don't reuse keys for different bodies — you'll get 409 idempotency_key_conflict.
  • Keys are retained for 24 hours.
POST/api/v1/internal/eventsInbound · FN → us

Events — sale, reset, refund

Single inbound endpoint for every revenue event. The `event_type` field on the body selects the variant. Sale-like events (order_completed, account_reset) run attribution → commission engine; order_refunded looks up the original commission event and writes a negative.

Request body

# order_completed — paid sale
{
  "event_type":      "order_completed",
  "transaction_id":  "TXN-98765",              // required — your payment transaction ID
  "customer_email":  "buyer@example.com",      // required
  "amount_cents":    95000,                    // required — grand total in minor units
  "currency":        "USD",                    // required — ISO 4217
  "market":          "cfd",                   // required — "cfd" | "futures" | "fnmarkets"
  "coupon_code":     "ALICE10",               // optional — coupon APPLIED AT CHECKOUT → earns commission
  "referral_code":   "ALICE10",               // optional — code from the fpr cookie → TRACK ONLY, no commission
  "product_name":    "CFD Challenge 100K",    // optional
  "occurred_at":     "2026-05-21T12:00:00Z"  // required
}

# account_reset — trading account reset fee
{
  "event_type":     "account_reset",
  "transaction_id": "TXN-RESET-001",          // required
  "customer_email": "buyer@example.com",       // required
  "amount_cents":   4900,                      // required — reset fee in minor units
  "market":         "cfd",                    // required — "cfd" | "futures" | "fnmarkets"
  "occurred_at":    "2026-05-21T14:00:00Z"    // required
}

# account_signup — customer signed up, no purchase yet (lead capture)
{
  "event_type":         "account_signup",
  "transaction_id":     "SIGNUP-12345",        // required — your signup/registration id (idempotency key)
  "customer_email":     "buyer@example.com",    // required
  "market":             "cfd",                 // required — "cfd" | "futures" | "fnmarkets"
  "referral_code":      "ALICE10",             // from the fpr cookie — the usual case for a free signup
  "coupon_code":        "ALICE10",             // OR a coupon entered at signup. Need at least one; neither ⇒ dropped
  "signup_value_cents": 100000,                // optional — plan/intent value the customer signed up for
  "occurred_at":        "2026-05-21T11:00:00Z" // required
}

# order_refunded — clawback for a previously paid sale
{
  "event_type":          "order_refunded",
  "transaction_id":      "TXN-98765",        // required — the original sale's transaction_id
  "refund_amount_cents": 95000,              // required
  "occurred_at":         "2026-05-21T13:00:00Z"
}

Success · 200

# Sale-kind (order_completed, account_reset)
{
  "attributed": true,
  "written":    1,
  "fraud":      false,
  "events": [
    {
      "id":             "9f4c8b2e-…",
      "affiliate_id":   "720416f9-…",
      "level":          0,
      "gross_usd_cents": 7600,
      "net_usd_cents":   7600,
      "status":          "pending",
      "holdback_until":  "2026-06-04T12:00:00Z"
    }
  ]
}

# referral_code path — trader tracked, NO commission written
{ "attributed": true, "commissionable": false, "written": 0 }

# account_signup (lead captured — no commission written)
{ "attributed": true, "lead": true }

# Refund
{
  "written": 1,
  "events": [
    {
      "id":            "8a3c1d4f-…",
      "level":         0,
      "net_usd_cents": -7600,
      "status":        "clawed_back"
    }
  ]
}

No attribution · 200

// no coupon and no prior attribution found for this email+market
{ "attributed": false, "reason": "no_prior_attribution" }

// coupon code not found or inactive
{ "attributed": false, "reason": "unknown_coupon" }

// referral_code not found or inactive
{ "attributed": false, "reason": "unknown_referral" }

// market value not recognised (not a configured program)
{ "attributed": false, "reason": "unknown_market:fnmarkets" }

// account_reset: no prior attribution found for this email+market
{ "attributed": false, "reason": "no_prior_attribution" }

// account_signup: neither coupon_code nor referral_code supplied
{ "attributed": false, "reason": "no_code" }

Notes

  • Pick the variant via `event_type` — values: `order_completed`, `account_reset`, `account_signup`, `order_refunded`.
  • `market` is required on `order_completed` and `account_reset`. It identifies which program's commission structure applies — `cfd`, `futures`, or `fnmarkets`. Affiliate codes are program-agnostic: the same code works across all markets; the rate applied depends on the market you send here.
  • `transaction_id` is your payment gateway transaction ID. It is the idempotency key — sending the same `transaction_id` twice is a safe no-op; commissions are only written once.
  • `coupon_code` vs `referral_code` — THE key distinction. `coupon_code` = the trader actually applied the code at checkout → commission is calculated. `referral_code` = the code came from the `fpr` referral cookie only (no code entered at checkout) → we record who referred whom and track the trader's spend, but NO commission is ever written for it. Send `coupon_code` when the discount was applied; send `referral_code` when you only have the cookie. If both are present, `coupon_code` wins.
  • On `order_completed` with `coupon_code`: we look up the tracking code and attribute the sale to that affiliate using the specified `market` to determine commission rates. The customer's email is recorded so future coupon-free purchases from the same customer still credit the same affiliate.
  • On `order_completed` with `referral_code` (cookie): we record attribution + the trader's spend and return `{ attributed:true, commissionable:false, written:0 }`. A cookie-only trader NEVER earns — even later coupon-free recurring purchases stay non-commissionable. They only start earning if a future `order_completed` carries a real checkout `coupon_code`.
  • On `order_completed` without any code (recurring purchase): we look up prior attribution by `customer_email` + `market`. If that attribution was commissionable (a checkout coupon), commission is written; if it was cookie-only, the purchase is tracked but earns nothing.
  • On `account_reset`: we look up prior attribution by `customer_email` + `market`. If none exists, returns `attributed:false`.
  • On `account_signup`: send this when a customer registers but hasn't paid — usually with `referral_code` (the fpr cookie). We attribute them and store a `lead` (with optional `signup_value_cents`); no commission is written. Neither code ⇒ dropped (`reason: no_code`). When that trader later makes their first `order_completed`, the lead is promoted to a converted customer — and earns only if that order carried a checkout `coupon_code`.
  • On `order_refunded`: `transaction_id` must be the same `transaction_id` you sent on the original `order_completed`. We look up the sale by that ID and reverse the commission. Refunds always reprice at the ORIGINAL rate — the audit trail is immutable. No `market` field required on refunds.
  • All variants are HMAC-verified and idempotent on `transaction_id`.
POST/api/v1/internal/affiliates/checkInbound · FN → us

Affiliate check (single + bulk)

Ask whether a given email is a registered, active affiliate. Useful for showing 'partner badge' on FN-side UIs, mailer suppression, or compliance flows. Single-email and bulk variants share one endpoint.

Request body

// Single
{ "email": "alice@example.com" }

// Bulk (cap 500 per call)
{ "emails": ["alice@example.com", "bob@example.com"] }

Success · 200

{
  "results": [
    {
      "email":         "alice@example.com",
      "is_affiliate":  true,
      "affiliate_id":  "720416f9-9694-4d83-99ed-0c8559bd4471",
      "status":        "active",
      "joined_at":     "2026-02-01T00:00:00Z"
    },
    {
      "email":        "unknown@example.com",
      "is_affiliate": false
    }
  ]
}

Notes

  • Always returns a `results` array, even for a single email — easier for FN-side code to handle one path.
  • Only `status='active'` returns `is_affiliate:true`. Suspended / terminated / application_submitted all return false.
  • Bulk requests over 500 emails return `400 bulk_cap_exceeded`. Paginate.
  • Email comparison is case-insensitive (lowercased on both sides).
GET/api/v1/internal/affiliatesInbound · FN → us

Affiliate lookup & list

Look up affiliates by email or retrieve a paginated list of all affiliates. Pass `email` for a single lookup; omit it for a full paginated list. Always returns an `affiliates` array — empty when the email is not found. Useful for FN-side enrichment flows, partner badge display, and analytics pipelines.

Request body

(no body — filters are query parameters)

Success · 200

// Single lookup: GET /api/v1/internal/affiliates?email=alice@example.com
{
  "affiliates": [
    {
      "id":        "720416f9-9694-4d83-99ed-0c8559bd4471",
      "email":     "alice@example.com",
      "name":      "Alice Lee",       // null if KYC not completed
      "country":   "United Kingdom", // null if not provided
      "status":    "active",
      "joined_at": "2026-02-01T00:00:00Z"
    }
  ],
  "total": 1,
  "page":  1,
  "limit": 50
}

// Paginated list: GET /api/v1/internal/affiliates?page=2&limit=100
{
  "affiliates": [ /* ... */ ],
  "total": 1247,
  "page":  2,
  "limit": 100
}

Notes

  • This is a GET request — the body is empty. Use the empty-body HMAC hash: `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`.
  • `email` lookup is case-insensitive. Returns a one-item array on match, empty array on no match.
  • Without `email`, returns all affiliates ordered by `joined_at` ascending. Paginate with `page` (1-based, default 1) and `limit` (default 50, max 200).
  • `name` and `country` come from KYC identity data — may be `null` for affiliates who have not completed KYC.
  • `status` values: `pending`, `active`, `suspended`, `rejected`.
GET/api/v1/internal/coupons/{code}Inbound · FN → us

Coupon validate

Call this at checkout time to verify a code and retrieve the affiliate's resolved discount rules for each program + plan category combination. Returns a `discounts` array (one entry per applicable rule group) and the linked affiliate's identity. The `affiliateId` is what goes into the subsequent order_completed event.

Request body

(no body — code is in the URL path)

Success · 200

// Valid code
{
  "valid":       true,
  "affiliateId": "720416f9-9694-4d83-99ed-0c8559bd4471",
  "affiliate": {
    "id":      "720416f9-9694-4d83-99ed-0c8559bd4471",
    "email":   "alice@example.com",
    "name":    "Alice Lee",          // null if KYC not completed
    "country": "United Kingdom"     // null if not provided
  },
  "discounts": [
    {
      "programCode":       "cfd",          // null = applies to all programs
      "planCategory":      null,           // null = applies to all plan categories
      "discountType":      "percentage",   // "none" | "percentage" | "flat"
      "discountBps":       500,            // basis points (500 = 5%); 0 when type ≠ percentage
      "discountFlatCents": 0,              // cents; 0 when type ≠ flat
      "discountPercent":   5               // percentage as a number (1050 bps → 10.5); null when type ≠ percentage
    },
    {
      "programCode":       "futures",
      "planCategory":      "stellar",
      "discountType":      "percentage",
      "discountBps":       800,
      "discountFlatCents": 0,
      "discountPercent":   8
    }
  ],
  "expiresAt": null                        // ISO 8601 or null if no expiry
}

// Invalid code
{ "valid": false, "reason": "not_found" }
{ "valid": false, "reason": "inactive" }
{ "valid": false, "reason": "expired" }
{ "valid": false, "reason": "usage_limit_reached" }

Notes

  • This is a GET request — the body is empty. When computing the HMAC signature, `sha256_hex(body)` is the SHA-256 of an empty string: `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`.
  • The `code` path segment is matched case-insensitively — we normalise to uppercase internally.
  • The `discounts` array contains one entry per (programCode, planCategory) combination. Rules are resolved dynamically — the most-specific rule wins each group (affiliate-specific > campaign-specific > tier-specific > program-wide > global). An empty array means no discount rules are configured for this affiliate.
  • To apply a discount: find the entry whose `programCode` matches the customer's market and whose `planCategory` matches the plan (or where both are `null` for a catch-all). If FN has an active campaign offering a higher rate, apply that instead — the portal is the source of truth for the affiliate's configured rate, FN is the source of truth for campaign rates.
  • Pass `affiliateId` back to us via `coupon_code` on the subsequent `order_completed` (we re-resolve the affiliate from the code). Do not pass `affiliateId` directly.
  • A `valid: false` response does not block attribution — if no coupon is sent on `order_completed`, email-fallback attribution still runs for returning customers.
POST/api/internal/affiliate/couponsOutbound · we → you

Create coupon

Called when an affiliate generates a tracking code in our portal — we mirror it as a coupon in your catalog so it can be applied at checkout.

Request body

{
  "code":                      "ALICE10",
  "base_discount_type":        "percentage",
  "base_discount_value":       1000,
  "total_discount_value":      1500,
  "valid_from":                "2026-05-21T00:00:00Z",
  "valid_to":                  "2026-12-31T23:59:59Z",
  "usage_limit":               100,
  "max_redemption_per_user":   1,
  "affiliate_external_id":     "720416f9-…",
  "program_code":              "cfd",
  "country_ids":               [840, 826]
}

Success · 201

{
  "fn_coupon_id": 42,
  "code":         "ALICE10",
  "status":       1
}

Notes

  • `base_discount_value` is basis points for percentage (1000 = 10%) or cents for flat.
  • `total_discount_value` = base + affiliate-funded portion (the affiliate can sacrifice commission to widen the discount).
  • We retry on 5xx with exponential backoff. The endpoint must be idempotent on `code` — return the existing `fn_coupon_id` if the code already exists.
PATCH/api/internal/affiliate/coupons/{fn_coupon_id}Outbound · we → you

Update coupon

Called when an affiliate edits a code — typically updates the affiliate-funded discount, validity window, or usage cap.

Request body

{
  "total_discount_value": 1800,
  "valid_to":             "2027-01-31T23:59:59Z"
}

Success · 200

{
  "fn_coupon_id": 42,
  "code":         "ALICE10",
  "status":       1
}

Notes

  • Body is a partial — only fields present should be updated. Same shape as `Create` but every field optional.
  • `code` is immutable. To rename, archive the old coupon and create a new one.
DELETE/api/internal/affiliate/coupons/{fn_coupon_id}Outbound · we → you

Delete coupon

Called when an affiliate archives a code. Soft-delete on your side is fine — we don't expect the coupon to be redeemable after this returns.

Request body

(no body)

Success · 204

(no body)

Notes

  • Idempotent — deleting an already-deleted coupon should return 204, not 404.
GET/api/internal/affiliate/plans?cursor={cursor}Outbound · we → you

List plans

Used by our migration importer + the dev simulate flow to know what products you sell. Paginated with opaque cursor.

Request body

(no body)

Success · 200

{
  "plans": [
    {
      "id":           12,
      "name":         "FundedNext CFD Challenge 100K",
      "category_id":  1,
      "type":         "challenge",
      "price_cents":  49900
    }
  ],
  "next_cursor": "eyJpZCI6MTJ9"
}

Notes

  • Cursor is opaque — we pass back whatever you give. Encode whatever lets you implement keyset pagination.
  • Return `next_cursor: null` on the last page.
GET/api/internal/affiliate/promoters?cursor={cursor}Outbound · we → you

List promoters

Used by the migration importer to pull your existing FirstPromoter / legacy partner records into our `aff_affiliates` table.

Request body

(no body)

Success · 200

{
  "promoters": [
    {
      "promoter_id":        891,
      "customer_id":        12001,
      "email":              "alice@example.com",
      "first_name":         "Alice",
      "last_name":          "Lee",
      "country":            "US",
      "parent_promoter_id": 102,
      "current_tier_code":  "GALACTIC"
    }
  ],
  "next_cursor": null
}

Notes

  • Same cursor model as plans.
  • `parent_promoter_id` lets us rebuild the L1/L2/L3 hierarchy in our system.
GET/api/internal/affiliate/customers/{customer_id}/existsOutbound · we → you

Customer exists

Used by partner-side 'add referral manually' flows and the dev simulate endpoint to confirm a customer exists in your system before writing attribution.

Request body

(no body)

Success · 200

{
  "exists": true,
  "email":  "buyer@example.com"
}

Notes

  • Return `exists:false, email:null` for unknown customer ids (still 200, not 404).
  • Email is included so we can pre-fill the UI; you can return null if you don't want to share.

Changelog

  • v1.10 · 2026-06 — added referral_code to order_completed and account_signup, distinct from coupon_code. coupon_code = applied at checkout → earns commission; referral_code = from the fpr cookie → tracking only, never earns. A cookie-only trader stays non-commissionable until a real checkout coupon arrives. New reasons unknown_referral / no_code. See the new referral-cookie snippet for the frontend capture script. Additive — existing fields unchanged.
  • v1.9 · 2026-06 — new inbound event account_signup on POST /api/v1/internal/events: capture a customer who registered but hasn't purchased as a lead, attributed via coupon_code (with optional signup_value_cents). The lead is auto-promoted to a converted customer on their first order_completed. Additive — existing event variants unchanged.
  • v1.8 · 2026-06 — each entry in the coupon validate discounts array now also carries discountPercent: the percentage as a plain number (500 bps → 5, 1050 bps → 10.5); null when discountType is not percentage. Additive — existing fields unchanged.
  • v1.7 · 2026-06 — coupon validate response now includes an affiliate object (id, email, name, country) and a discounts array replacing the old flat discount fields. Each discount entry covers one (programCode, planCategory) group — the most-specific rule wins per group. Old fields discountType, discountBps, discountFlatCents at the top level are removed. Added GET /internal/affiliates for email lookup and paginated affiliate listing.
  • v1.0 · 2026-02 — initial contract. Four inbound + six outbound endpoints.
  • v1.1 · 2026-05 — added /affiliates/check (bulk + single).
  • v1.2 · 2026-05 — three-way match: paid payouts now require an external-reference amount that matches the request total + included-event sum.
  • v1.3 · 2026-06 — simplified order_completed and account_reset payloads. Removed customer_id, program_code, category_id, gross_amount_cents, discount_amount_cents, commission_amount_cents, and fn_coupon_id. Replaced with customer_email and fn_coupon_code.
  • v1.4 · 2026-06 — replaced UUID event_id with transaction_id (your payment gateway's transaction ID — any string up to 64 chars). Removed order_id and account_id. Renamed grand_total_centsamount_cents and reset_fee_centsamount_cents. Renamed fn_coupon_codecoupon_code (now optional — omit for recurring purchases). Attribution now falls back to email lookup for coupon-free recurring purchases. order_refundedsimplified — use the original sale's transaction_id directly (no separate refund ID).
  • v1.6 · 2026-06 — added GET /internal/coupons/{code}(coupon validate). FN calls this at checkout to get the affiliate's base discount rate before deciding the final applied discount. Supports discountType of none, percentage (bps), or flat (cents).
  • v1.5 · 2026-06breaking: added required market field ("cfd" | "futures" | "fnmarkets") to order_completed and account_reset. Affiliate codes are now program-agnostic — one code works across all markets; the marketfield determines which program's commission rates apply. New unattributed reason: unknown_market:<value> when the market does not match a configured program. account_reset attribution now uses email + market lookup (same as order_completed recurring path) instead of scanning prior commission events.

Support

For onboarding, fresh credentials, or test environment requests, contact .

Operational issues with a live integration are tracked in our incident process — file via your assigned support channel.