API reference · v1

Affilitera SubNetwork API

Programmatic access to the brand catalogue, product feeds, promotions, links and reporting behind your connected networks. 30 endpoints, one envelope, cursor pagination throughout.

The spec at /docs/api/openapi.json is the source of truth for this page — point your client generator straight at it.

How the SubNetwork commission model works

Before you call anything, it helps to know who is in the room. A sale that shows up in this API has already passed through four parties, and each one sees a different number for the same transaction. Get this straight first and the field reference further down stops being abstract — you will already know why each field exists.

Who is who

  • The network — Awin, CJ, Rakuten, and so on. This is who the advertiser actually pays commission to, and who tells us what a sale was worth.
  • The owner — a SubNetwork partner (you, if you are reading this with an owner key) who has connected their own network account to Affilitera and is reselling access to that inventory under their own brand.
  • The affiliate — someone the owner has brought on to promote that inventory through their own tracked links: a creator, a smaller publisher, a partner site. The owner decides what each of them earns; we just track and report it.
  • Affilitera — us. We track the click, resolve who gets what, and report it back through this API. We take our own cut off the top of every sale, the same way an owner takes a cut before one of their affiliates sees anything.

One sale, four numbers

Say Amy, an affiliate registered under a SubNetwork owner called Northshore Media, posts a tracked link to a €200.00 order at Verve Skincare — an Awin advertiser Northshore Media has connected. Awin reports the sale and its commission the way it always does: a pool of €10.00 (5% of the order), paid out on Awin’s own schedule to whoever holds the Awin account, which is Northshore Media, not Amy and not us. Nobody downstream of Awin ever touches that money directly — what this API reports is our record of how that pool should be divided, not a transfer of funds.

The split runs as a waterfall off the top of that €10.00 pool:

  1. Affilitera takes its own cut first — say 20% of the pool: €2.00. €8.00 remains.
  2. Amy’s affiliate rate applies to what is left — say 25%: €2.00.
  3. Northshore Media, as the owner, keeps whatever is left over: €6.00.

Every tier adds back up to the original pool, always: €2.00 + €2.00 + €6.00 = €10.00. That is not a coincidence — each tier takes a percentage of what is left after the tier before it, and the last party in line simply keeps the remainder, to the cent.

Why the same sale produces different numbers for different readers

This API reports two of those four numbers, and which two depends on whose key you are using — never all four, and never Affilitera’s own cut.

Northshore Media, the owner, authenticates with an instance key (snk_…, minted from the Affilitera app) and reads GET /subnetwork/reporting/transactions and GET /subnetwork/reporting/summary. Those return pool_commission (the whole €10.00 Awin reported), computed_owner_split (Northshore’s own €6.00), and computed_sub_split (what an affiliate — Amy — earned, €2.00). An owner sees the full picture for their own connections, because it is their Awin account and their affiliate to manage.

Amy, the affiliate, authenticates with a completely different key, on a completely different endpointGET /affiliate/transactions, outside this reference, on the main-network affiliate API. It returns exactly one money field for this sale, your_share: €2.00. Amy never sees the €10.00 pool, never sees Northshore Media’s €6.00, and never sees Affilitera’s cut — only her own number. If you are building for one of those affiliates rather than for an owner, this is not the reference you want; ask the owner you are integrating with for the affiliate-side documentation instead.

And Affilitera’s own €2.00 is not a field anywhere on either surface. It is the one number in the waterfall neither of you needs to see, because it is not paid to either of you — it is ours. If you want it, it is pool_commission − computed_owner_split − computed_sub_split, but we do not hand it to you directly, on purpose.

Two currencies on every reporting row

Every transaction and summary row carries both currency and split_currency, and they usually agree. They are not the same field wearing two names, though, and the difference is worth understanding before you write a parser that only reads one of them.

currency is whatever we currently have on file for the order itself — set when the network reports the sale, and occasionally corrected afterward if the network corrects it. split_currency is the currency pool_commission, computed_owner_split and computed_sub_split are actually denominated in, stamped at the moment we last resolved the split — which can be earlier than the order’s currency was last touched. A network correcting an order’s currency updates currency immediately; the split itself is not recomputed until the next reconciliation pass picks the order back up, and until it does, the two fields can legitimately disagree.

Parse the three split amounts against split_currency, never against currency. It is the field that is actually true of the money the amounts represent — currency can be a step ahead of it.

null means not yet accrued — never zero

pool_commission, computed_owner_split, computed_sub_split and split_currency are null together, on any transaction we have not resolved a split for yet — most commonly a sale from the last day or two, before the network has confirmed the commission and our reconciler has run against it. order_value and currency are populated immediately, because those come from the order itself and not from the split; only the split fields wait.

Treat null as we do not have this yet, ask again later — never as €0.00. A €0.00 split is a real, different fact: it means we resolved the split and the result was genuinely nothing (a 0% rate, for instance). Coercing a null to zero in your own code will make a sale that has not accrued yet look identical to one that accrued nothing, and those are not the same event.

The same rule holds for every money field on this API: a missing amount is reported as null, never as 0 or "0", and every one of them is a decimal string ("6.00"), not a JSON number — parse with a decimal type. parseFloat("6.00") happens to work today; the moment an amount needs more precision than a double can hold, it silently will not, and nothing will tell you.

The split is frozen to the transaction date

Every rate that feeds a split — Affilitera’s own cut, an affiliate’s share — is looked up as of the order’s transaction date, not as of whenever the split happens to be computed or whenever you happen to be reading it. If Northshore Media renegotiates Amy’s rate next month, this sale’s numbers do not move; only sales from the effective date of the new rate onward use it.

That has a real consequence for reconciliation: if you keep your own record of what rate was in effect on May 18th and it does not precisely match what actually was, your numbers will disagree with ours on that sale — and they will go on disagreeing indefinitely, because neither side recomputes an old, settled split against new information. If a number here ever looks wrong to you, check whether your own rate history agrees with ours as of the transaction date, not as of today.

There is no balance, payable, or owed field

You will not find one, and that is deliberate, not an omission. Affilitera does not disburse SubNetwork commission — Northshore Media, in the example above, is paid by Awin directly, on Awin’s own schedule, because it is Northshore’s Awin account. Everything under /reporting/* is informational: it tells you how a sale’s commission was divided, not what anyone is owed or when they will see it. If you are looking for a running balance, it does not exist on this API — track payments to your own affiliates with GET/POST /subnetwork/payouts instead, which is a record you maintain yourself, not money Affilitera moves.

Base URL and authentication

Every request goes to https://app.affilitera.com/api/v1 over HTTPS and carries your key as a bearer token. Keys are issued from the Affilitera app; there is no self-service enrolment endpoint.

curl — your first request
curl -sS "https://app.affilitera.com/api/v1/subnetwork/instance" \
  -H "Authorization: Bearer $AFFILITERA_API_KEY"

That returns the network instances your key authorises. The scope of a key is derived solely from the key and can never be named in a request, so there is nothing to enumerate: asking for another owner's brand or feed returns 404 or an empty page.

Pagination

Every list endpoint takes limit and cursor and returns next_cursor.

Default page size50
Maximum page size200 (100 on reporting endpoints — their per-row cost is higher)
Over the maximumClamped, not rejected. You get a valid first page, and the response says so: limit, limit_clamped: true, limit_max.
Not a positive integer400 invalid_parameter, naming the parameter.

cursor is opaque. Pass back next_cursor verbatim; do not construct, parse or increment it. An unrecognised cursor restarts from the beginning rather than erroring, so a truncated cursor cannot wedge your integration.

Ordering is deterministic and always ends on a unique tiebreak, so no row can be dropped or duplicated at a page boundary.

Pulling a full catalogue

Loop until next_cursor is null. Never loop on total — it is an approximate planner estimate on most endpoints, a display hint rather than a count. Where has_more is present it is exactly next_cursor !== null, so it is a convenience, not a second signal — and it is the one condition below that also holds on the two endpoints in the amber note further down that do not return has_more at all.

bash + jq — every brand, no gaps, no duplicates
#!/usr/bin/env bash
set -euo pipefail

CURSOR=""
PAGE=0
TOTAL=0

while :; do
  RESP=$(curl -sS -G "https://app.affilitera.com/api/v1/subnetwork/brands" \
    -H "Authorization: Bearer $AFFILITERA_API_KEY" \
    --data-urlencode "limit=200" \
    ${CURSOR:+--data-urlencode "cursor=$CURSOR"})

  COUNT=$(echo "$RESP" | jq '.data | length')
  PAGE=$((PAGE + 1)); TOTAL=$((TOTAL + COUNT))
  echo "page $PAGE: $COUNT brands (running total $TOTAL)" >&2

  echo "$RESP" | jq -c '.data[]'

  CURSOR=$(echo "$RESP" | jq -r '.next_cursor // empty')
  [ -z "$CURSOR" ] && break        # <-- the ONLY correct termination condition
done

echo "done: $TOTAL brands over $PAGE pages" >&2
node — the same loop, with 429 and 503 handled
const BASE = "https://app.affilitera.com/api/v1";
const KEY = process.env.AFFILITERA_API_KEY;

async function* brands(params = {}) {
  let cursor = null;
  do {
    const qs = new URLSearchParams({ limit: "200", ...params });
    if (cursor) qs.set("cursor", cursor);

    const res = await fetch(`${BASE}/subnetwork/brands?${qs}`, {
      headers: { Authorization: `Bearer ${KEY}` },
    });

    // 429 (throttled) and 503 (transient read timeout) both carry Retry-After
    // and both mean "retry this same page". A 500 does not — that one is a real fault.
    if (res.status === 429 || res.status === 503) {
      const wait = Number(res.headers.get("Retry-After") ?? 2);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    if (!res.ok) {
      const body = await res.json();
      throw new Error(
        `${body.error.code}: ${body.error.message} (request_id ${body.error.request_id})`,
      );
    }

    const page = await res.json();
    if (page.limit_clamped) {
      console.warn(`limit clamped to ${page.limit} (max ${page.limit_max})`);
    }
    yield* page.data;
    cursor = page.next_cursor;
  } while (cursor !== null);
}

let n = 0;
for await (const brand of brands({ has_products: "true" })) n++;
console.log(`pulled ${n} brands`);

Response envelope

One shape across every list endpoint.

{
  "data": [ /* … */ ],
  "next_cursor": "eyJuYW1lIjoiQWNtZSIsImlkIjoiOWY4…",
  "has_more": true,
  "total": 862,
  "limit": 200,
  "limit_clamped": true,
  "limit_max": 200,
  "request_id": "req_a90fa758cb0447d8"
}

New fields may be added to any response without notice — that is not a breaking change, so please ignore fields you do not recognise rather than validating strictly against a closed shape.

Two endpoints are still on the older envelope. GET /subnetwork/contracts and GET /subnetwork/reporting/transactions return next_cursor and total but not yet has_more, limit, limit_clamped, limit_max or an in-body request_id — the X-Request-ID header is still there. Page them the same way, looping while next_cursor is non-null. We would rather say so here than have this page describe fields you will not receive.

Errors

One shape on every non-2xx response.

{
  "error": {
    "code": "invalid_parameter",
    "message": "limit must be a positive integer (got \"abc\"). Valid range 1-200; values above 200 are clamped to 200.",
    "request_id": "req_a90fa758cb0447d8"
  },
  "parameter": "limit"
}

