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.
https://partner-api.fundednext.net/api/v1For 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 generatedn— 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
tmore than 300 seconds off wall-clock are rejected with401 stale_timestamp. - Each
n(nonce) is dedupe-cached for 10 minutes on the receiver side; a replayed nonce returns401 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
| Code | Meaning |
|---|---|
| 200 | Success. Body has the result. |
| 400 | Validation failure. The body or query is malformed; response.error names the issue. |
| 401 | Signature invalid, timestamp stale, or nonce replayed. Don't retry — fix the signing. |
| 403 | Authenticated but unauthorized for this resource. Rare on inbound; typical on partner-side mistakes. |
| 404 | Endpoint or referenced entity not found. |
| 409 | Idempotency-Key conflict — same key, different body. Don't retry without a new key. |
| 429 | Rate-limited. Retry after the duration in `Retry-After` (seconds). |
| 500 | Server 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 verifystale_timestamp— > 300s skewnonce_replayed— same nonce seen recentlyinvalid_json— body wasn't parseable JSONinvalid_payload— schema validation failed (seedetails.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_idfield (we use a unique index onsource_event_id × affiliate_id × level). So even without an Idempotency-Key, the sameevent_idnever 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.
/api/v1/internal/eventsInbound · FN → usEvents — 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`.
/api/v1/internal/affiliates/checkInbound · FN → usAffiliate 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).
/api/v1/internal/affiliatesInbound · FN → usAffiliate 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`.
/api/v1/internal/coupons/{code}Inbound · FN → usCoupon 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.
/api/internal/affiliate/couponsOutbound · we → youCreate 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.
/api/internal/affiliate/coupons/{fn_coupon_id}Outbound · we → youUpdate 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.
/api/internal/affiliate/coupons/{fn_coupon_id}Outbound · we → youDelete 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.
/api/internal/affiliate/plans?cursor={cursor}Outbound · we → youList 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.
/api/internal/affiliate/promoters?cursor={cursor}Outbound · we → youList 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.
/api/internal/affiliate/customers/{customer_id}/existsOutbound · we → youCustomer 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_codetoorder_completedandaccount_signup, distinct fromcoupon_code.coupon_code= applied at checkout → earns commission;referral_code= from thefprcookie → tracking only, never earns. A cookie-only trader stays non-commissionable until a real checkout coupon arrives. New reasonsunknown_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_signuponPOST /api/v1/internal/events: capture a customer who registered but hasn't purchased as alead, attributed viacoupon_code(with optionalsignup_value_cents). The lead is auto-promoted to a converted customer on their firstorder_completed. Additive — existing event variants unchanged. - v1.8 · 2026-06 — each entry in the coupon validate
discountsarray now also carriesdiscountPercent: the percentage as a plain number (500 bps →5, 1050 bps →10.5);nullwhendiscountTypeis notpercentage. Additive — existing fields unchanged. - v1.7 · 2026-06 — coupon validate response now includes an
affiliateobject (id,email,name,country) and adiscountsarray replacing the old flat discount fields. Each discount entry covers one (programCode, planCategory) group — the most-specific rule wins per group. Old fieldsdiscountType,discountBps,discountFlatCentsat the top level are removed. AddedGET /internal/affiliatesfor 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_completedandaccount_resetpayloads. Removedcustomer_id,program_code,category_id,gross_amount_cents,discount_amount_cents,commission_amount_cents, andfn_coupon_id. Replaced withcustomer_emailandfn_coupon_code. - v1.4 · 2026-06 — replaced UUID
event_idwithtransaction_id(your payment gateway's transaction ID — any string up to 64 chars). Removedorder_idandaccount_id. Renamedgrand_total_cents→amount_centsandreset_fee_cents→amount_cents. Renamedfn_coupon_code→coupon_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'stransaction_iddirectly (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. SupportsdiscountTypeofnone,percentage(bps), orflat(cents). - v1.5 · 2026-06 — breaking: added required
marketfield ("cfd" | "futures" | "fnmarkets") toorder_completedandaccount_reset. Affiliate codes are now program-agnostic — one code works across all markets; themarketfield determines which program's commission rates apply. New unattributed reason:unknown_market:<value>when the market does not match a configured program.account_resetattribution now uses email + market lookup (same asorder_completedrecurring path) instead of scanning prior commission events.