code is machine-readable and stable — branch on it. message is for humans and may change.

CodeHTTPMeaning
invalid_parameter400A parameter is malformed. The message names it, and a `parameter` field repeats it.
filter_too_broad400The filter matches too many values to evaluate. Narrow it.
unauthorized401Missing, malformed, expired, revoked or unresolvable API key.
insufficient_scope403Valid key, but it lacks the scope this endpoint requires.
link_locked403The connection behind this key is deactivated; inventory is locked.
not_found404No such resource within this key's scope. Also returned for a path no v1 endpoint serves.
method_not_allowed405The path exists but does not accept this HTTP verb. The `Allow` response header lists the ones it does.
conflict409The resource is in a state that forbids the operation.
not_registered409The affiliate is not registered on the target network.
job_not_ready409The export job has not finished yet.
job_not_failed409Only a failed job can be retried.
max_retries_exceeded409The job has exhausted its retries.
link_not_supported409Brand/connector cannot carry the requested link. Body also has "kind": "blocked".
merchant_unavailable409The advertiser is platform-deactivated, its program has ended, or your owner hid it.
domain_ambiguous409The domain matched more than one advertiser in your scope. Resubmit with network_slug from candidates[].
product_unavailable409The product's recorded destination is absent, blank, unparseable, or was dropped by the connector's link format.
validation_error422Syntactically valid, semantically impossible.
not_supported422Not supported for this network or brand.
rate_limited429Throttled. Always carries Retry-After.
internal_error500A genuine, non-transient server fault. Quote the request_id.
service_unavailable503A dependency is unavailable.
timeout503A read exceeded its time budget. Transient — retry after Retry-After.

A 5xx for anything you could have sent differently is a bug on our side — please report it. Note that timeout is a 503, not a 500, precisely because it is worth retrying; a 500 means the opposite.

Rate limits

Two tiers per key, both enforced. Exceeding either returns 429 rate_limited with Retry-After, plus X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Throttling never surfaces as a 5xx.

TierWindowRequests
Standard1 second10
Standard1 hour3,000
Reporting1 second4
Reporting1 hour600

Reporting a problem

Every response — success and error alike — carries a request id, in the X-Request-ID header and in the body (request_id at the top level on success, inside error on failure).

Please quote it when you report a problem. The underlying cause of any 5xx is logged on our side against that id, so it is enough for us to recover the full cause without any further detail from you.

Endpoint reference

Generated from the OpenAPI spec. Every endpoint requires the bearer key described above.

Instance

Answers one question: what can this specific key see? Call it first when wiring up a new key — it is cheap, needs only instance:read, and lists every connection (network, status, capabilities) the key resolves to, whether it is scoped to one connection or your whole container.

get/subnetwork/instanceinstance:read

The instance set this key authorises

Returns every network instance the key can reach, with its connector slug, status and capabilities. Readable even when an instance is deactivated — it reports the status. Also the only place a credential holder can read their OWN key's `scopes` and `key_expires_at` — before this was added, neither was visible anywhere on the API, so an integrator had to guess why a call 403'd or discover an expiry only after the key started 401'ing.

get/subnetwork/instance/summaryinstance:read

Headline counts for the key's instance set

Catalogue

Your advertiser and product inventory — which brands you can generate links for, and what they are selling. Reads need catalogue:read.

get/subnetwork/brandscatalogue:read

List brands in this key's inventory

The canonical catalogue endpoint. Page through it with `limit` + `cursor` to pull a full catalogue — see the worked example on https://affilitera.com/docs/api. All filters are AND-ed and can only narrow *within* the key's scope.

ParameterInTypeRequiredNotes
limitqueryintegerno · default 50Page size. Default 50, maximum 200. A value ABOVE the maximum is CLAMPED to it, not rejected — the response reports the applied size in `limit` and sets `limit_clamped: true`. A value that is not a positive integer returns 400 `invalid_parameter`.
cursorquerystringnoOPAQUE keyset cursor. Pass back the `next_cursor` from the previous page verbatim. Do not construct, parse or increment it — its internal shape is not part of the contract. An unrecognised cursor restarts from the beginning rather than erroring. ONE EXCEPTION, on `/subnetwork/links/domains` only: that endpoint previously minted a bare decimal row offset as its cursor and still honours one for backward compatibility, but refuses one deeper than 10000 rows with `400 invalid_parameter`. Past that depth the underlying offset read exceeds the database statement timeout and fails identically on every retry, so it is reported as the permanent condition it is rather than as a retryable error; restart the pull with no cursor. Keyset cursors — everything `next_cursor` mints today, on every endpoint — do not degrade with depth and are never refused this way.
qquerystringnoCase-insensitive substring match on the brand name. An empty value is treated as absent.
countryquerystringnoISO-3166 alpha-2 merchant country, case-insensitive. An empty value is treated as absent.
has_productsquerystring (true | 1 | yes | on)noRestricts the result to brands for which we hold at least one INGESTED product. ACCEPTED VALUES ARE `true`, `1`, `yes` and `on`, compared case-insensitively after trimming; they all mean the same thing. OMITTING the parameter, or sending it EMPTY (`?has_products=`), applies no filter. EVERY OTHER VALUE IS REFUSED WITH 422 `validation_error` — it is NOT ignored. That includes the false-family spellings `false`, `0`, `no` and `off`, which are refused with their own message: this endpoint cannot filter to brands WITHOUT ingested products, because that negative set is a different and far more expensive query, so it is declined rather than silently served as the unfiltered set. There is no "brands without products" filter; filter client-side on each row's `has_products` field if you need one. Note this is authoritative about what we actually hold, unlike the `product_count` field, which is the figure the upstream network advertises — a brand can legitimately return `product_count: 0` with `has_products: true`. MUTUALLY EXCLUSIVE WITH `modified_since`: sending `has_products=true` together with `modified_since` returns 422 `validation_error` with the message `modified_since is not yet supported combined with has_products. Omit one.` The two take different query paths and the has_products path does not apply the modification filter, so the combination is refused rather than served as a page the caller would wrongly believe was filtered. For an incremental catalogue sync send `modified_since` ALONE and filter on the returned rows.
categoryquerystringnoSubstring match on the retail category path or leaf name. An over-broad value returns 400 `filter_too_broad`.
min_trafficqueryintegernoOnly brands whose domain has at least this many estimated monthly visits. An over-broad value returns 400 `filter_too_broad`.
networkquerystringnoNarrow to one connector slug within the key's scope (e.g. `awin`). A slug outside the scope returns an empty page, never another owner's data.
relationship_statusquerystring (approved | pending | declined | removed | ended | none | unknown)noNarrow to brands at one or more partnership states, using Affilitera's NORMALISED vocabulary — the same values the `relationship_status` response field carries. COMMA-SEPARATE for several (`?relationship_status=approved,pending`), because "approved or pending" is the query a caller actually wants and one value per request would force paging the whole catalogue twice and merging. An empty value is treated as absent, matching `q` and `country`. An UNRECOGNISED value is refused with 422 `validation_error` naming the valid set — never silently ignored, because a filter that quietly does nothing returns a page the caller would wrongly believe was narrowed. THESE ARE OUR TOKENS, NOT THE NETWORK'S OWN WORDS: Awin says `joined`, Linkbux says `No Relationship`, Rakuten says `temp-decline`. Filter on this normalised field and read `relationship_status_raw` for the vendor's verbatim term. A brand whose relationship has never been evaluated carries `null` and is matched by NO value of this filter.
modified_sincequerystringnoISO 8601 timestamp. Restricts the result to rows updated at or after this instant, for incremental sync instead of re-walking the full catalogue. An unparseable value returns 422 `validation_error`. For the NEXT poll, use the `modified_as_of` value from THIS response — never wall-clock time captured client-side; `modified_as_of` is stamped before the query runs, so a row updated in the gap between that stamp and the response arriving is still included next poll rather than silently missed. ORDERING: when this parameter is present the collection is ordered by modification time ascending (oldest change first) rather than by the endpoint's default sort, and `next_cursor` is minted in that same order — this is what makes the filter indexable. CURSORS DO NOT CROSS MODES: a cursor obtained WITH `modified_since` is valid only on requests that also pass it, and one obtained without it only on requests that omit it; mixing them returns 422 `validation_error` rather than a wrongly-filtered page. COMPLETENESS: some older rows carry an `updated_at` earlier than their true last change, from a period when that column was maintained by application writers rather than by a database trigger, and no backfill was performed. Nothing on the wire distinguishes such a row, so take ONE FULL PULL before relying on this parameter for incremental sync.
get/subnetwork/productscatalogue:read

List products in this key's inventory

ParameterInTypeRequiredNotes
limitqueryintegerno · default 50Page size. Default 50, maximum 200. A value ABOVE the maximum is CLAMPED to it, not rejected — the response reports the applied size in `limit` and sets `limit_clamped: true`. A value that is not a positive integer returns 400 `invalid_parameter`.
cursorquerystringnoOPAQUE keyset cursor. Pass back the `next_cursor` from the previous page verbatim. Do not construct, parse or increment it — its internal shape is not part of the contract. An unrecognised cursor restarts from the beginning rather than erroring. ONE EXCEPTION, on `/subnetwork/links/domains` only: that endpoint previously minted a bare decimal row offset as its cursor and still honours one for backward compatibility, but refuses one deeper than 10000 rows with `400 invalid_parameter`. Past that depth the underlying offset read exceeds the database statement timeout and fails identically on every retry, so it is reported as the permanent condition it is rather than as a retryable error; restart the pull with no cursor. Keyset cursors — everything `next_cursor` mints today, on every endpoint — do not degrade with depth and are never refused this way.
brand_idquerystringnoRestrict to one brand. Accepts EITHER the brand's minted `brand_public_id` (`brd_…`, the durable identity — preferred) OR its raw `brand_id` uuid. Both forms select the same rows. A `brd_…` value naming no brand is a `validation_error` (422), never an empty page; a value that is neither form is also a 422.
feed_idquerystringnoRestrict to one feed.
modified_sincequerystringnoISO 8601 timestamp. Restricts the result to rows updated at or after this instant, for incremental sync instead of re-walking the full catalogue. An unparseable value returns 422 `validation_error`. For the NEXT poll, use the `modified_as_of` value from THIS response — never wall-clock time captured client-side; `modified_as_of` is stamped before the query runs, so a row updated in the gap between that stamp and the response arriving is still included next poll rather than silently missed. ORDERING: when this parameter is present the collection is ordered by modification time ascending (oldest change first) rather than by the endpoint's default sort, and `next_cursor` is minted in that same order — this is what makes the filter indexable. CURSORS DO NOT CROSS MODES: a cursor obtained WITH `modified_since` is valid only on requests that also pass it, and one obtained without it only on requests that omit it; mixing them returns 422 `validation_error` rather than a wrongly-filtered page. COMPLETENESS: some older rows carry an `updated_at` earlier than their true last change, from a period when that column was maintained by application writers rather than by a database trigger, and no backfill was performed. Nothing on the wire distinguishes such a row, so take ONE FULL PULL before relying on this parameter for incremental sync.
include_delistedquerystring (true)noInclude products whose `status` is `delisted`. Delisted rows are EXCLUDED by default: a delisted product is one the feed STOPPED carrying, so its `destination_url` is expected to 404, and serving it unmarked hands you a dead link. The literal string `true` is the ONLY value that opts in — `1`, `yes`, `TRUE` and a bare `?include_delisted` all leave the default exclusion in place rather than silently widening the result set. When opted in, read `status` on each row to tell live from delisted.
get/subnetwork/products/searchcatalogue:read

Search products by name

Identical to `/subnetwork/products` but requires `q`. The key's scope is applied BEFORE the search, so a search term can only narrow within your own catalogue.

ParameterInTypeRequiredNotes
limitqueryintegerno · default 50Page size. Default 50, maximum 200. A value ABOVE the maximum is CLAMPED to it, not rejected — the response reports the applied size in `limit` and sets `limit_clamped: true`. A value that is not a positive integer returns 400 `invalid_parameter`.
cursorquerystringnoOPAQUE keyset cursor. Pass back the `next_cursor` from the previous page verbatim. Do not construct, parse or increment it — its internal shape is not part of the contract. An unrecognised cursor restarts from the beginning rather than erroring. ONE EXCEPTION, on `/subnetwork/links/domains` only: that endpoint previously minted a bare decimal row offset as its cursor and still honours one for backward compatibility, but refuses one deeper than 10000 rows with `400 invalid_parameter`. Past that depth the underlying offset read exceeds the database statement timeout and fails identically on every retry, so it is reported as the permanent condition it is rather than as a retryable error; restart the pull with no cursor. Keyset cursors — everything `next_cursor` mints today, on every endpoint — do not degrade with depth and are never refused this way.
qquerystringyesCase-insensitive substring match on the product name. Required — omitting it returns 422 `validation_error`. By default this does NOT match the brand name — pass `include_brand_match=true` to also match it.
brand_idquerystringnoRestrict to one brand. Accepts EITHER the brand's minted `brand_public_id` (`brd_…`, the durable identity — preferred) OR its raw `brand_id` uuid. Both forms select the same rows. A `brd_…` value naming no brand is a `validation_error` (422), never an empty page; a value that is neither form is also a 422.
feed_idquerystringnoRestrict the search to ONE ingested product feed, by the `feed_id` that `GET /subnetwork/feeds` emits. Ours, not the network's. Omit to search every feed your key can reach.
modified_sincequerystringnoISO 8601 timestamp. Restricts the result to rows updated at or after this instant, for incremental sync instead of re-walking the full catalogue. An unparseable value returns 422 `validation_error`. For the NEXT poll, use the `modified_as_of` value from THIS response — never wall-clock time captured client-side; `modified_as_of` is stamped before the query runs, so a row updated in the gap between that stamp and the response arriving is still included next poll rather than silently missed. ORDERING: when this parameter is present the collection is ordered by modification time ascending (oldest change first) rather than by the endpoint's default sort, and `next_cursor` is minted in that same order — this is what makes the filter indexable. CURSORS DO NOT CROSS MODES: a cursor obtained WITH `modified_since` is valid only on requests that also pass it, and one obtained without it only on requests that omit it; mixing them returns 422 `validation_error` rather than a wrongly-filtered page. COMPLETENESS: some older rows carry an `updated_at` earlier than their true last change, from a period when that column was maintained by application writers rather than by a database trigger, and no backfill was performed. Nothing on the wire distinguishes such a row, so take ONE FULL PULL before relying on this parameter for incremental sync.
include_delistedquerystring (true)noInclude products whose `status` is `delisted`. Delisted rows are EXCLUDED by default: a delisted product is one the feed STOPPED carrying, so its `destination_url` is expected to 404, and serving it unmarked hands you a dead link. The literal string `true` is the ONLY value that opts in — `1`, `yes`, `TRUE` and a bare `?include_delisted` all leave the default exclusion in place rather than silently widening the result set. When opted in, read `status` on each row to tell live from delisted.
sortquerystring (name | relevance)no`name` (DEFAULT — the original alphabetical order; unchanged, so no existing integration is affected by this parameter's addition) or `relevance` (OPT-IN — ranks by name/description trigram similarity, name weighted 0.7 / description 0.3; description is matched against `raw->>'description'` only, not the full multi-key alias chain the response's `description` field itself draws from, so recall is a documented subset). EXPERIMENTAL: relevance ranking can be measurably slow for a common, short query word — see the product docs before recommending it for high-QPS or latency-sensitive use. Any other value is a `validation_error` (422) naming the parameter.
min_pricequerystringnoMinimum price, inclusive. PER-CURRENCY ONLY — there is no FX conversion on this endpoint, so `currency` is REQUIRED whenever `min_price` or `max_price` is supplied (422 `validation_error` naming `currency` otherwise). A row whose `currency` is unset never matches a price filter. Non-negative decimal; `min_price` greater than `max_price` is a 422.
max_pricequerystringnoMaximum price, inclusive. Same currency requirement as `min_price`.
currencyquerystringnoISO-4217-shaped currency code, exact match. Required alongside `min_price`/`max_price`; may also be supplied alone as a plain narrowing filter with no price band.
categoryquerystringnoCase-insensitive substring match on the network's OWN category string (`category` on the Product schema) — the network's own vocabulary, NOT the canonical brand/category taxonomy `/brands?category=` uses. Populated on roughly 73% of rows; a row with no category never matches this filter.
include_brand_matchquerystring (true | false)noOPT-IN, default false (the ORIGINAL behaviour — q matches the product name only, unaffected by this parameter's addition). When true, q ALSO matches the product's brand name. CURRENTLY `sort=name` ONLY — combined with `sort=relevance` it is refused with `422 validation_error` (the underlying RPC is already applied to prod and was not extended this pass; a later change may lift this). Not specially demoted, so a broad brand-name term can return many same-brand rows in (name, id) order. A term matching more than roughly 200 distinct brands is refused with `filter_too_broad` (400) rather than silently narrowed.
get/subnetwork/feedscatalogue:read

List product feeds

ParameterInTypeRequiredNotes
limitqueryintegerno · default 50Page size. Default 50, maximum 200. A value ABOVE the maximum is CLAMPED to it, not rejected — the response reports the applied size in `limit` and sets `limit_clamped: true`. A value that is not a positive integer returns 400 `invalid_parameter`.
cursorquerystringnoOPAQUE keyset cursor. Pass back the `next_cursor` from the previous page verbatim. Do not construct, parse or increment it — its internal shape is not part of the contract. An unrecognised cursor restarts from the beginning rather than erroring. ONE EXCEPTION, on `/subnetwork/links/domains` only: that endpoint previously minted a bare decimal row offset as its cursor and still honours one for backward compatibility, but refuses one deeper than 10000 rows with `400 invalid_parameter`. Past that depth the underlying offset read exceeds the database statement timeout and fails identically on every retry, so it is reported as the permanent condition it is rather than as a retryable error; restart the pull with no cursor. Keyset cursors — everything `next_cursor` mints today, on every endpoint — do not degrade with depth and are never refused this way.
modified_sincequerystringnoISO 8601 timestamp. Restricts the result to rows updated at or after this instant, for incremental sync instead of re-walking the full catalogue. An unparseable value returns 422 `validation_error`. For the NEXT poll, use the `modified_as_of` value from THIS response — never wall-clock time captured client-side; `modified_as_of` is stamped before the query runs, so a row updated in the gap between that stamp and the response arriving is still included next poll rather than silently missed. ORDERING: when this parameter is present the collection is ordered by modification time ascending (oldest change first) rather than by the endpoint's default sort, and `next_cursor` is minted in that same order — this is what makes the filter indexable. CURSORS DO NOT CROSS MODES: a cursor obtained WITH `modified_since` is valid only on requests that also pass it, and one obtained without it only on requests that omit it; mixing them returns 422 `validation_error` rather than a wrongly-filtered page. COMPLETENESS: some older rows carry an `updated_at` earlier than their true last change, from a period when that column was maintained by application writers rather than by a database trigger, and no backfill was performed. Nothing on the wire distinguishes such a row, so take ONE FULL PULL before relying on this parameter for incremental sync.
get/subnetwork/feeds/{feed_id}/productscatalogue:read

List the products in one feed

ParameterInTypeRequiredNotes
feed_idpathstringyesA feed inside this key's scope. A feed belonging to anyone else returns 404.
limitqueryintegerno · default 50Page size. Default 50, maximum 200. A value ABOVE the maximum is CLAMPED to it, not rejected — the response reports the applied size in `limit` and sets `limit_clamped: true`. A value that is not a positive integer returns 400 `invalid_parameter`.
cursorquerystringnoOPAQUE keyset cursor. Pass back the `next_cursor` from the previous page verbatim. Do not construct, parse or increment it — its internal shape is not part of the contract. An unrecognised cursor restarts from the beginning rather than erroring. ONE EXCEPTION, on `/subnetwork/links/domains` only: that endpoint previously minted a bare decimal row offset as its cursor and still honours one for backward compatibility, but refuses one deeper than 10000 rows with `400 invalid_parameter`. Past that depth the underlying offset read exceeds the database statement timeout and fails identically on every retry, so it is reported as the permanent condition it is rather than as a retryable error; restart the pull with no cursor. Keyset cursors — everything `next_cursor` mints today, on every endpoint — do not degrade with depth and are never refused this way.
modified_sincequerystringnoISO 8601 timestamp. Restricts the result to rows updated at or after this instant, for incremental sync instead of re-walking the full catalogue. An unparseable value returns 422 `validation_error`. For the NEXT poll, use the `modified_as_of` value from THIS response — never wall-clock time captured client-side; `modified_as_of` is stamped before the query runs, so a row updated in the gap between that stamp and the response arriving is still included next poll rather than silently missed. ORDERING: when this parameter is present the collection is ordered by modification time ascending (oldest change first) rather than by the endpoint's default sort, and `next_cursor` is minted in that same order — this is what makes the filter indexable. CURSORS DO NOT CROSS MODES: a cursor obtained WITH `modified_since` is valid only on requests that also pass it, and one obtained without it only on requests that omit it; mixing them returns 422 `validation_error` rather than a wrongly-filtered page. COMPLETENESS: some older rows carry an `updated_at` earlier than their true last change, from a period when that column was maintained by application writers rather than by a database trigger, and no backfill was performed. Nothing on the wire distinguishes such a row, so take ONE FULL PULL before relying on this parameter for incremental sync.
include_delistedquerystring (true)noInclude products whose `status` is `delisted`. Delisted rows are EXCLUDED by default: a delisted product is one the feed STOPPED carrying, so its `destination_url` is expected to 404, and serving it unmarked hands you a dead link. The literal string `true` is the ONLY value that opts in — `1`, `yes`, `TRUE` and a bare `?include_delisted` all leave the default exclusion in place rather than silently widening the result set. When opted in, read `status` on each row to tell live from delisted.

Promotions

Marketing creative — coupons, offers, and banner or text creatives — each already carrying a sub-attributed link, ready to publish. Reads need promotions:read.

get/subnetwork/couponspromotions:read

List coupon promotions

Coupon promotions currently IN FORCE for the merchants in your scope. CHANGED 2026-09-04 — WHAT THIS ENDPOINT RETURNS IS NOW A SMALLER SET. Until this date the response contained every stored promotion regardless of its dates, including ones that had already expired; 10.1% of the published surface was expired or not yet started. It now returns only rows whose stated window contains the request instant: `starts_at` is null or in the past, AND `ends_at` is null or in the future. A null bound means the network stated no bound and never excludes a row. WHAT A PROMOTION'S ABSENCE MEANS — AND WHAT IT DOES NOT. A coupon that is absent from this response is a coupon we are not publishing to you RIGHT NOW. It is not a coupon that never existed. It is NOT, however, a promise that the row is still stored: an expired promotion is subject to deletion under a separate retention rule, so a coupon that has passed its `ends_at` may have been removed permanently and must not be assumed to reappear if the network later re-dates it. A coupon absent only because it has not yet started is not subject to that rule and appears once its `starts_at` has passed. Do not treat a disappearance as a signal that the merchant withdrew the offer, that the code was invalid, or that your earlier record of it was wrong. If you cache rows, expire your cache on the promotion's own `ends_at`; a row you still hold past its `ends_at` will not be refreshed by us and may no longer exist here. The filter compares absolute instants in UTC and is inclusive at both bounds.

ParameterInTypeRequiredNotes
cursorquerystringnoOPAQUE keyset cursor. Pass back the `next_cursor` from the previous page verbatim. Do not construct, parse or increment it — its internal shape is not part of the contract. An unrecognised cursor restarts from the beginning rather than erroring. ONE EXCEPTION, on `/subnetwork/links/domains` only: that endpoint previously minted a bare decimal row offset as its cursor and still honours one for backward compatibility, but refuses one deeper than 10000 rows with `400 invalid_parameter`. Past that depth the underlying offset read exceeds the database statement timeout and fails identically on every retry, so it is reported as the permanent condition it is rather than as a retryable error; restart the pull with no cursor. Keyset cursors — everything `next_cursor` mints today, on every endpoint — do not degrade with depth and are never refused this way.
limitqueryintegerno · default 50Page size. Default 50, maximum 200. A value ABOVE the maximum is CLAMPED to it, not rejected — the response reports the applied size in `limit` and sets `limit_clamped: true`. A value that is not a positive integer returns 400 `invalid_parameter`.
brand_idquerystringnoRestrict to one brand. Accepts EITHER the brand's minted `brand_public_id` (`brd_…`, the durable identity — preferred) OR its raw `brand_id` uuid. Both forms select the same rows. A `brd_…` value naming no brand is a `validation_error` (422), never an empty page; a value that is neither form is also a 422.
offer_typequerystringnoRestrict to one offer type (connector-supplied, e.g. `sale`/`free_shipping` on CJ). Applies equally on `/coupons` and `/offers` — it is not restricted to non-coupon rows.
get/subnetwork/creativespromotions:read

List network creatives

ParameterInTypeRequiredNotes
cursorquerystringnoOPAQUE keyset cursor. Pass back the `next_cursor` from the previous page verbatim. Do not construct, parse or increment it — its internal shape is not part of the contract. An unrecognised cursor restarts from the beginning rather than erroring. ONE EXCEPTION, on `/subnetwork/links/domains` only: that endpoint previously minted a bare decimal row offset as its cursor and still honours one for backward compatibility, but refuses one deeper than 10000 rows with `400 invalid_parameter`. Past that depth the underlying offset read exceeds the database statement timeout and fails identically on every retry, so it is reported as the permanent condition it is rather than as a retryable error; restart the pull with no cursor. Keyset cursors — everything `next_cursor` mints today, on every endpoint — do not degrade with depth and are never refused this way.
limitqueryintegerno · default 50Page size. Default 50, maximum 200. A value ABOVE the maximum is CLAMPED to it, not rejected — the response reports the applied size in `limit` and sets `limit_clamped: true`. A value that is not a positive integer returns 400 `invalid_parameter`.
brand_idquerystringnoRestrict to one brand. Accepts EITHER the brand's minted `brand_public_id` (`brd_…`, the durable identity — preferred) OR its raw `brand_id` uuid. Both forms select the same rows. A `brd_…` value naming no brand is a `validation_error` (422), never an empty page; a value that is neither form is also a 422.
kindquerystring (image_banner | text_link)noRestrict to one creative kind. Any other value is a `validation_error` (422) naming the parameter — never a silently empty page, which would read as though the merchant had published no banners.
get/subnetwork/offerspromotions:read

List non-coupon offers

Code-less offers currently IN FORCE for the merchants in your scope. CHANGED 2026-09-04 — WHAT THIS ENDPOINT RETURNS IS NOW A SMALLER SET. Until this date the response contained every stored promotion regardless of its dates, including ones that had already expired; 10.1% of the published surface was expired or not yet started. It now returns only rows whose stated window contains the request instant: `starts_at` is null or in the past, AND `ends_at` is null or in the future. A null bound means the network stated no bound and never excludes a row. WHAT AN OFFER'S ABSENCE MEANS — AND WHAT IT DOES NOT. An offer that is absent from this response is an offer we are not publishing to you RIGHT NOW. It is not an offer that never existed. It is NOT, however, a promise that the row is still stored: an expired promotion is subject to deletion under a separate retention rule, so an offer that has passed its `ends_at` may have been removed permanently and must not be assumed to reappear if the network later re-dates it. An offer absent only because it has not yet started is not subject to that rule and appears once its `starts_at` has passed. Do not treat a disappearance as a signal that the merchant withdrew the offer or that your earlier record of it was wrong. If you cache rows, expire your cache on the promotion's own `ends_at`; a row you still hold past its `ends_at` will not be refreshed by us and may no longer exist here. The filter compares absolute instants in UTC and is inclusive at both bounds.

ParameterInTypeRequiredNotes
cursorquerystringnoOPAQUE keyset cursor. Pass back the `next_cursor` from the previous page verbatim. Do not construct, parse or increment it — its internal shape is not part of the contract. An unrecognised cursor restarts from the beginning rather than erroring. ONE EXCEPTION, on `/subnetwork/links/domains` only: that endpoint previously minted a bare decimal row offset as its cursor and still honours one for backward compatibility, but refuses one deeper than 10000 rows with `400 invalid_parameter`. Past that depth the underlying offset read exceeds the database statement timeout and fails identically on every retry, so it is reported as the permanent condition it is rather than as a retryable error; restart the pull with no cursor. Keyset cursors — everything `next_cursor` mints today, on every endpoint — do not degrade with depth and are never refused this way.
limitqueryintegerno · default 50Page size. Default 50, maximum 200. A value ABOVE the maximum is CLAMPED to it, not rejected — the response reports the applied size in `limit` and sets `limit_clamped: true`. A value that is not a positive integer returns 400 `invalid_parameter`.
brand_idquerystringnoRestrict to one brand. Accepts EITHER the brand's minted `brand_public_id` (`brd_…`, the durable identity — preferred) OR its raw `brand_id` uuid. Both forms select the same rows. A `brd_…` value naming no brand is a `validation_error` (422), never an empty page; a value that is neither form is also a 422.
offer_typequerystringnoRestrict to one offer type (connector-supplied, e.g. `sale`/`free_shipping` on CJ).

Links

Generate and resolve the tracked deeplinks that carry an affiliate’s identity through to the network. Reads need trackedlinks:read; minting a link needs trackedlinks:write.

get/subnetwork/links/domainstrackedlinks:read

List the merchant domains this key can link to

Only brands with a recorded merchant domain are listed — a brand with no domain cannot be linked by domain. That exclusion is reported in `excluded_missing_domain`, so an empty `data` array is distinguishable from "you have brands, but we hold no domain for them".

ParameterInTypeRequiredNotes
cursorquerystringnoOPAQUE keyset cursor. Pass back the `next_cursor` from the previous page verbatim. Do not construct, parse or increment it — its internal shape is not part of the contract. An unrecognised cursor restarts from the beginning rather than erroring. ONE EXCEPTION, on `/subnetwork/links/domains` only: that endpoint previously minted a bare decimal row offset as its cursor and still honours one for backward compatibility, but refuses one deeper than 10000 rows with `400 invalid_parameter`. Past that depth the underlying offset read exceeds the database statement timeout and fails identically on every retry, so it is reported as the permanent condition it is rather than as a retryable error; restart the pull with no cursor. Keyset cursors — everything `next_cursor` mints today, on every endpoint — do not degrade with depth and are never refused this way.
limitqueryintegerno · default 50Page size. Default 50, maximum 200. A value ABOVE the maximum is CLAMPED to it, not rejected — the response reports the applied size in `limit` and sets `limit_clamped: true`. A value that is not a positive integer returns 400 `invalid_parameter`.
networkquerystringnoRestrict the listing to ONE network family, by its slug (e.g. `awin`) as emitted in the `network` field of these rows. NOTE THAT A SLUG IS NOT UNIQUE ACROSS YOUR CONNECTIONS: if you hold two accounts at the same network, this filter returns the domains from BOTH, and you must separate them yourself on `instance_id`. Omit to list every network your key can reach. It also narrows the scope that the envelope's `excluded_missing_domain` count is computed over.
get/subnetwork/subid-budgettrackedlinks:read

The per-connector sub1 character budget

How many characters a subid may be on each connector, so a payload can be sized BEFORE minting rather than discovering an overflow afterwards. THERE IS NO SINGLE SUB1 LENGTH LIMIT: documented ceilings differ by more than an order of magnitude across networks. `max_length` is what Affilitera enforces at mint time. When `max_length_documented` is true it is the network's own published ceiling and `source` cites it. When false, the network publishes no subid length we could find and the value is the conservative `undocumented_floor` — read that as "unknown, so we are being careful", NOT as evidence the network's real ceiling is that low, and never as licence to send more. An over-budget subid is REFUSED at mint with 422 `validation_error`; it is never truncated. A truncated subid still looks well-formed when the conversion returns, so it attributes to the wrong sub or to nothing at all and cannot be recovered. `truncates: true` marks a network documented to CUT an over-long value rather than reject it. When packing several registered subids into the one slot, segments are joined with `pack_delimiter` and the JOINED length is measured against `max_length`. This response is static reference data — identical for every caller, carrying no account, instance or transaction identity.

post/subnetwork/links/by-domaintrackedlinks:write

Mint a link from a merchant URL

ParameterInTypeRequiredNotes
Idempotency-KeyheaderstringnoMakes this write safe to retry. Send any unique string (a UUID is typical) and reuse it for the retry. A retry with the SAME key and a byte-identical body does NOT re-execute the write — it returns the stored response, with the original `request_id` still in the body and an `Idempotency-Replayed: true` response header. So a client that timed out and retried creates exactly one record. The SAME key with a DIFFERENT body returns 409 `conflict`; use a new key for a new request. A key whose first call is still in flight also returns 409 — retry once it settles. Keys are scoped to your API key (yours can never collide with another partner's) and retained 24 hours. A non-2xx stores nothing, so you may correct the request and retry with the same key. If the idempotency store itself is unreachable the request is REFUSED with a retryable 503 rather than executed without the guarantee you asked for.

Request body (required)

FieldTypeRequiredNotes
domainstringyesThe merchant's bare domain (not a full URL).
affiliate_idstringyesThe affiliate id to attribute the link to.
destination_urlstringyesThe full merchant page URL to deep-link to.
network_slugstringnoOPTIONAL. Disambiguates which merchant to use when the domain matches more than one (see the 409 domain_ambiguous response's candidates).
labelstringnoOPTIONAL, cosmetic only.
post/subnetwork/links/by-domain/bulktrackedlinks:write

Mint links from many merchant URLs in one call

ParameterInTypeRequiredNotes
Idempotency-KeyheaderstringnoMakes this write safe to retry. Send any unique string (a UUID is typical) and reuse it for the retry. A retry with the SAME key and a byte-identical body does NOT re-execute the write — it returns the stored response, with the original `request_id` still in the body and an `Idempotency-Replayed: true` response header. So a client that timed out and retried creates exactly one record. The SAME key with a DIFFERENT body returns 409 `conflict`; use a new key for a new request. A key whose first call is still in flight also returns 409 — retry once it settles. Keys are scoped to your API key (yours can never collide with another partner's) and retained 24 hours. A non-2xx stores nothing, so you may correct the request and retry with the same key. If the idempotency store itself is unreachable the request is REFUSED with a retryable 503 rather than executed without the guarantee you asked for.

Request body (required)

FieldTypeRequiredNotes
linksobject[]yes1 to a per-request maximum of items, each shaped like POST /subnetwork/links/by-domain's body.

Sub-affiliates

get/subnetwork/sub-affiliatessubaffiliates:read

MODE-KEYED, and the value above is the scope for THIS operation as documented (roster listing). `GET /sub-affiliates` and `GET /links` are the same handler, and it picks the scope from the MODE, not the path: pass `?sn_sub=<id>` on this path and it lists that sub's TRACKED LINKS and requires `trackedlinks:read` instead.

List the affiliate roster

ParameterInTypeRequiredNotes
cursorquerystringnoOPAQUE keyset cursor. Pass back the `next_cursor` from the previous page verbatim. Do not construct, parse or increment it — its internal shape is not part of the contract. An unrecognised cursor restarts from the beginning rather than erroring. ONE EXCEPTION, on `/subnetwork/links/domains` only: that endpoint previously minted a bare decimal row offset as its cursor and still honours one for backward compatibility, but refuses one deeper than 10000 rows with `400 invalid_parameter`. Past that depth the underlying offset read exceeds the database statement timeout and fails identically on every retry, so it is reported as the permanent condition it is rather than as a retryable error; restart the pull with no cursor. Keyset cursors — everything `next_cursor` mints today, on every endpoint — do not degrade with depth and are never refused this way.
limitqueryintegerno · default 50Page size. Default 50, maximum 200. A value ABOVE the maximum is CLAMPED to it, not rejected — the response reports the applied size in `limit` and sets `limit_clamped: true`. A value that is not a positive integer returns 400 `invalid_parameter`.
statusquerystring (active | observed | all)noAnything else returns 422 `validation_error`.
post/subnetwork/sub-affiliatessubaffiliates:write

Register an affiliate

ParameterInTypeRequiredNotes
Idempotency-KeyheaderstringnoMakes this write safe to retry. Send any unique string (a UUID is typical) and reuse it for the retry. A retry with the SAME key and a byte-identical body does NOT re-execute the write — it returns the stored response, with the original `request_id` still in the body and an `Idempotency-Replayed: true` response header. So a client that timed out and retried creates exactly one record. The SAME key with a DIFFERENT body returns 409 `conflict`; use a new key for a new request. A key whose first call is still in flight also returns 409 — retry once it settles. Keys are scoped to your API key (yours can never collide with another partner's) and retained 24 hours. A non-2xx stores nothing, so you may correct the request and retry with the same key. If the idempotency store itself is unreachable the request is REFUSED with a retryable 503 rather than executed without the guarantee you asked for.

Request body (required)

FieldTypeRequiredNotes
sn_substringyesLetters, numbers, underscores and hyphens only, max 64 chars.
labelstringnoAn optional DISPLAY NAME for the sub. Free text, purely presentational, and NEVER used as identity or on the money path — `sn_sub` is the identity. IMPORTANT: IF THE SUB IS ALREADY REGISTERED THIS IS IGNORED and the stored label is returned unchanged; this endpoint registers, it does not rename. Compare the `label` in the response against what you sent.
kindstring (main)no · default main"sub" (sub-of-a-sub) is rejected 422 — not_supported; the commission engine is a fixed single-level sub tier.

Reporting

Per-transaction and aggregate views of the split described in How the SubNetwork commission model works, for your own connections only. Reads need reports:read; enqueueing an export needs reports:write. Continuing the Verve Skincare example from that section — Northshore Media’s owner key, one confirmed €10.00 sale plus one order still awaiting accrual:

curl — GET /subnetwork/reporting/transactions
curl -sS -G "https://app.affilitera.com/api/v1/subnetwork/reporting/transactions" \
  -H "Authorization: Bearer $AFFILITERA_API_KEY" \
  --data-urlencode "from=2026-05-01" \
  --data-urlencode "to=2026-05-31"
response
{
  "data_as_of": "2026-06-01T00:00:00Z",
  "data": [
    {
      "transaction_id": "txn_84213",
      "network": "awin",
      "instance_id": "7c06a219-...",
      "brand_id": "14902cbc-...",
      "brand_public_id": "brd_0A1b2C3d4E5",
      "brand_name": "Verve Skincare",
      "merchant_id": "501419",
      "merchant_domain": "verveskincare.com",
      "sn_sub": "creator_amy",
      "sn_sub_dimensions": null,
      "order_value": "200.00",
      "currency": "EUR",
      "status": "approved",
      "status_bucket": "confirmed",
      "transaction_date": "2026-05-18",
      "pool_commission": "10.00",
      "computed_owner_split": "6.00",
      "computed_sub_split": "2.00",
      "split_currency": "EUR"
    },
    {
      "transaction_id": "txn_84240",
      "network": "awin",
      "instance_id": "7c06a219-...",
      "brand_id": "14902cbc-...",
      "brand_public_id": "brd_0A1b2C3d4E5",
      "brand_name": "Verve Skincare",
      "merchant_id": "501419",
      "merchant_domain": "verveskincare.com",
      "sn_sub": "creator_amy",
      "sn_sub_dimensions": null,
      "order_value": "64.00",
      "currency": "EUR",
      "status": "pending",
      "status_bucket": "pending",
      "transaction_date": "2026-05-31",
      "pool_commission": null,
      "computed_owner_split": null,
      "computed_sub_split": null,
      "split_currency": null
    }
  ],
  "next_cursor": null,
  "has_more": false,
  "total": null,
  "limit": 50,
  "limit_clamped": false,
  "limit_max": 100,
  "request_id": "req_b3a8ef61ded34b9b",
  "_note": "Informational only — Affilitera does not disburse SubNetwork commission; owners are paid by their own networks. Per-currency, never cross-summed."
}

txn_84213 is the confirmed Verve Skincare sale from the guide above — read computed_owner_split (€6.00) against split_currency (EUR), not the sibling currency field. txn_84240 placed on May 31st and has not accrued yet, so all four split fields are null — not €0.00 — exactly as the guide above describes.

GET /subnetwork/reporting/summary aggregates the same underlying rows, grouped by whatever you ask for (brand, sn_sub, date), still within currency and still per your own instances:

curl — GET /subnetwork/reporting/summary
curl -sS -G "https://app.affilitera.com/api/v1/subnetwork/reporting/summary" \
  -H "Authorization: Bearer $AFFILITERA_API_KEY" \
  --data-urlencode "group_by=brand" \
  --data-urlencode "from=2026-05-01" \
  --data-urlencode "to=2026-05-31"
response
{
  "data_as_of": "2026-06-01T00:00:00Z",
  "group_by": ["brand"],
  "data": [
    {
      "group": {
        "network": "awin",
        "instance_id": "7c06a219-...",
        "instance_public_id": "inst_9ViVV9aGEXa",
        "brand_id": "14902cbc-...",
        "brand_public_id": "brd_0A1b2C3d4E5",
        "brand_name": "Verve Skincare",
        "merchant_id": "501419",
        "merchant_domain": "verveskincare.com"
      },
      "currency": "EUR",
      "orders": 2,
      "orders_accrued": 1,
      "orders_not_yet_accrued": 1,
      "orders_accrual_unresolved": 0,
      "commission_totals_partial": true,
      "order_value_total": "264.00",
      "pool_commission_total": "10.00",
      "computed_owner_split_total": "6.00",
      "computed_sub_split_total": "2.00",
      "by_status": { "confirmed": "6.00", "pending": "0.00", "reversed": "0.00" }
    }
  ],
  "truncated": false,
  "next_cursor": null,
  "has_more": false,
  "total": 1,
  "limit": 50,
  "limit_clamped": false,
  "limit_max": 10000,
  "request_id": "req_e114a2f9c8b64a11",
  "_note": "Informational only — Affilitera does not disburse SubNetwork commission; owners are paid by their own networks. Per-currency, never cross-summed."
}

A bucket total is a total over orders_accrued, never over orders

orders counts every order in the bucket. The commission figures — pool_commission_total, computed_owner_split_total, computed_sub_split_total and all three by_status values — only ever received a contribution from the orders that have actually accrued. An order we have not resolved a split for yet contributes nothing, so it is counted in orders and absent from the money. Three counts tell you exactly how the bucket divides:

  • orders_accrued — a split was resolved in the bucket’s currency and its amounts are in the totals. This may legitimately be a €0.00 contribution: that means we resolved the split and the answer was genuinely nothing.
  • orders_not_yet_accrued — no split yet, most commonly a sale from the last day or two. This resolves itself; ask again later.
  • orders_accrual_unresolved — a split exists but not in this bucket’s currency, so we decline to fold it in rather than guess. This does not resolve itself. If you ever see a non-zero count here, tell us.

The three always sum to orders. commission_totals_partial is the derived shortcut — true whenever orders_accrued is less than orders, which is your signal that the commission figures in this bucket are a subtotal rather than a period total. order_value_total is never partial: order value comes from the order itself and does not wait on accrual.

When orders_accrued is 0, the commission figures are null, not "0.00". Nothing contributed, so there is no sum — and reporting one as zero is precisely the coercion this API tells you never to make. Read null here the same way you read a null split on GET /subnetwork/reporting/transactions: we do not have this yet.

On rare occasions this endpoint and GET /subnetwork/reporting/transactions classify one order differently — an order whose only split is in a currency other than the one it resolved to is counted orders_accrual_unresolved here, while the per-transaction view still shows you its single unambiguous split. Both are accurate about their own figure: the aggregate really did leave that order out of the total, and the row really does have a readable split.

Superseded 2026-08-25. This section previously carried a Known limitation notice stating that the bucket above folded txn_84240 in as if it contributed €0.00, that nothing in the response said so, and that you should pull /subnetwork/reporting/transactions and sum the non-null rows yourself if you needed to tell “not yet accrued” from “confirmed zero”. That disclosure was accurate when written. The fields above replace it: the response now states its own coverage, and the manual workaround is no longer necessary.

A reversed order (status_bucket: "reversed") carries negative split amounts, clawing back what was previously accrued — not a deletion of the original row. Summing a currency’s by_status.reversed is how you see the clawback total for that bucket.

get/subnetwork/reporting/transactionsreports:read

List commission transactions

Reporting endpoints use the TIGHTER rate-limit tier and a lower maximum page size (100).

ParameterInTypeRequiredNotes
limitqueryintegerno · default 50Page size on GET /reporting/transactions. Default 50, maximum 100 (its ledger queries cost more per row). Over-max is CLAMPED, not rejected. A zero, negative or non-numeric value returns 400 `invalid_parameter` naming `limit` — it does NOT fall back to the default; only an absent or empty `limit` does. NOTE: GET /reporting/summary does NOT use this parameter — its ceiling is 10000 and means something different. See SummaryLimit.
cursorquerystringnoOPAQUE keyset cursor. Pass back the `next_cursor` from the previous page verbatim. Do not construct, parse or increment it — its internal shape is not part of the contract. An unrecognised cursor restarts from the beginning rather than erroring. ONE EXCEPTION, on `/subnetwork/links/domains` only: that endpoint previously minted a bare decimal row offset as its cursor and still honours one for backward compatibility, but refuses one deeper than 10000 rows with `400 invalid_parameter`. Past that depth the underlying offset read exceeds the database statement timeout and fails identically on every retry, so it is reported as the permanent condition it is rather than as a retryable error; restart the pull with no cursor. Keyset cursors — everything `next_cursor` mints today, on every endpoint — do not degrade with depth and are never refused this way.
fromquerystringnoStart of the reporting window, INCLUSIVE, compared against each row's `transaction_date` — the date the SALE occurred, not the date we ingested it or resolved its commission. Accepts a calendar day (`YYYY-MM-DD`). Rows whose `transaction_date` is null match no window and are therefore excluded whenever you supply one. Omit for no lower bound.
toquerystringnoEnd of the reporting window, INCLUSIVE OF THE WHOLE CALENDAR DAY — `to=2026-08-24` includes sales timestamped at any time on the 24th, not just midnight. Compared against `transaction_date`, the date the SALE occurred. Rows whose `transaction_date` is null are excluded whenever you supply a window. Omit for no upper bound.
brand_idquerystringnoRestrict to one brand. Accepts EITHER the brand's minted `brand_public_id` (`brd_…`, the durable identity — preferred) OR its raw `brand_id` uuid. Both forms select the same rows. A `brd_…` value naming no brand is a `validation_error` (422), never an empty page; a value that is neither form is also a 422.
sn_subquerystringnoRestrict to one affiliate's transactions — the exact stored `sn_sub` value.
statusquerystringnoRestrict to one order status (network-reported, not the closed `status_bucket` set).
currencyquerystringnoRestrict to one ISO-4217 currency. Never converts or cross-sums — this only filters which single-currency rows are returned.
get/subnetwork/reporting/summaryreports:read

Aggregated commission summary

ParameterInTypeRequiredNotes
limitqueryintegerno · default 50A BUCKET PAGE SIZE, not a scan cap — the aggregation runs in the database over the WHOLE filtered range before `limit`/`cursor` page the resulting buckets, so this value never changes which orders are counted, only how many result rows come back per call. Default 50, maximum 10000, and `limit_max` reports 10000 accordingly. Over-max is CLAMPED, not rejected. A zero, negative or non-numeric value returns 400 `invalid_parameter` naming `limit` — it does NOT fall back to the default. Every bucket is therefore a whole-period total from the first page you see it: `truncated` is pinned `false` and `partial_totals` is never emitted.
cursorquerystringnoOPAQUE keyset cursor. Pass back the `next_cursor` from the previous page verbatim. Do not construct, parse or increment it — its internal shape is not part of the contract. An unrecognised cursor restarts from the beginning rather than erroring. ONE EXCEPTION, on `/subnetwork/links/domains` only: that endpoint previously minted a bare decimal row offset as its cursor and still honours one for backward compatibility, but refuses one deeper than 10000 rows with `400 invalid_parameter`. Past that depth the underlying offset read exceeds the database statement timeout and fails identically on every retry, so it is reported as the permanent condition it is rather than as a retryable error; restart the pull with no cursor. Keyset cursors — everything `next_cursor` mints today, on every endpoint — do not degrade with depth and are never refused this way.
fromquerystringnoStart of the reporting window, INCLUSIVE, compared against each order's `transaction_date` — the date the SALE occurred, not the date we ingested it. Accepts a calendar day (`YYYY-MM-DD`). Orders with no `transaction_date` are excluded whenever you supply a window. Omit for no lower bound.
toquerystringnoEnd of the reporting window, INCLUSIVE OF THE WHOLE CALENDAR DAY — `to=2026-08-24` includes sales timestamped at any time on the 24th. Compared against `transaction_date`. Orders with no `transaction_date` are excluded whenever you supply a window. Omit for no upper bound.
brand_idquerystringnoRestrict to one brand. Accepts EITHER the brand's minted `brand_public_id` (`brd_…`, the durable identity — preferred) OR its raw `brand_id` uuid. Both forms select the same rows. A `brd_…` value naming no brand is a `validation_error` (422), never an empty page; a value that is neither form is also a 422.
sn_subquerystringnoRestrict to one affiliate's orders before aggregating.
statusquerystringnoRestrict to one order status (network-reported) before aggregating.
currencyquerystringnoRestrict to one ISO-4217 currency before aggregating. Never converts or cross-sums — every bucket is already single-currency.
group_byquerystringnoComma-separated subset of `brand`, `sn_sub`, `date` (e.g. `brand,date`). Unrecognised tokens are dropped, not rejected. Omit for network-only buckets. Echoed back on the response as the `group_by` array.
granularityquerystring (day | month)no · default dayBucket width when `group_by` includes `date`. Ignored otherwise. Echoed back on the response ONLY when `group_by` includes `date`.
post/subnetwork/reporting/exportreports:write

Start an asynchronous report export

Returns a job. Poll `/subnetwork/jobs/{job_id}` until it is ready, then follow `/subnetwork/jobs/{job_id}/download`.

ParameterInTypeRequiredNotes
Idempotency-KeyheaderstringnoMakes this write safe to retry. Send any unique string (a UUID is typical) and reuse it for the retry. A retry with the SAME key and a byte-identical body does NOT re-execute the write — it returns the stored response, with the original `request_id` still in the body and an `Idempotency-Replayed: true` response header. So a client that timed out and retried creates exactly one record. The SAME key with a DIFFERENT body returns 409 `conflict`; use a new key for a new request. A key whose first call is still in flight also returns 409 — retry once it settles. Keys are scoped to your API key (yours can never collide with another partner's) and retained 24 hours. A non-2xx stores nothing, so you may correct the request and retry with the same key. If the idempotency store itself is unreachable the request is REFUSED with a retryable 503 rather than executed without the guarantee you asked for.

Request body (required)

FieldTypeRequiredNotes
fromstringyesStart of the exported window, INCLUSIVE, compared against `transaction_date` — the date each SALE occurred. Calendar day (`YYYY-MM-DD`). Narrow this first if an export keeps timing out: window size is the main driver of export cost.
tostringyesEnd of the exported window, INCLUSIVE OF THE WHOLE CALENDAR DAY, compared against `transaction_date`. Calendar day (`YYYY-MM-DD`).
formatstring (csv)no · default csvOutput format of the generated file. `csv` is the only accepted value and is the default when you omit the field. `json` is explicitly recognised and REJECTED with a distinct message rather than silently falling back, so you can tell 'not supported yet' apart from a typo; any other value is a plain validation error.
brand_idstring | nullnoRestrict the export to ONE merchant. Accepts either the internal uuid (`brand_id`) or the durable handle (`brand_public_id`, `brd_…`) — prefer the handle. Omit to export every brand in your key's scope. NULL MEANS NO RESTRICTION, and is identical to omitting the key. Every OTHER non-string value — `""`, whitespace, a number, a boolean, an object, an array — is REJECTED with a 422 naming the key — a type-wrong filter never widens to 'every brand'. Send a non-empty string, or null, or nothing.
sn_substring | nullnoRestrict the export to ONE affiliate, matched VERBATIM AND CASE-SENSITIVELY against each row's `sn_sub`. Omit to export every sub, including rows attributed to none. NULL MEANS NO RESTRICTION, identical to omitting the key — every sub is exported, including rows attributed to none. As with the other filters on this body, any OTHER non-string or empty value is REJECTED with a 422 naming the key. A mistyped but well-formed sub id still matches no row and yields an empty export — check the row count against what you expected.
statusstring | nullnoRestrict the export to sales in one lifecycle state — `pending`, `approved`, `declined` or `rejected`. Matched exactly against the stored status. Remember that `declined` and `rejected` both mean reversed and are used by different networks, so filtering on only one of them will miss reversals from the other. Omit to export every status. NULL MEANS NO RESTRICTION, identical to omitting the key: every status is exported. The value is NOT validated against the four-word vocabulary — an unrecognised non-empty string is passed through and simply matches no row, yielding an empty export; a null exports everything (null is 'no filter'); a non-string or empty value is REJECTED with a 422. The first two mistakes fail in opposite directions, so check the row count against what you expected.
currencystring | nullnoRestrict the export to sales recorded in ONE currency, as an ISO-4217 code matched against the row's sale `currency`. Useful precisely because this platform performs no FX conversion and never cross-sums currencies: exporting one currency at a time gives you a file you can total directly. Omit to export every currency, in which case you must group by currency before summing anything. NULL MEANS NO RESTRICTION, identical to omitting the key: every currency is exported, and you must then group by currency before summing anything, because this platform performs no FX conversion. An unrecognised code is not rejected — it matches no row and yields an empty file.
client_referencestring | nullnoIdempotency key in the body. The `Idempotency-Key` header wins if both are sent. NULL MEANS NO BODY-LEVEL IDEMPOTENCY KEY — identical to omitting it. If no `Idempotency-Key` header is sent either, THE REQUEST IS NOT IDEMPOTENT AT ALL and a retry creates a second export job. Null is therefore not a safe default; it is the opt-out.

Payouts

Track manual payments you make to your own affiliates out of your own funds — a record you maintain, not money Affilitera moves (see there is no balance, payable, or owed field above). Reads need payouts:read; recording a payment needs payouts:write, which is never granted by default.

get/subnetwork/payoutspayouts:read

Payout statements

`data` is a PAGE of payout records, newest first, ordered by (`created_at`, `id`) descending — page it with `cursor` / `has_more` like every other list endpoint here. `earned_vs_paid` IS NOT PAGED and never will be: it is a SUM over every payout record in your scope, so a windowed version of it would under-report `paid` and overstate `outstanding`. It is therefore complete on every page, and identical on every page of one pull — read it once and ignore it thereafter. COST NOTE, so you can size your own timeouts honestly: paging bounds the RESPONSE, not the work. Because the aggregate above spans your whole payout history, this endpoint reads all of it on every request — `limit=1` costs the server the same as `limit=200`. Prefer few large pages to many small ones.

ParameterInTypeRequiredNotes
cursorquerystringnoOPAQUE keyset cursor. Pass back the `next_cursor` from the previous page verbatim. Do not construct, parse or increment it — its internal shape is not part of the contract. An unrecognised cursor restarts from the beginning rather than erroring. ONE EXCEPTION, on `/subnetwork/links/domains` only: that endpoint previously minted a bare decimal row offset as its cursor and still honours one for backward compatibility, but refuses one deeper than 10000 rows with `400 invalid_parameter`. Past that depth the underlying offset read exceeds the database statement timeout and fails identically on every retry, so it is reported as the permanent condition it is rather than as a retryable error; restart the pull with no cursor. Keyset cursors — everything `next_cursor` mints today, on every endpoint — do not degrade with depth and are never refused this way.
limitqueryintegerno · default 50Page size. Default 50, maximum 200. A value ABOVE the maximum is CLAMPED to it, not rejected — the response reports the applied size in `limit` and sets `limit_clamped: true`. A value that is not a positive integer returns 400 `invalid_parameter`.
post/subnetwork/payoutspayouts:write

Record a payout you have made to an affiliate

Requires the `payouts:write` scope — every other endpoint here is read-only. A container key MUST name `instance_id` explicitly; it is not defaulted, because attributing a payout to an arbitrary instance would be worse than refusing. A NAMED `instance_id` must also be one of the key's ACTIVE authorized instances (deactivated ones are excluded, same as every other write route) — otherwise 422 `validation_error` ('instance_id is not authorized by this key.'), distinct from the 403 `link_locked` case above, which fires when the KEYED instance itself is deactivated. A deactivated connection returns 403 `link_locked`. The affiliate must already be registered under your subnetwork, else 422. IDEMPOTENCY: send an `Idempotency-Key` header. A retry with the same key and the same body returns the ORIGINAL 201, not an error, with `Idempotency-Replayed: true` set — so a client that timed out and retried records exactly ONE payout. The same key with a different body returns 409. The `client_reference` body field remains a fallback for callers not sending the header, and a collision on it alone still returns 409 carrying the payout already recorded. ITEMIZED MODE: sending `items` (or `coverage_mode: "itemized"`) records a payout broken down per order instead of a single lump `amount`, and `amount` is then not required. ITEMIZED-MODE TRANSACTION RESOLUTION IS TWO STEPS WITH TWO DIFFERENT NOT-FOUND SHAPES. First, every `items[].transaction_id` is resolved to an internal order id WITHIN THE KEYED INSTANCE ONLY (before any RPC runs): an id that does not resolve there returns a plain 404 `not_found` ('One or more transaction_ids were not found.') — indistinguishable from one belonging to someone else's instance, so this is not an existence oracle. Only once every id resolves does the write proceed to `sn_create_itemized_payout`, whose OWN failures surface as the 422 `order_not_found` sub-code below — a structurally different case (e.g. a since-voided or re-keyed row) that the pre-resolution step cannot see. Do not conflate the two: a 404 here means "resend without that id"; a 422 `order_not_found` means the write itself was refused. ITEMIZED-MODE SUB-CODES: a 409 or 422 from itemized mode carries a top-level `code` field (sibling of `error.code`, which stays `conflict`/`validation_error`) drawn from a SECOND, itemized-only vocabulary — not a member of the platform-wide closed error-code set. Values: `transaction_already_claimed` (409 — one of these transactions was claimed by another payout record while you were reviewing; reload and retry), `stale_snapshot` (409 — one of these transactions changed value while reviewing; reload), `items_required` (422 — must reference at least one transaction), `invalid_direction` (422), `invalid_item_kind` (422 — each item must be an earning or an adjustment), `order_not_found` (422), `order_not_in_instance` (422), `currency_mismatch` (422 — amounts are never converted), `transaction_not_confirmed` (422 — only confirmed transactions are itemizable), `nothing_to_claim` (422), `no_drift_to_adjust` (422), `overpaid_no_disbursement` (422 — reversals on already-paid transactions exceed new earnings; no payment due, balance carries forward), `nothing_to_recover` (422), `exceeds_outstanding` (422 — this payment is larger than what's still owed in this currency), `exceeds_overpayment` (422 — this recovery is larger than the amount overpaid in this currency). NOT CONFIGURED (503): if the server has no database configured at all, both write paths return 503 with `error.code: service_unavailable` and a `Retry-After` header. CHANGED 2026-08-16 (gap G-29, now CLOSED): this previously returned 503 carrying `error.code: internal_error`, contradicting that code's own documented 500 — a status-branching caller read "transient, retry" while a code-branching caller read "a genuine, non-transient server fault", for the identical failure. THE STATUS IS UNCHANGED at 503; only the code that disagreed with it moved. `GET /subnetwork/payouts/claimable` carried the same defect and was corrected in the same change, so the two payout surfaces agree.

ParameterInTypeRequiredNotes
Idempotency-KeyheaderstringnoMakes this write safe to retry. Send any unique string (a UUID is typical) and reuse it for the retry. A retry with the SAME key and a byte-identical body does NOT re-execute the write — it returns the stored response, with the original `request_id` still in the body and an `Idempotency-Replayed: true` response header. So a client that timed out and retried creates exactly one record. The SAME key with a DIFFERENT body returns 409 `conflict`; use a new key for a new request. A key whose first call is still in flight also returns 409 — retry once it settles. Keys are scoped to your API key (yours can never collide with another partner's) and retained 24 hours. A non-2xx stores nothing, so you may correct the request and retry with the same key. If the idempotency store itself is unreachable the request is REFUSED with a retryable 503 rather than executed without the guarantee you asked for.

Request body (required)

FieldTypeRequiredNotes
sn_substringyesThe affiliate paid. Must be registered under this subnetwork.
amountstringyesAmount paid, in `currency`. Must be strictly positive. Never cross-summed across currencies. FORBIDDEN in itemized mode (`items` present, or `coverage_mode: "itemized"`) — not merely optional: supplying it there is 422 `validation_error` ('amount is derived from the items and may not be supplied.'), since the amount is computed from `items[]` and a caller-supplied value could disagree with it. SEND A DECIMAL STRING (`"25.50"`). That is the form every response emits, so a payout you read back from this API can be posted straight back with no type conversion, and a large or high-precision amount is not rounded by your JSON encoder on the way in. A JSON number is still accepted for convenience. NOTHING ELSE IS. `"0x10"`, `"1e5"`, `"Infinity"`, `"$25"` and `"1,234.00"` are 422 `validation_error`. (`"0x10"` previously recorded a payout of 16.00, silently.)
currencystringyesISO-4217 code, upper-cased server-side — but the two write paths validate at a DIFFERENT POINT relative to that upper-casing, so they disagree on lower-case input. Non-itemized mode validates the format on the RAW value before upper-casing it, so `"usd"` is 422 `validation_error`. Itemized mode upper-cases FIRST, so `"usd"` is accepted there. Send upper-case to avoid depending on which mode you are in.
providerstring | nullnoRESERVED for Phase-2 (provider-managed payouts). Setting `provider`, `provider_payout_id` or `provider_status` on a create — in EITHER mode — is 422 `validation_error` ('provider fields are reserved for Phase-2 and may not be set manually.' on the non-itemized path; the itemized path's wording is 'provider fields are reserved for Phase-2 and may not be set.'). Do not send any of the three. NULL IS NOT AN ACCEPTED VALUE, and this is the trap: the guard tests whether the KEY IS PRESENT, not what it holds, so sending `"provider": null` is 422 exactly as `"provider": "stripe"` is. The key must be ABSENT from the body. That makes this field the one place on this API where null and omitted differ, so a client that serialises its whole model — nulls included — will be rejected until it drops the key.
settlement_currencystring | nullnoRESERVED for Phase-2. ITEMIZED MODE ONLY (the non-itemized path has no equivalent check, since it has no settlement concept today). Setting `settlement_currency` or `settlement_amount` is 422 `validation_error` ('settlement fields are reserved for Phase-2 and may not be set.'). Do not send either. NULL IS NOT AN ACCEPTED VALUE. Like the provider fields, the guard tests KEY PRESENCE rather than value, so `"settlement_currency": null` is 422. Omit the key entirely. A client that emits every field of its model with explicit nulls will be rejected on this key alone.
instance_idstringnoRequired for container keys. For a single-instance key it is resolved from the key.
payment_methodstring | nullnoHow you paid the sub, as FREE TEXT that you choose — for example a bank transfer, PayPal or Wise. NOT an enum: nothing validates or normalises it, so it will only be as consistent as your own writes. Recorded for your reference and never interpreted by us. Omit if you have nothing to record. NULL MEANS NOT RECORDED — you chose to say nothing about how you paid — and is identical to omitting the key, to `""`, and to any non-string value, all of which are stored as null rather than rejected. Nothing is inferred: we never guess a method from the amount, the currency or your previous payouts.
referencestring | nullnoYour own payment reference. NULL MEANS NOT RECORDED — no payment reference was supplied — and is identical to omitting the key, to `""`, and to any non-string value. It is never generated for you, and it is NOT the idempotency key: use `client_reference` or the `Idempotency-Key` header for that. Two payouts may share a reference; nothing enforces uniqueness here.
notestring | nullnoA free-text note to store against this payout. Never interpreted by us and never shown to the sub by this API. Omit if unused. NULL MEANS NOT RECORDED — no note was supplied — and is identical to omitting the key, to `""`, and to any non-string value. Never interpreted, never shown to the sub, never generated for you.
client_referencestring | nullnoIdempotency key in the body. The `Idempotency-Key` header wins if both are sent. NULL MEANS NO BODY-LEVEL IDEMPOTENCY KEY — identical to omitting it. If no `Idempotency-Key` header is sent either, THE REQUEST IS NOT IDEMPOTENT AT ALL: a retried POST records a SECOND PAYOUT for the same money. On this endpoint that is not a duplicate record but a duplicate payment in your books, so treat null here as a decision, not a default.
coverage_modestring (itemized)noSwitches to itemized mode.
itemsobject[]noPer-order breakdown. Presence alone switches to itemized mode.
directionstring (disbursement | recovery | write_off)no · default disbursementITEMIZED MODE ONLY (ignored/absent on a non-itemized create, which has no direction concept). `amount` on the created `Payout` and on each `items[].amount` is ALWAYS positive; `direction` carries the sign — see the `Payout.direction` schema note. `disbursement` (the default) is a normal payment. `recovery` claws back an earlier disbursement (money coming BACK to the owner). `write_off` records an amount as no longer owed without money moving. Any other value returns 422 with sub-code `invalid_direction` (see ITEMIZED-MODE SUB-CODES below) — added later than the rest of this request shape and previously undocumented here even though the error sub-code it produces was.
statusstring (recorded | pending)no · default recordedCreation status. `recorded` (the default, and the unchanged behaviour) means the payment has already been made and is being recorded — it counts as settled immediately. `pending` reports the amount as in_flight and withholds it from claimable, so a later batch cannot double-pay it; it does NOT reduce outstanding. ONE-WAY DOOR: there is currently no mark-sent / mark-confirmed path for an owner-to-sub payout, so a pending record cannot yet be advanced to settled by anyone, and this endpoint has no PATCH. Any other value returns 422. NON-ITEMIZED CREATES ONLY: the itemized write path always records `recorded`, and supplying `status` alongside `items` returns 422 rather than being silently ignored.
get/subnetwork/payouts/claimablepayouts:read

Claimable balance

A payout PROPOSAL for exactly one (`sn_sub`, `currency`) pair. Both of those parameters are REQUIRED — a proposal spanning currencies is meaningless (a GBP recovery never offsets a EUR debt), so the endpoint refuses rather than rolling up across a sub's currencies. `instance_id` becomes required as well when the key is a CONTAINER key, because the instance cannot then be derived from the key alone; for an instance-scoped key it is derived and must be omitted or match. Every one of these is a `422 validation_error`, not a `400`.

ParameterInTypeRequiredNotes
sn_subquerystringyesREQUIRED. The affiliate to build the proposal for. Absent or blank returns 422 (`sn_sub is required.`).
currencyquerystringyesREQUIRED. A 3-letter ISO-4217 code; it is upper-cased before validation, so `eur` is accepted. Anything not matching returns 422 (`currency is required and must be a 3-letter ISO-4217 code.`).
instance_idquerystringnoREQUIRED FOR A CONTAINER KEY, and validated against the key's own authorized instance set — an unauthorized value returns 422, never 403, and never reveals whether the instance exists. Omitting it on a container key returns 422 (`instance_id required for container keys`). On an instance-scoped key it defaults to the key's instance. `GET /subnetwork/instance` tells you which kind of key you hold (`scope_kind`).

Contracts

The active commission contracts behind your instances — payout tiers and the rules that select them. Needs contracts:read.

get/subnetwork/contractscontracts:read

List active commission contracts

ParameterInTypeRequiredNotes
cursorquerystringnoOPAQUE keyset cursor. Pass back the `next_cursor` from the previous page verbatim. Do not construct, parse or increment it — its internal shape is not part of the contract. An unrecognised cursor restarts from the beginning rather than erroring. ONE EXCEPTION, on `/subnetwork/links/domains` only: that endpoint previously minted a bare decimal row offset as its cursor and still honours one for backward compatibility, but refuses one deeper than 10000 rows with `400 invalid_parameter`. Past that depth the underlying offset read exceeds the database statement timeout and fails identically on every retry, so it is reported as the permanent condition it is rather than as a retryable error; restart the pull with no cursor. Keyset cursors — everything `next_cursor` mints today, on every endpoint — do not degrade with depth and are never refused this way.
limitqueryintegerno · default 50Page size. Default 50, maximum 200. A value ABOVE the maximum is CLAMPED to it, not rejected — the response reports the applied size in `limit` and sets `limit_clamped: true`. A value that is not a positive integer returns 400 `invalid_parameter`.

Jobs

Poll and download the CSV/JSON exports you enqueued from Reporting. Reads need reports:read; enqueueing or retrying an export needs reports:write.

get/subnetwork/jobs/{job_id}reports:read

Job status

ParameterInTypeRequiredNotes
job_idpathstringyesThe export job to poll, as returned in `job_id` when you submitted it. Ours. A job belonging to another owner returns 404, deliberately indistinguishable from one that does not exist.
get/subnetwork/jobs/{job_id}/downloadreports:read

Download a finished export

Responds 302 to a short-lived presigned URL. A job that has not finished returns 409 `job_not_ready`.

ParameterInTypeRequiredNotes
job_idpathstringyesThe export job whose file you want, as returned in `job_id` when you submitted it. The job must have reached `done`; anything earlier returns a conflict rather than an empty file. This endpoint REDIRECTS to a short-lived storage URL, so follow redirects and do not cache the target.
post/subnetwork/jobs/{job_id}/retryreports:write

Retry a failed export

Only a FAILED job can be retried — anything else returns 409 `job_not_failed`. A job that has exhausted its retries returns 409 `max_retries_exceeded`.

ParameterInTypeRequiredNotes
job_idpathstringyesThe export job to retry. ONLY A `failed` JOB CAN BE RETRIED — retrying a job in any other state is a conflict, not a no-op. There is a hard cap on retries per job, after which further attempts are refused.
Idempotency-KeyheaderstringnoMakes this write safe to retry. Send any unique string (a UUID is typical) and reuse it for the retry. A retry with the SAME key and a byte-identical body does NOT re-execute the write — it returns the stored response, with the original `request_id` still in the body and an `Idempotency-Replayed: true` response header. So a client that timed out and retried creates exactly one record. The SAME key with a DIFFERENT body returns 409 `conflict`; use a new key for a new request. A key whose first call is still in flight also returns 409 — retry once it settles. Keys are scoped to your API key (yours can never collide with another partner's) and retained 24 hours. A non-2xx stores nothing, so you may correct the request and retry with the same key. If the idempotency store itself is unreachable the request is REFUSED with a retryable 503 rather than executed without the guarantee you asked for.

Which version answered you

Every response from this API — success, error, 405 and 404 alike — carries an API-Version header. It is currently API-Version: 1 on every endpoint.

The value is a major version only, and it is deliberately not a date. We do not run a dated release train, so a dated value would imply a versioning scheme we do not have — and one you could reasonably pin a request against. There is nothing to pin: what counts as a breaking change is the enumerated list in our API standard, and a breaking change ships as a new major version with its own path.

The /v1 in the path says what you asked for; this header says what we served. Those agree unless something between us — a proxy, a rewrite, a redirect you did not expect — sent your request somewhere else. If you ever see a value here that does not match the path you called, that mismatch is the bug, and it is worth reporting with the request_id.

Deprecations and breaking changes

Every field, parameter or endpoint we have deprecated, removed, or changed the meaning of. This list is generated from the same registry the API itself reads, so it cannot fall behind what the API does.

While something here is deprecated, every response from the affected endpoint carries Deprecation: true, a Sunset date in RFC 7231 format, and a Link header pointing back at its entry below. Point your monitoring at those headers rather than at this page: they are the only signal that reaches an integration whose author has moved on. A deprecated element stays available for at least 180 days from the announced date.

Entries marked removed or meaning changed are history, not warnings — they carry no headers, because the element is already gone or already different. They are published because a client that suddenly reads undefined from a field needs somewhere to find out what it is called now. Several of them shipped before this mechanism existed and had no deprecation window at all; that is stated plainly in each entry rather than smoothed over.

Deprecated — still served, going away

deprecatedGET /v1/subnetwork/contractsendpoint

The contract object is being retired platform-wide; commission groups replace it. This endpoint has only ever returned an empty page, because the tables behind it have never held a row. No replacement exists yet — if you depend on this route existing, say so before the sunset date.

Affects
GET /v1/subnetwork/contracts
Announced
2026-09-03
Removable from
2027-03-15
deprecatednetwork_idfield

Byte-identical to instance_id on every row these endpoints can serve, and named 'network' while holding an instance id. Read instance_id.

Affects
GET /v1/subnetwork/coupons, GET /v1/subnetwork/offers
Read instead
instance_id
Announced
2026-08-16
Removable from
2027-02-15
deprecatedby_currency.commissionfield

Adds together earnings from different roles (affiliate, network operator) that are paid by different payers. Read by_currency.by_earning_role, which reports each role separately.

Affects
GET /v1/affiliate/stats
Read instead
by_currency.by_earning_role
Announced
2026-08-27
Removable from
2027-02-23
deprecatedtotals_by_currencyfield

Adds together earnings from different roles (affiliate, network operator) that are paid by different payers. Read totals_by_currency_and_earning_role, which reports each role separately.

Affects
GET /v1/affiliate/transactions
Read instead
totals_by_currency_and_earning_role
Announced
2026-08-27
Removable from
2027-02-23
deprecatednetworkfield

Byte-identical to network_slug, which is the name GET /subnetwork/brands uses for the same value. Read network_slug.

Affects
GET /v1/subnetwork/coupons, GET /v1/subnetwork/offers
Read instead
network_slug
Announced
2026-08-16
Removable from
2027-02-15

Removed

removedis_upper_boundfield

Removed with no deprecation window, on default_rate and on every groups[] entry. It flagged a value that was a ceiling rather than an earnable rate (the 'up to 8%' case). READ THE value/value_max PAIR INSTEAD, which carries the same distinction without the flag: on a ceiling term value is null and value_max holds the ceiling, so a null value beside a non-null value_max IS what is_upper_bound used to signal. value_max is NOT removed and is still served.

Affects
GET /v1/subnetwork/brands
Announced
2026-08-27
Removed on
2026-08-27
removednamefield

Renamed to brand_name, with no deprecation window. On a row that also carries merchant_domain and a product name one join away, a bare 'name' did not say whose.

Affects
GET /v1/subnetwork/brands
Read instead
brand_name
Announced
2026-08-14
Removed on
2026-08-14
removedpublic_idfield

Renamed to brand_public_id, with no deprecation window, converging the third spelling of one fact onto the name ten other row shapes already used.

Affects
GET /v1/subnetwork/brands
Read instead
brand_public_id
Announced
2026-08-14
Removed on
2026-08-14
removednetwork_idfield

Removed with no deprecation window. It held the INSTANCE id despite the name, and on granted-main rows it exposed the owning org's instance id to a read-only grantee. It will not return under any name.

Affects
GET /v1/subnetwork/brands
Read instead
instance_id (own rows) / network_slug (all rows)
Announced
2026-08-13
Removed on
2026-08-13
removednetwork_namefield

Removed with no deprecation window. A per-row copy of the instance display name, whose fallback chain meant the value alone could not say whether it was a custom label or the generic network name.

Affects
GET /v1/subnetwork/brands
Read instead
GET /subnetwork/instance -> name, joined on instance_id
Announced
2026-08-13
Removed on
2026-08-13
removedinstancesfield

The collection key was renamed from instances to data, converging on the key every other list endpoint uses. Shipped explicitly with no alias and no transition period.

Affects
GET /v1/subnetwork/instance
Read instead
data
Announced
2026-08-13
Removed on
2026-08-13

Meaning changed, same name and type

meaning changeddata[]semantics

These endpoints now return only promotions currently in force: starts_at is null or past, and ends_at is null or future, compared against the request instant in UTC and inclusive at both bounds. Previously every stored promotion was returned regardless of its dates, and 10.1% of the rows served were expired or not yet started. A null bound means the network stated no bound and never excludes a row. A promotion's absence means we are not publishing it at this instant, and not that it never existed. It carries NO guarantee that the row is retained: an expired promotion is subject to deletion under a separate retention rule, so do not rely on one reappearing if the network later re-dates it. A promotion withheld only because it has not yet started is not subject to that rule and appears once its starts_at has passed.

Affects
GET /v1/subnetwork/coupons, GET /v1/subnetwork/offers
Announced
2026-09-04
Changed on
2026-09-04
meaning changeddata[].your_sharesemantics

data[] now carries one row per (transaction, earning role) instead of one row per transaction, so transaction_id is no longer unique within data[]. Only accounts that earn under more than one role are affected; every other account's array is unchanged. Pair transaction_id with earning_role to key a row.

Affects
GET /v1/affiliate/transactions
Read instead
data[].earning_role
Announced
2026-08-27
Changed on
2026-08-27
meaning changed(every money field)semantics

Money fields changed from JSON numbers to decimal STRINGS, so an exact amount cannot be mangled by a consumer's float parser. This is a type change and is breaking under our own standard; it shipped without notice.

Affects
GET /v1/subnetwork/reporting/transactions, GET /v1/subnetwork/reporting/summary, GET /v1/subnetwork/payouts, GET /v1/subnetwork/payouts/claimable, GET /v1/subnetwork/contracts, GET /v1/affiliate/transactions, GET /v1/affiliate/stats
Announced
2026-08-13
Changed on
2026-08-13
meaning changedhas_productssemantics

SAME name, SAME type, DIFFERENT meaning: it now reports whether we hold actually-ingested product rows for the brand, where it previously reflected the network's own advertised figure. A consumer cannot detect this from the response at all — it is listed here because that is the only place it can be seen.

Affects
GET /v1/subnetwork/brands
Announced
2026-08-13
Changed on
2026-08-13

What is not here

This reference covers the SubNetwork API in full. Everything else Affilitera runs — administrative, first-party application and internal scheduled endpoints — is not part of this contract, is not reachable with a SubNetwork key, and is deliberately not documented here.