Programmatic access to the brand catalogue, product feeds, promotions, links and reporting behind your connected networks. 40 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:
Affilitera takes its own cut first — say 20% of the pool: €2.00. €8.00 remains.
Amy’s affiliate rate applies to what is left — say 25%: €2.00.
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 endpoint — GET /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.
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 size
50
Maximum page size
200 (100 on reporting endpoints — their per-row cost is higher)
Over the maximum
Clamped, not rejected. You get a valid first page, and the response says so: limit, limit_clamped: true, limit_max.
Not a positive integer
400 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.
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`);
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.
One endpoint is still on the older envelope.GET /subnetwork/reporting/transactions returns 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.
Code
HTTP
Meaning
invalid_parameter
400
A parameter is malformed. The message names it, and a `parameter` field repeats it.
filter_too_broad
400
The filter matches too many values to evaluate. Narrow it.
unauthorized
401
Missing, malformed, expired, revoked or unresolvable API key.
insufficient_scope
403
Valid key, but it lacks the scope this endpoint requires.
link_locked
403
The connection behind this key is deactivated; inventory is locked.
not_found
404
No such resource within this key's scope. Also returned for a path no v1 endpoint serves.
method_not_allowed
405
The path exists but does not accept this HTTP verb. The `Allow` response header lists the ones it does.
conflict
409
The resource is in a state that forbids the operation.
not_registered
409
The affiliate is not registered on the target network.
job_not_ready
409
The export job has not finished yet.
job_not_failed
409
Only a failed job can be retried.
max_retries_exceeded
409
The job has exhausted its retries.
link_not_supported
409
Brand/connector cannot carry the requested link. Body also has "kind": "blocked".
merchant_unavailable
409
The advertiser is platform-deactivated, its program has ended, or your owner hid it.
domain_ambiguous
409
The domain matched more than one advertiser in your scope. Resubmit with network_slug from candidates[].
product_unavailable
409
The product's recorded destination is absent, blank, unparseable, or was dropped by the connector's link format.
validation_error
422
Syntactically valid, semantically impossible.
not_supported
422
Not supported for this network or brand.
rate_limited
429
Throttled. Always carries Retry-After.
internal_error
500
A genuine, non-transient server fault. Quote the request_id.
service_unavailable
503
A dependency is unavailable.
timeout
503
A 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.
Tier
Window
Requests
Standard
1 second
10
Standard
1 hour
3,000
Reporting
1 second
4
Reporting
1 hour
600
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.
Parameter
In
Type
Required
Notes
limit
query
integer
no · default 50
Page 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`.
cursor
query
string
no
OPAQUE 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.
q
query
string
no
Case-insensitive substring match on the brand name. An empty value is treated as absent.
country
query
string
no
ISO-3166 alpha-2 merchant country, case-insensitive. An empty value is treated as absent.
has_products
query
string (true | 1 | yes | on)
no
Restricts 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.
category
query
string
no
Substring match on the retail category path or leaf name. An over-broad value returns 400 `filter_too_broad`.
min_traffic
query
integer
no
Only brands whose domain has at least this many estimated monthly visits. An over-broad value returns 400 `filter_too_broad`.
network
query
string
no
Narrow 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
query
string (all | joined | pending | declined | not_joined)
no
Filter by your relationship in the SAME vocabulary the Affilitera screens use (the `relationship` response field): `joined`, `pending`, `declined`, `not_joined`, or `all` (the default). This endpoint serves joined programmes only, so `joined` and `all` return the normal page, while `pending`, `declined` and `not_joined` are accepted and ALWAYS return 200 with an empty `data` array and a `filter_notices` entry (filter `relationship`) saying so. That is by design, not a missing result. Case-insensitive. Any other value is refused with 422.
Narrow 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_since
query
string
no
ISO 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_revshare
query
string
no
Include brands whose ONLY published commission term is a revenue share, which are WITHHELD BY DEFAULT. A revshare-only brand quotes a share of the merchant's own revenue rather than a rate on the sale, so its number is not comparable with an ordinary commission and rendering the two side by side misleads. Default `false`. Accepted true values: `true`, `1`, `yes`, `on`; accepted false values: `false`, `0`, `no`, `off`. ANY OTHER VALUE IS A 422 `validation_error`, deliberately — silently reading an unrecognised value as `false` would answer a request to WIDEN the result set by narrowing it. On `/brands` the exclusion is applied to the listing, its counts and its `modified_since` results by the same predicate, so those three cannot come apart. WHAT YOU OBSERVE WHEN A BRAND LEAVES THE SET: silence. The row is simply absent from later pages — there is no tombstone and it is NOT a `modified_since` event, so an incremental consumer keeps whatever it last cached and cannot learn from this API that the brand went away.
deeplink_allowed
query
string
no
Keep only brands whose `deeplink_allowed` is this value. Accepted true values: `true`, `1`, `yes`, `on`; accepted false values: `false`, `0`, `no`, `off`; any other value is a 422 `validation_error`. Applied to the computed field after the page is read, so a filtered page can be SHORTER than `limit` and `total` is null while it is in force; keep following `next_cursor` until it is null to see every match.
get/subnetwork/productscatalogue:read
List products in this key's inventory
Parameter
In
Type
Required
Notes
limit
query
integer
no · default 50
Page 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`.
cursor
query
string
no
OPAQUE 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_id
query
integer
no
Restrict to one brand, by the brand's number (`brand_id` on `GET /subnetwork/brands`). Digits only. A uuid or a `brd_…` handle is a `validation_error` (400) — those forms are no longer accepted. A number naming no brand is answered exactly like a brand outside this key's scope.
feed_id
query
string
no
Restrict to one feed.
modified_since
query
string
no
ISO 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_delisted
query
string (true | 1 | yes | on | false | 0 | no | off)
no
Include products whose `status` is `delisted`. Delisted rows are EXCLUDED by default: a delisted product is one the platform is not serving (the feed stopped carrying it, its programme is not approved, or it is out of stock), so serving it unmarked could hand you a dead link. Accepts true, 1, yes, on (and false, 0, no, off); any other value is a 422 validation_error rather than a silent default. When opted in, read `status` on each row. To re-admit ONLY out-of-stock products, use include_out_of_stock.
include_out_of_stock
query
string (true | 1 | yes | on | false | 0 | no | off)
no
Include products hidden because their network reports them OUT OF STOCK. They are EXCLUDED by default, and each reappears on its own when the network reports it back in stock. true re-admits exactly those products (status delisted, stock_state out_of_stock) and no other delisted row. total counts exactly what the chosen option returns. Accepts true, 1, yes, on (and false, 0, no, off); any other value is a 422 validation_error. With include_delisted=true every delisted row, these included, is already returned.
include_link
query
string (true)
no
Mint YOUR OWN tracked affiliate link for every row in this same call — the same link `POST /subnetwork/links` would build for that `brand_id` + `product_id`, with your account's own subid baked in. Defaults to false; the response is byte-identical to today when omitted, not merely null-valued. When true, every row gains `affiliate_link`, `unusable_reason` and `unusable_origin`. A row whose link cannot be built is kept, never dropped, with `affiliate_link: null` and a stated reason — see the `Product` schema and the Unusable-items documentation. The literal string `true` is the only value that opts in.
include_commission
query
string (true | 1 | yes | on | false | 0 | no | off)
no
Add each row's per-product `commission`: the commission tier that applies to this product (matched exactly, or the lowest tier that could apply when the brand publishes several and the product cannot be matched to exactly one), and what YOU take home on a sale at the row's current price after the network's revenue split. Defaults to false; the response is byte-identical when omitted. Never a figure without its currency.
category_id
query
string
no
Restrict to products in these categories, by category number, comma-separated (at most 50). Each category includes every category beneath it. The same filter as the Catalogs page's Category filter. A number naming no category is a `validation_error` (400). Not combinable with `sort=relevance` on /products/search (400); use `sort=name`. A category whose index is still being built is answered with a `validation_error` naming that, never an empty list; retry later.
country
query
string
no
The PRODUCT's country: where the feed states one for the product itself (a travel destination or an event location), that; otherwise its brand's programme country (connector-stated only). The same filter as the Catalogs page's Country. Comma-separated ISO 3166-1 alpha-2 codes (GB and UK name the same country), and/or the token __unknown__ for products whose country is not known. Omit for all countries. At most 300 values. A value outside [A-Za-z0-9_-]{1,40} is a 400 invalid_parameter naming this parameter; a well-formed code no product carries simply matches nothing. Resolved over the feeds in this key's scope: a selection spanning more feeds than one page can read is a 422 validation_error, never a partial page; not available with sort=relevance (422).
source_country
query
string
no
The ADVERTISER's market: the brand's programme country, as the connector states it (a brand whose network states none matches __unknown__). The Catalogs page's Advertiser country (Travel). Comma-separated ISO 3166-1 alpha-2 codes (GB and UK name the same country), and/or the token __unknown__ for products whose country is not known. Omit for all countries. At most 300 values. A value outside [A-Za-z0-9_-]{1,40} is a 400 invalid_parameter naming this parameter; a well-formed code no product carries simply matches nothing. Not available with sort=relevance (422).
destination_country
query
string
no
WHERE the hotel, flight, trip or event IS: the country derived from the feed's own location fields (country, location, city, region, destination name, arrival airport) and stored per product at ingestion. A product whose feed names no place that resolves matches __unknown__, never a guess. The Catalogs page's Destination country (Travel). Comma-separated ISO 3166-1 alpha-2 codes (GB and UK name the same country), and/or the token __unknown__ for products whose country is not known. Omit for all countries. At most 300 values. A value outside [A-Za-z0-9_-]{1,40} is a 400 invalid_parameter naming this parameter; a well-formed code no product carries simply matches nothing. Not available with sort=relevance (422).
include_revshare
query
string
no
Include brands whose ONLY published commission term is a revenue share, which are WITHHELD BY DEFAULT. A revshare-only brand quotes a share of the merchant's own revenue rather than a rate on the sale, so its number is not comparable with an ordinary commission and rendering the two side by side misleads. Default `false`. Accepted true values: `true`, `1`, `yes`, `on`; accepted false values: `false`, `0`, `no`, `off`. ANY OTHER VALUE IS A 422 `validation_error`, deliberately — silently reading an unrecognised value as `false` would answer a request to WIDEN the result set by narrowing it. On `/brands` the exclusion is applied to the listing, its counts and its `modified_since` results by the same predicate, so those three cannot come apart. WHAT YOU OBSERVE WHEN A BRAND LEAVES THE SET: silence. The row is simply absent from later pages — there is no tombstone and it is NOT a `modified_since` event, so an incremental consumer keeps whatever it last cached and cannot learn from this API that the brand went away.
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.
Parameter
In
Type
Required
Notes
limit
query
integer
no · default 50
Page 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`.
cursor
query
string
no
OPAQUE 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.
q
query
string
yes
Case-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_id
query
integer
no
Restrict to one brand, by the brand's number (`brand_id` on `GET /subnetwork/brands`). Digits only. A uuid or a `brd_…` handle is a `validation_error` (400) — those forms are no longer accepted. A number naming no brand is answered exactly like a brand outside this key's scope.
feed_id
query
string
no
Restrict 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_since
query
string
no
ISO 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_delisted
query
string (true | 1 | yes | on | false | 0 | no | off)
no
Include products whose `status` is `delisted`. Delisted rows are EXCLUDED by default: a delisted product is one the platform is not serving (the feed stopped carrying it, its programme is not approved, or it is out of stock), so serving it unmarked could hand you a dead link. Accepts true, 1, yes, on (and false, 0, no, off); any other value is a 422 validation_error rather than a silent default. When opted in, read `status` on each row. To re-admit ONLY out-of-stock products, use include_out_of_stock.
include_out_of_stock
query
string (true | 1 | yes | on | false | 0 | no | off)
no
Include products hidden because their network reports them OUT OF STOCK. They are EXCLUDED by default, and each reappears on its own when the network reports it back in stock. true re-admits exactly those products (status delisted, stock_state out_of_stock) and no other delisted row. total counts exactly what the chosen option returns. Accepts true, 1, yes, on (and false, 0, no, off); any other value is a 422 validation_error. With include_delisted=true every delisted row, these included, is already returned.
include_link
query
string (true)
no
Mint YOUR OWN tracked affiliate link for every row in this same call — the same link `POST /subnetwork/links` would build for that `brand_id` + `product_id`, with your account's own subid baked in. Defaults to false; the response is byte-identical to today when omitted, not merely null-valued. When true, every row gains `affiliate_link`, `unusable_reason` and `unusable_origin`. A row whose link cannot be built is kept, never dropped, with `affiliate_link: null` and a stated reason — see the `Product` schema and the Unusable-items documentation. The literal string `true` is the only value that opts in.
include_commission
query
string (true | 1 | yes | on | false | 0 | no | off)
no
Add each row's per-product `commission`: the commission tier that applies to this product (matched exactly, or the lowest tier that could apply when the brand publishes several and the product cannot be matched to exactly one), and what YOU take home on a sale at the row's current price after the network's revenue split. Defaults to false; the response is byte-identical when omitted. Never a figure without its currency.
category_id
query
string
no
Restrict to products in these categories, by category number, comma-separated (at most 50). Each category includes every category beneath it. The same filter as the Catalogs page's Category filter. A number naming no category is a `validation_error` (400). Not combinable with `sort=relevance` on /products/search (400); use `sort=name`. A category whose index is still being built is answered with a `validation_error` naming that, never an empty list; retry later.
sort
query
string (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_price
query
string
no
Minimum 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_price
query
string
no
Maximum price, inclusive. Same currency requirement as `min_price`.
currency
query
string
no
ISO-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.
category
query
string
no
Case-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_match
query
string (true | false)
no
OPT-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.
country
query
string
no
The PRODUCT's country: where the feed states one for the product itself (a travel destination or an event location), that; otherwise its brand's programme country (connector-stated only). The same filter as the Catalogs page's Country. Comma-separated ISO 3166-1 alpha-2 codes (GB and UK name the same country), and/or the token __unknown__ for products whose country is not known. Omit for all countries. At most 300 values. A value outside [A-Za-z0-9_-]{1,40} is a 400 invalid_parameter naming this parameter; a well-formed code no product carries simply matches nothing. Resolved over the feeds in this key's scope: a selection spanning more feeds than one page can read is a 422 validation_error, never a partial page; not available with sort=relevance (422).
source_country
query
string
no
The ADVERTISER's market: the brand's programme country, as the connector states it (a brand whose network states none matches __unknown__). The Catalogs page's Advertiser country (Travel). Comma-separated ISO 3166-1 alpha-2 codes (GB and UK name the same country), and/or the token __unknown__ for products whose country is not known. Omit for all countries. At most 300 values. A value outside [A-Za-z0-9_-]{1,40} is a 400 invalid_parameter naming this parameter; a well-formed code no product carries simply matches nothing. Not available with sort=relevance (422).
destination_country
query
string
no
WHERE the hotel, flight, trip or event IS: the country derived from the feed's own location fields (country, location, city, region, destination name, arrival airport) and stored per product at ingestion. A product whose feed names no place that resolves matches __unknown__, never a guess. The Catalogs page's Destination country (Travel). Comma-separated ISO 3166-1 alpha-2 codes (GB and UK name the same country), and/or the token __unknown__ for products whose country is not known. Omit for all countries. At most 300 values. A value outside [A-Za-z0-9_-]{1,40} is a 400 invalid_parameter naming this parameter; a well-formed code no product carries simply matches nothing. Not available with sort=relevance (422).
include_revshare
query
string
no
Include brands whose ONLY published commission term is a revenue share, which are WITHHELD BY DEFAULT. A revshare-only brand quotes a share of the merchant's own revenue rather than a rate on the sale, so its number is not comparable with an ordinary commission and rendering the two side by side misleads. Default `false`. Accepted true values: `true`, `1`, `yes`, `on`; accepted false values: `false`, `0`, `no`, `off`. ANY OTHER VALUE IS A 422 `validation_error`, deliberately — silently reading an unrecognised value as `false` would answer a request to WIDEN the result set by narrowing it. On `/brands` the exclusion is applied to the listing, its counts and its `modified_since` results by the same predicate, so those three cannot come apart. WHAT YOU OBSERVE WHEN A BRAND LEAVES THE SET: silence. The row is simply absent from later pages — there is no tombstone and it is NOT a `modified_since` event, so an incremental consumer keeps whatever it last cached and cannot learn from this API that the brand went away.
get/subnetwork/feedscatalogue:read
List product feeds
Parameter
In
Type
Required
Notes
limit
query
integer
no · default 50
Page 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`.
cursor
query
string
no
OPAQUE 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_since
query
string
no
ISO 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_revshare
query
string
no
Include brands whose ONLY published commission term is a revenue share, which are WITHHELD BY DEFAULT. A revshare-only brand quotes a share of the merchant's own revenue rather than a rate on the sale, so its number is not comparable with an ordinary commission and rendering the two side by side misleads. Default `false`. Accepted true values: `true`, `1`, `yes`, `on`; accepted false values: `false`, `0`, `no`, `off`. ANY OTHER VALUE IS A 422 `validation_error`, deliberately — silently reading an unrecognised value as `false` would answer a request to WIDEN the result set by narrowing it. On `/brands` the exclusion is applied to the listing, its counts and its `modified_since` results by the same predicate, so those three cannot come apart. WHAT YOU OBSERVE WHEN A BRAND LEAVES THE SET: silence. The row is simply absent from later pages — there is no tombstone and it is NOT a `modified_since` event, so an incremental consumer keeps whatever it last cached and cannot learn from this API that the brand went away.
A feed inside this key's scope. A feed belonging to anyone else returns 404.
limit
query
integer
no · default 50
Page 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`.
cursor
query
string
no
OPAQUE 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_since
query
string
no
ISO 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_delisted
query
string (true | 1 | yes | on | false | 0 | no | off)
no
Include products whose `status` is `delisted`. Delisted rows are EXCLUDED by default: a delisted product is one the platform is not serving (the feed stopped carrying it, its programme is not approved, or it is out of stock), so serving it unmarked could hand you a dead link. Accepts true, 1, yes, on (and false, 0, no, off); any other value is a 422 validation_error rather than a silent default. When opted in, read `status` on each row. To re-admit ONLY out-of-stock products, use include_out_of_stock.
include_out_of_stock
query
string (true | 1 | yes | on | false | 0 | no | off)
no
Include products hidden because their network reports them OUT OF STOCK. They are EXCLUDED by default, and each reappears on its own when the network reports it back in stock. true re-admits exactly those products (status delisted, stock_state out_of_stock) and no other delisted row. total counts exactly what the chosen option returns. Accepts true, 1, yes, on (and false, 0, no, off); any other value is a 422 validation_error. With include_delisted=true every delisted row, these included, is already returned.
include_link
query
string (true)
no
Mint YOUR OWN tracked affiliate link for every row in this same call — the same link `POST /subnetwork/links` would build for that `brand_id` + `product_id`, with your account's own subid baked in. Defaults to false; the response is byte-identical to today when omitted, not merely null-valued. When true, every row gains `affiliate_link`, `unusable_reason` and `unusable_origin`. A row whose link cannot be built is kept, never dropped, with `affiliate_link: null` and a stated reason — see the `Product` schema and the Unusable-items documentation. The literal string `true` is the only value that opts in.
include_revshare
query
string
no
Include brands whose ONLY published commission term is a revenue share, which are WITHHELD BY DEFAULT. A revshare-only brand quotes a share of the merchant's own revenue rather than a rate on the sale, so its number is not comparable with an ordinary commission and rendering the two side by side misleads. Default `false`. Accepted true values: `true`, `1`, `yes`, `on`; accepted false values: `false`, `0`, `no`, `off`. ANY OTHER VALUE IS A 422 `validation_error`, deliberately — silently reading an unrecognised value as `false` would answer a request to WIDEN the result set by narrowing it. On `/brands` the exclusion is applied to the listing, its counts and its `modified_since` results by the same predicate, so those three cannot come apart. WHAT YOU OBSERVE WHEN A BRAND LEAVES THE SET: silence. The row is simply absent from later pages — there is no tombstone and it is NOT a `modified_since` event, so an incremental consumer keeps whatever it last cached and cannot learn from this API that the brand went away.
get/subnetwork/advertiserscontracts:read
List your advertisers' accounts and their commission groups
Every advertiser (brand) on this key's own connections, with its advertiser account number, whether the advertiser can log in, and its commission groups: the one public default group and any private groups, each with the affiliate split. Numeric ids only. Another operator's advertisers are never listed. A group's `split_pct` is the affiliate's share of the commission the network pays, after Affilitera's platform fee; it is a percentage, not money.
Parameter
In
Type
Required
Notes
brand_id
query
integer
no
Only this advertiser (its brand number). Not a UUID; anything other than a positive whole number is a 400 `invalid_parameter`.
cursor
query
string
no
OPAQUE 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.
limit
query
integer
no · default 50
Page 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`.
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.
Parameter
In
Type
Required
Notes
cursor
query
string
no
OPAQUE 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.
limit
query
integer
no · default 50
Page 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_id
query
integer
no
Restrict to one brand, by the brand's number (`brand_id` on `GET /subnetwork/brands`). Digits only. A uuid or a `brd_…` handle is a `validation_error` (400) — those forms are no longer accepted. A number naming no brand is answered exactly like a brand outside this key's scope.
offer_type
query
string
no
Restrict 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.
country
query
array
no
Restrict to the countries a promotion is for, ANY-of. A comma list of ISO 3166-1 alpha-2 codes (`US,GB`), plus `__unknown__` for promotions with no known country; the parameter may also be repeated. A promotion's countries are its OWN stated countries where the network gives them (Awin regions), otherwise its advertiser's country as the network published it. `UK` is read as `GB`. A value outside letters, digits, `-` and `_` is a `validation_error` (422), never an unfiltered page.
sub_id
query
string
no
Optional. YOUR OWN extra tracking value, carried on the earner mapping behind every link this call returns and echoed back in your reports — it is NEVER placed in the link URL, so it is not visible to the network or the merchant. Letters, digits, dot, underscore, tilde and hyphen, 64 characters at most. A value outside that set is a 422 rather than a silently dropped field.
get/subnetwork/creativespromotions:read
List network creatives
Parameter
In
Type
Required
Notes
cursor
query
string
no
OPAQUE 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.
limit
query
integer
no · default 50
Page 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_id
query
integer
no
Restrict to one brand, by the brand's number (`brand_id` on `GET /subnetwork/brands`). Digits only. A uuid or a `brd_…` handle is a `validation_error` (400) — those forms are no longer accepted. A number naming no brand is answered exactly like a brand outside this key's scope.
kind
query
string (image_banner | text_link)
no
Restrict 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.
size
query
string
no
Restrict to one banner size, `WIDTHxHEIGHT` (e.g. `300x250`). A malformed value is a `validation_error` (422).
id
query
integer
no
Restrict to one creative by its numeric `id`.
sn_sub
query
string
no
The sub-affiliate each row's tracked `code` is minted under. Must be a sub already registered and active for this key's owner; absent = the connection's stored sub-ID. With neither, `code` is null and `code_error.code` is `sub_id_required` — a code is never issued without our tracking and the sub-ID.
sub_affiliate_number
query
integer
no
OPTIONAL alternative to `sn_sub`: the sub-affiliate's number. Names the same sub; sending both for two different subs is a 422, a number naming none of your subs is a 422, and a non-number is a 400.
sub_id
query
string
no
Optional. YOUR OWN extra tracking value, carried on the earner mapping behind every link this call returns and echoed back in your reports — it is NEVER placed in the link URL, so it is not visible to the network or the merchant. Letters, digits, dot, underscore, tilde and hyphen, 64 characters at most. A value outside that set is a 422 rather than a silently dropped field.
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.
Parameter
In
Type
Required
Notes
cursor
query
string
no
OPAQUE 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.
limit
query
integer
no · default 50
Page 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_id
query
integer
no
Restrict to one brand, by the brand's number (`brand_id` on `GET /subnetwork/brands`). Digits only. A uuid or a `brd_…` handle is a `validation_error` (400) — those forms are no longer accepted. A number naming no brand is answered exactly like a brand outside this key's scope.
offer_type
query
string
no
Restrict to one offer type (connector-supplied, e.g. `sale`/`free_shipping` on CJ).
country
query
array
no
Restrict to the countries a promotion is for, ANY-of. A comma list of ISO 3166-1 alpha-2 codes (`US,GB`), plus `__unknown__` for promotions with no known country; the parameter may also be repeated. A promotion's countries are its OWN stated countries where the network gives them (Awin regions), otherwise its advertiser's country as the network published it. `UK` is read as `GB`. A value outside letters, digits, `-` and `_` is a `validation_error` (422), never an unfiltered page.
sub_id
query
string
no
Optional. YOUR OWN extra tracking value, carried on the earner mapping behind every link this call returns and echoed back in your reports — it is NEVER placed in the link URL, so it is not visible to the network or the merchant. Letters, digits, dot, underscore, tilde and hyphen, 64 characters at most. A value outside that set is a 422 rather than a silently dropped field.
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. A product_id is never required: pass only brand_id to POST /links and get back a link to the advertiser’s own website (type: "brand") — the same call with a product_id added links to that one item instead. Starting from a website instead of a brand id? POST /links/by-domain takes a domain directly. A brand link that cannot be built is refused with 409 and a stable brand_link_refusal code — never an empty or untracked URL.
get/subnetwork/linkstrackedlinks:read
MODE-KEYED, and the value above is the scope for THIS operation as documented (`?sn_sub=` present). `GET /links` and `GET /sub-affiliates` are the same handler, and it picks the scope from the MODE, not the path: omit `sn_sub` and the call lists the affiliate ROSTER and requires `subaffiliates:read` instead. A key holding only `trackedlinks:read` therefore gets 403 `insufficient_scope` from this path when it omits `sn_sub`.
List tracked links minted for an affiliate
Parameter
In
Type
Required
Notes
sn_sub
query
string
no
OPTIONAL. When present, lists this affiliate's persisted tracked links instead of the sub roster (mutually exclusive with `status`; must belong to this key's owner or returns 404). When absent, lists the affiliate roster — same handler and response shape as `GET /sub-affiliates`.
sub_affiliate_number
query
integer
no
OPTIONAL alternative to `sn_sub`: the sub-affiliate's number. Names the same sub; sending both for two different subs is a 422, a number naming none of your subs is a 422, and a non-number is a 400.
status
query
string (active | observed | all)
no
Only applies to the roster-listing form (ignored when `sn_sub` is present). Anything else returns 422 `validation_error`.
cursor
query
string
no
OPAQUE 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.
limit
query
integer
no · default 50
Page 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/linkstrackedlinks:write
Mint a deep link for a brand
Parameter
In
Type
Required
Notes
Idempotency-Key
header
string
no
Makes 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)
Field
Type
Required
Notes
destination_url
string
no
OPTIONAL. A SPECIFIC PAGE on the advertiser's own website for this link to open, e.g. `https://affilitera.com/example-advertiser-page` — sent in the serving network's own destination parameter, with your subid carried exactly as on a brand link. Allowed only where DEEP LINKING IS ALLOWED for the advertiser on the network that serves it (`deeplink_allowed` on `GET /subnetwork/brands`); otherwise 409 `link_not_supported` with `kind: "deeplink_not_allowed"` and the message "This advertiser doesn't allow links to specific pages — this link goes to the advertiser's default landing page." The page must be on the advertiser's own domain(s): an off-domain page is 422 `validation_error` with `kind: "destination_off_domain"` (the message names the allowed domains), a value that is not an http(s) URL 422 with `kind: "destination_invalid"`. Mutually exclusive with `product_id` (422). Omit it for the advertiser's default landing page.
brand_id
integer
yes
The brand's number (`brand_id` on `GET /subnetwork/brands`): a JSON integer or a digits-only string. A uuid or a `brd_…` handle is a `validation_error` (400) — those forms are no longer accepted; a number naming no brand is answered exactly like a brand outside this key's scope.
product_id
string
no
OPTIONAL. A PRODUCT LINK sends the visitor to that product's own page: the product URL wrapped with the network's documented deeplink parameter, your subid carried as on a brand link, and the 201 carries a `product` object. CJ is refused today: the only CJ product link Affilitera holds carries the syncing operator's credential and is never served. A product link that cannot be built is a 409 with NO `url`, a `network` and a stable `product_link_refusal`: `advertiser_disabled_deeplinks`, `network_link_not_servable_without_credential`, `network_has_no_destination_param`, `network_deeplink_endpoint_not_built`, `network_destination_param_not_established`, `network_link_broken`, `network_link_not_held` or `destination_dropped` — never a link to the merchant's homepage instead. Must belong to brand_id. OMIT IT FOR A BRAND LINK: a tracked link to the merchant's own website, `type: "brand"`. A brand link that cannot be built is a 409 with NO `url` and a stable `brand_link_refusal`: `network_brand_link_broken` (every CJ merchant — the CJ link we build does not resolve at CJ) or `brand_website_not_recorded` (a Rakuten merchant with no recorded website — Rakuten sends a link with no destination to an error page, so a Rakuten brand link lands on the merchant's recorded website). On every other network the brand link is the network's own merchant link carrying your subid.
subid
string
no
OPTIONAL. Attribute the link to an affiliate for this call only, overriding the caller's stored per-{account, network} subid. Invalid value → 422 (never a silent fallback to the stored one). Neither this nor a stored subid → 409 not_registered. CLICKBANK AFFILIATE LINKS: ClickBank's `tid` accepts only lowercase letters, digits and underscores, so all sub-IDs on a ClickBank link must be lowercase. The code Affilitera places in `tid` is always lowercase; a sub-ID you write into a ClickBank link yourself (for example into `link_template`) must be lowercase too, or ClickBank may not return it as written.
label
string
no
OPTIONAL, cosmetic only.
get/subnetwork/links/minetrackedlinks:read
List every link you created (Your links)
Every link this account created (Promote → Links, `POST /links`, `POST /links/tracked`), newest first by default, with its label, affiliate ID, Sub-ID, advertiser, destination, click recording, created date, clicks, sales and commission per currency. Numeric ids only. `GET /subnetwork/links` is unchanged — it remains the affiliate roster and per-affiliate tracked links.
Parameter
In
Type
Required
Notes
q
query
string
no
Search label, affiliate ID, Sub-ID, advertiser name and destination.
Sort key; default `created`. Any other value is a 400 `invalid_parameter`.
order
query
string (asc | desc)
no
Default `desc` for `created`, `asc` otherwise.
cursor
query
string
no
OPAQUE 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.
limit
query
integer
no · default 50
Page 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/links/trackedtrackedlinks:write
Mint a click-tracked link
Parameter
In
Type
Required
Notes
Idempotency-Key
header
string
no
Makes 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)
Field
Type
Required
Notes
destination_url
string
no
OPTIONAL. A SPECIFIC PAGE on the advertiser's own website for this link to open, e.g. `https://affilitera.com/example-advertiser-page` — sent in the serving network's own destination parameter, with your subid carried exactly as on a brand link. Allowed only where DEEP LINKING IS ALLOWED for the advertiser on the network that serves it (`deeplink_allowed` on `GET /subnetwork/brands`); otherwise 409 `link_not_supported` with `kind: "deeplink_not_allowed"` and the message "This advertiser doesn't allow links to specific pages — this link goes to the advertiser's default landing page." The page must be on the advertiser's own domain(s): an off-domain page is 422 `validation_error` with `kind: "destination_off_domain"` (the message names the allowed domains), a value that is not an http(s) URL 422 with `kind: "destination_invalid"`. Mutually exclusive with `product_id` (422). Omit it for the advertiser's default landing page.
brand_id
integer
yes
The brand's number (`brand_id` on `GET /subnetwork/brands`): a JSON integer or a digits-only string. A uuid or a `brd_…` handle is a `validation_error` (400) — those forms are no longer accepted; a number naming no brand is answered exactly like a brand outside this key's scope.
product_id
string
no
OPTIONAL. See POST /subnetwork/links.
subid
string
no
OPTIONAL. See POST /subnetwork/links.
label
string
no
An optional DISPLAY NAME to store against the minted link. Free text, never interpreted by us, and never part of the link's identity or attribution — the link is identified by its `slug`. Omit it if you have nothing to record.
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".
Parameter
In
Type
Required
Notes
cursor
query
string
no
OPAQUE 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.
limit
query
integer
no · default 50
Page 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`.
network
query
string
no
Restrict 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.
get/subnetwork/links/resolvetrackedlinks:read
Resolve a tracked link to its attribution
REVERSE lookup on a link you already minted — NOT a merchant-URL-to-brand search. Given a tracked link's slug, or the full tracking URL it was returned in, this returns which affiliate, brand, network and destination URL that link is attributed to.
Only links persisted by `POST /subnetwork/links/tracked` or `POST /subnetwork/links/by-domain[/bulk]` are resolvable. `POST /subnetwork/links` re-bakes a deterministic deeplink and writes no row, so its output has nothing to resolve FROM and reads back as a 404.
SEND ONE OF `slug` OR `url`. Neither is required on its own; sending neither is 422. A slug that does not exist, and a slug belonging to another owner's container, return the SAME flat 404 — the response is deliberately not an existence oracle.
Parameter
In
Type
Required
Notes
slug
query
string
no
The tracked link's slug. Takes PRECEDENCE over `url` when both are sent.
url
query
string
no
The full tracked-link URL exactly as it was returned to you by the endpoint that minted it. The slug is extracted from the trailing `/t/{slug}` segment, whatever the scheme and host — the tracking base is configurable, so no host is assumed. A value carrying no such segment is used VERBATIM as a slug, which is why this is deliberately not `format: uri`.
post/subnetwork/links/by-domaintrackedlinks:write
Mint a link from a merchant URL
Parameter
In
Type
Required
Notes
Idempotency-Key
header
string
no
Makes 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)
Field
Type
Required
Notes
domain
string
yes
The merchant's bare domain (not a full URL).
affiliate_id
string
yes
The affiliate id to attribute the link to.
destination_url
string
yes
The full merchant page URL to deep-link to. To land on the merchant's home page rather than one item, send the home page itself. A network whose brand-template link does not resolve (see POST /subnetwork/links) refuses here too — the destination does not rescue it — with 409 and `brand_link_refusal: "network_brand_link_broken"`.
network_slug
string
no
OPTIONAL. Disambiguates which merchant to use when the domain matches more than one (see the 409 domain_ambiguous response's candidates).
Makes 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)
Field
Type
Required
Notes
links
object[]
yes
1 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
Parameter
In
Type
Required
Notes
cursor
query
string
no
OPAQUE 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.
limit
query
integer
no · default 50
Page 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`.
status
query
string (active | observed | all)
no
Anything else returns 422 `validation_error`.
post/subnetwork/sub-affiliatessubaffiliates:write
Register an affiliate
Parameter
In
Type
Required
Notes
Idempotency-Key
header
string
no
Makes 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)
Field
Type
Required
Notes
sn_sub
string
yes
Letters, numbers, underscores and hyphens only, max 64 chars.
label
string
no
An 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.
kind
string (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:
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.
instance_public_id is the durable handle for the network connection this sale ran through — the same value GET /instance, /brands, /products and /feeds emit, so you can join a transaction back to your catalogue without holding the internal instance_id uuid. sn_sub_resolution is null only when the row carries no sn_sub at all; otherwise it is not_encoded for a plain sub-id like creator_amy, or resolved/unresolved when sn_sub packs several registered sub-ids and this row states whether that pack could be decoded — unresolved is the value worth alerting on, since it means a packed sub-id arrived that could not be trusted.
brand_id is the brand’s number: a positive integer that stays the same when a network is disconnected and reconnected, so it is safe to store. It is the only brand id the API serves, and the only one every brand_id parameter accepts — a uuid or a brd_ handle is refused with a 400. Affiliate ids work the same way: affiliate_id on /v1/affiliate/* is the affiliate’s number. An affiliate in your own network keeps the name you give it (sn_sub) and also has a number, sub_affiliate_number, which you may send instead of the name.
merchant_id is the network’s own id for the advertiser, and its type is integer | string | null. The type varies by network because network ids do: where the id is genuinely an integer it is a JSON integer (501419), and where it is not it is the network’s string, unchanged. One connection can serve both kinds on different rows, so check the type per row. Leading zeros, a sign or whitespace keep an id a string ("007" is not 7), and any id above 9007199254740991 is always a string, because a JSON number that large loses digits. The same rule applies on every endpoint that carries merchant_id.
For Impact, merchant_id is the AdvertiserId (the brand), not the CampaignId (the programme). One Impact advertiser can run several programmes, each its own row with its own brand_id, so several rows can carry the same merchant_id. That is correct, not a duplicate: key per programme on brand_id. Linkbux works the same way: merchant_id is its advertiser id, mid, not the programme mcid.
For every other network, merchant_id is the id that network itself gives the brand: an advertiser, programme or offer id, depending on how that network keys its catalogue. It is never one of our ids. Our own identifiers have their own fields (brand_id, merchant_number) and never appear in merchant_id. So a brand no network issued an id to, such as an advertiser running its programme directly on Affilitera, has merchant_id: null.
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:
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).
Parameter
In
Type
Required
Notes
label
query
string
no
Only sales on your links carrying this exact label (see `label` on `POST /links`). A label no link carries returns no sales.
limit
query
integer
no · default 50
Page 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.
cursor
query
string
no
OPAQUE 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.
from
query
string
no
Start 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.
to
query
string
no
End 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_id
query
integer
no
Restrict to one brand, by the brand's number (`brand_id` on `GET /subnetwork/brands`). Digits only. A uuid or a `brd_…` handle is a `validation_error` (400) — those forms are no longer accepted. A number naming no brand is answered exactly like a brand outside this key's scope.
sn_sub
query
string
no
Restrict to one affiliate's transactions — the exact stored `sn_sub` value.
sub_affiliate_number
query
integer
no
OPTIONAL alternative to `sn_sub`: the sub-affiliate's number. Names the same sub; sending both for two different subs is a 422, a number naming none of your subs is a 422, and a non-number is a 400.
status
query
string
no
Restrict to one order status (network-reported, not the closed `status_bucket` set).
currency
query
string
no
Restrict 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
Parameter
In
Type
Required
Notes
label
query
string
no
Only sales on your links carrying this exact label (see `label` on `POST /links`). A label no link carries returns no sales.
limit
query
integer
no · default 50
A 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.
cursor
query
string
no
OPAQUE 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.
from
query
string
no
Start 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.
to
query
string
no
End 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_id
query
integer
no
Restrict to one brand, by the brand's number (`brand_id` on `GET /subnetwork/brands`). Digits only. A uuid or a `brd_…` handle is a `validation_error` (400) — those forms are no longer accepted. A number naming no brand is answered exactly like a brand outside this key's scope.
sn_sub
query
string
no
Restrict to one affiliate's orders before aggregating.
sub_affiliate_number
query
integer
no
OPTIONAL alternative to `sn_sub`: the sub-affiliate's number. Names the same sub; sending both for two different subs is a 422, a number naming none of your subs is a 422, and a non-number is a 400.
status
query
string
no
Restrict to one order status (network-reported) before aggregating.
currency
query
string
no
Restrict to one ISO-4217 currency before aggregating. Never converts or cross-sums — every bucket is already single-currency.
group_by
query
string
no
Comma-separated subset of `brand`, `sn_sub`, `date`, `label` (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. `label` groups by the label of the link each sale came through (`group.label`, null = no labelled link). A request using `label` (as a group or as the `label` filter) is aggregated over every matching order and answered in one page (no `next_cursor`).
granularity
query
string (day | month)
no · default day
Bucket width when `group_by` includes `date`. Ignored otherwise. Echoed back on the response ONLY when `group_by` includes `date`.
get/subnetwork/sub-affiliates/reportreports:read
Per-affiliate performance and the split of commission
For each of your affiliates and for all of them together, one row per currency: clicks, sales, conversion rate, the commission your side of the split received on those sales (`commission_generated`), the affiliate's share after your own controls (the status you set on a sale, pause and removal), your share, and the status breakdown by the network's status and by the status you set. Amounts are DECIMAL STRINGS and are never summed across currencies. `clicks` is what your networks report; it is `null`, not zero, when none of your connections reports clicks.
Parameter
In
Type
Required
Notes
from
query
string
no
Start of the window, INCLUSIVE, on each sale's `transaction_date`. `YYYY-MM-DD`.
to
query
string
no
End of the window, INCLUSIVE OF THE WHOLE CALENDAR DAY. `YYYY-MM-DD`.
brand_id
query
integer
no
Restrict to an advertiser, by its number. May repeat. A number naming no advertiser is a 422.
network
query
string
no
Restrict to one of your connections, by the network's slug (e.g. `awin`). May repeat. A slug matching none of your connections is a 400.
currency
query
string
no
Restrict to one ISO-4217 currency.
country
query
string
no
Restrict to advertisers in one country (the network-stated country, else the derived one).
sub_affiliate_number
query
integer
no
Restrict to an affiliate, by its number. May repeat. A number naming none of your affiliates is a 422.
label
query
string
no
Only sales on links carrying this exact label.
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`.
Parameter
In
Type
Required
Notes
Idempotency-Key
header
string
no
Makes 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)
Field
Type
Required
Notes
from
string
yes
Start 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.
to
string
yes
End of the exported window, INCLUSIVE OF THE WHOLE CALENDAR DAY, compared against `transaction_date`. Calendar day (`YYYY-MM-DD`).
format
string (csv)
no · default csv
Output 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_id
integer
no
The brand's number (`brand_id` on `GET /subnetwork/brands`): a JSON integer or a digits-only string. A uuid or a `brd_…` handle is a `validation_error` (400) — those forms are no longer accepted; a number naming no brand is answered exactly like a brand outside this key's scope.
sn_sub
string | null
no
Restrict 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.
status
string | null
no
Restrict 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.
currency
string | null
no
Restrict 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_reference
string | null
no
Idempotency 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.
Parameter
In
Type
Required
Notes
cursor
query
string
no
OPAQUE 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.
limit
query
integer
no · default 50
Page 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.
Parameter
In
Type
Required
Notes
Idempotency-Key
header
string
no
Makes 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)
Field
Type
Required
Notes
sn_sub
string
yes
The affiliate paid. Must be registered under this subnetwork.
sub_affiliate_number
integer
no
OPTIONAL alternative to `sn_sub`: the sub-affiliate's number. Names the same sub; sending both for two different subs is a 422, a number naming none of your subs is a 422, and a non-number is a 400.
amount
string
yes
Amount 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.)
currency
string
yes
ISO-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.
provider
string | null
no
RESERVED 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_currency
string | null
no
RESERVED 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_id
string
no
Required for container keys. For a single-instance key it is resolved from the key.
payment_method
string | null
no
How 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.
reference
string | null
no
Your 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.
note
string | null
no
A 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_reference
string | null
no
Idempotency 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_mode
string (itemized)
no
Switches to itemized mode.
items
object[]
no
Per-order breakdown. Presence alone switches to itemized mode.
direction
string (disbursement | recovery | write_off)
no · default disbursement
ITEMIZED 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.
status
string (recorded | pending)
no · default recorded
Creation 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/reconciliation/linespayouts:read
List network payment reconciliation lines
What your networks say they PAID YOU, one line per transaction, each matched against your orders (network transaction id first, then order reference, then brand + date + amount). Lines come from a network's payment API or from a statement you uploaded. Every id is a number; no internal UUID is returned. Duplicate lines are excluded unless you ask for `outcome=duplicate`.
Parameter
In
Type
Required
Notes
limit
query
integer
no · default 50
Page 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`.
cursor
query
string
no
OPAQUE 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.
Restrict to lines carrying this reconciliation outcome. A line can carry several (for example `amount_mismatch` and `status_differs`).
source
query
string (api | csv)
no
Restrict to lines read from the network's API (`api`) or from an uploaded statement file (`csv`, which also covers XLSX uploads).
month
query
string
no
Restrict to one calendar month, `YYYY-MM`: the month of the line's payment date, or of its transaction date when the network gave no payment date. Anything else returns 400 `invalid_parameter`.
Restrict to the network's payment status, reduced to a class. `unknown` selects lines whose network status word was not recognised — it is never read as paid or pending.
get/subnetwork/reconciliation/summarypayouts:read
Network payment reconciliation totals
Totals of what your networks say they paid you, by network, brand, month and network status — ALWAYS one row per (group, currency). Two currencies are never added together, and duplicate lines are excluded.
Parameter
In
Type
Required
Notes
month
query
string
no
Restrict to one calendar month, `YYYY-MM`: the month of the line's payment date, or of its transaction date when the network gave no payment date. Anything else returns 400 `invalid_parameter`.
source
query
string (api | csv)
no
Restrict to lines read from the network's API (`api`) or from an uploaded statement file (`csv`, which also covers XLSX uploads).
get/subnetwork/payouts/balancespayouts:read
What you owe each affiliate, or owe on each brand
Pending / approved / cancelled / paid / in a batch / owed now, per affiliate or per brand, per currency — the API twin of the owner portal's Balances view, computed by the same fold, so the two never disagree. An affiliate's share of a sale is their commission group's share of the commission the network actually paid on it (Decision 294); no per-sale rate is served. Filters narrow the sales; an unrecognised value is refused (400/422), never silently ignored. Money is a decimal string beside its currency and is never summed across currencies. Every id is a number: `brand_id` is the brand's number; an affiliate is `sn_sub` beside `sub_affiliate_number`.
Parameter
In
Type
Required
Notes
group
query
string (affiliate | brand)
no · default affiliate
Group per affiliate (default) or per brand.
from
query
string
no
Earliest sale date, inclusive (YYYY-MM-DD). A sale with no date is excluded by any date filter.
to
query
string
no
Latest sale date, inclusive (YYYY-MM-DD).
brand_id
query
array
no
Only these brands, by NUMBER (repeatable, or comma-separated). A uuid is a 400; a number naming no brand is a 422.
sn_sub
query
array
no
Only these affiliates, by name (repeatable, or comma-separated).
sub_affiliate_number
query
integer
no
Only this affiliate, by number. A number naming none of your affiliates is a 422.
Only sales in this state. `paid` = in a completed payment; `owed` = approved, unpaid, not in a batch.
currency
query
string
no
Only this currency (ISO-4217, upper-cased).
country
query
string
no
Only sales on brands in these countries: a comma list of stored country codes, plus `__unknown__` for brands with no country.
instance_id
query
string
no
Narrow to one connection. Validated against the key's own authorized set — an unauthorized value is a 422. Default: every connection the key holds.
get/subnetwork/payouts/batchespayouts:read
Your payment batches
Every payment batch you issued, newest first, with its state and per-currency totals. Not paginated. Only `paid` payments count as paid; an exported batch is still owed.
post/subnetwork/payouts/batchespayouts:write
Create a payment batch
Issue one payment per (affiliate, connection, currency) over the approved-and-unpaid sales the selection names — every owed sale of the chosen affiliates (default: every affiliate with something owed), optionally only the sales the network has already paid you for. Each payment also nets every clawback on that affiliate's already-paid sales; netting is not optional. NO AMOUNT IS ACCEPTED: amounts come from the ledger inside the database, and a sale can be in only one open or completed payment (the database refuses a second). Two steps: `dry_run: true` returns the plan and its per-currency `totals`; `dry_run: false` with those totals as `confirm_totals` creates the batch — refused with 409 `conflict` (`reason: totals_changed`) if the sales moved since, so nothing you did not review is ever created. Idempotent under `Idempotency-Key`. A deactivated connection accepts no batch (403 `link_locked`).
Request body (required)
Field
Type
Required
Notes
dry_run
boolean
no · default true
`true` (default) plans and creates nothing. `false` creates the batch and requires `confirm_totals`.
sn_sub
string[]
no
Affiliates to pay, by name. Omit to pay every affiliate with something owed.
sub_affiliate_numbers
integer[]
no
Affiliates to pay, by number (a list). May be combined with `sn_sub`.
only_network_paid
boolean
no · default false
Include only sales the network has already paid you for (`network_paid` true).
currency
string
no
Only this currency (ISO-4217). Default: every currency, each as its own payment.
from
string
no
Only sales dated on or after this day (YYYY-MM-DD).
to
string
no
Only sales dated on or before this day (YYYY-MM-DD).
brand_ids
integer[]
no
Only sales on these brands, by NUMBER.
confirm_totals
object[]
no
REQUIRED when `dry_run` is `false`: the `totals` a dry run returned. Compared per currency; any difference refuses the batch.
payment_method
string
no
Free text stamped on every payment (e.g. `bank_transfer`).
note
string
no
Free text stamped on every payment.
instance_id
string
no
Narrow to one connection; must be one the key holds and be active.
Default (`action: complete`): mark the whole batch, or only `payment_ids`, PAID, with an optional transfer `reference` and `paid_date`. Only this makes a payment count as paid — exporting a file does not. A payment already paid is left alone (repeat-safe); a payment with a payout provider is refused (409): the provider's confirmation marks it paid. `action: cancel` releases the batch's UNPAID payments: their sales are owed again. Paid payments are never cancelled; a batch with a payment at a provider cannot be cancelled here (409). Idempotent under `Idempotency-Key`.
Parameter
In
Type
Required
Notes
batch_reference
path
string
yes
The batch key (`T2BATCH-…`) as returned by POST /subnetwork/payouts/batches. A key you do not own, or a native `NBATCH-…` key, is the same 404.
Request body
Field
Type
Required
Notes
action
string (complete | cancel)
no · default complete
Which operation to apply to the batch, caller-supplied. `complete` marks the batch, or only `payment_ids`, paid; `cancel` releases the batch's unpaid payments so their sales are owed again. Absent means `complete`.
payment_ids
string[]
no
(complete) Only these payments of the batch. Omit for the whole batch. A payment not in this batch is a 422.
reference
string
no
(complete) The transfer reference to stamp on the payments.
paid_date
string
no
(complete) The day the payments were made (YYYY-MM-DD, not in the future). Default: now.
The payments file for the batch's OPEN payments, in your choice of format: `csv` (generic: affiliate, payee details on file, amount, currency, reference), `paypal` (PayPal Payouts bulk CSV: recipient email, amount, currency, reference, note, PAYPAL), or `sepa` (SEPA credit transfer, pain.001.001.03, EUR only — needs your own account as `debtor`). The response IS the file (`Content-Disposition: attachment`), not a JSON envelope. Payments the format could not include are listed in the `X-Payments-Excluded` header (percent-encoded JSON `[{sn_sub, reason, amount, currency}]`) — never silently dropped — and the per-currency totals of the file in `X-Payments-Totals`. Producing the file marks the batch `exported`; it stays owed until you complete it. A POST, not a GET: the debtor account must not travel in a URL and the export records state.
Parameter
In
Type
Required
Notes
batch_reference
path
string
yes
The batch key (`T2BATCH-…`) as returned by POST /subnetwork/payouts/batches. A key you do not own, or a native `NBATCH-…` key, is the same 404.
Request body (required)
Field
Type
Required
Notes
format
string (csv | paypal | sepa)
yes
The file format.
debtor
object
no
(sepa) Your own account the transfers are drawn on. Taken per request, never stored.
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`.
Parameter
In
Type
Required
Notes
sn_sub
query
string
yes
REQUIRED. The affiliate to build the proposal for. Absent or blank returns 422 (`sn_sub is required.`).
sub_affiliate_number
query
integer
no
OPTIONAL alternative to `sn_sub`: the sub-affiliate's number. Names the same sub; sending both for two different subs is a 422, a number naming none of your subs is a 422, and a non-number is a 400.
currency
query
string
yes
REQUIRED. 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_id
query
string
no
REQUIRED 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`).
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
Parameter
In
Type
Required
Notes
job_id
path
string
yes
The 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`.
Parameter
In
Type
Required
Notes
job_id
path
string
yes
The 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`.
Parameter
In
Type
Required
Notes
job_id
path
string
yes
The 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-Key
header
string
no
Makes 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
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
removedGET /v1/subnetwork/contractsendpoint
Removed: the contract object is retired platform-wide (Decision 238) and the route now answers 410 Gone to every caller. It had only ever returned an empty page, because the tables behind it never held a row. Read commission terms from GET /v1/subnetwork/brands.
Affects
GET /v1/subnetwork/contracts
Read instead
GET /v1/subnetwork/brands (default_rate and groups[])
Announced
2026-09-03
Removed on
2026-09-21
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
removedupstream_creative_idfield
Removed with no deprecation window. Creative ids are numbers only: id is now the creative's numeric public id (it was the internal uuid), and the network's own string id is no longer published. Store id as an integer.
Affects
GET /v1/subnetwork/creatives
Read instead
id
Announced
2026-09-29
Removed on
2026-09-29
removedbrand_public_idfield
Removed with no deprecation window (owner ruling, 2026-09-29): brand ids are numbers only. brand_id now carries the brand's number, which is stable across a network disconnect and reconnect and is what every brand_id parameter accepts.
Affects
GET /v1/subnetwork/reporting/transactions
Read instead
brand_id
Announced
2026-09-29
Removed on
2026-09-29
removedbrand_public_idfield
Removed with no deprecation window (owner ruling, 2026-09-29): brand ids are numbers only. brand_id now carries the brand's number, which is stable across a network disconnect and reconnect and is what every brand_id parameter accepts.
Affects
GET /v1/subnetwork/links, GET /v1/subnetwork/sub-affiliates
Read instead
brand_id
Announced
2026-09-29
Removed on
2026-09-29
removedbrand_public_idfield
Removed with no deprecation window (owner ruling, 2026-09-29): brand ids are numbers only. brand_id now carries the brand's number, which is stable across a network disconnect and reconnect and is what every brand_id parameter accepts.
Affects
GET /v1/subnetwork/links/resolve
Read instead
brand_id
Announced
2026-09-29
Removed on
2026-09-29
removedbrand_public_idfield
Removed with no deprecation window (owner ruling, 2026-09-29): brand ids are numbers only. brand_id now carries the brand's number, which is stable across a network disconnect and reconnect and is what every brand_id parameter accepts.
Affects
GET /v1/subnetwork/brands
Read instead
brand_id
Announced
2026-09-29
Removed on
2026-09-29
removedmerchant_keyfield
Removed with no deprecation window (owner ruling, 2026-09-29): it carried a text brand handle, and brand ids are numbers only. Group rows on merchant_domain, or on brand_id within one connection.
Affects
GET /v1/subnetwork/brands
Read instead
merchant_domain
Announced
2026-09-29
Removed on
2026-09-29
removedbrand_public_idfield
Removed with no deprecation window (owner ruling, 2026-09-29): brand ids are numbers only. brand_id now carries the brand's number, which is stable across a network disconnect and reconnect and is what every brand_id parameter accepts.
Affects
GET /v1/subnetwork/links/domains
Read instead
brand_id
Announced
2026-09-29
Removed on
2026-09-29
removedbrand_public_idfield
Removed with no deprecation window (owner ruling, 2026-09-29): brand ids are numbers only. brand_id now carries the brand's number, which is stable across a network disconnect and reconnect and is what every brand_id parameter accepts.
Affects
GET /v1/subnetwork/products, GET /v1/subnetwork/products/search, GET /v1/subnetwork/feeds/{feed_id}/products
Read instead
brand_id
Announced
2026-09-29
Removed on
2026-09-29
removedbrand_public_idfield
Removed with no deprecation window (owner ruling, 2026-09-29): brand ids are numbers only. brand_id now carries the brand's number, which is stable across a network disconnect and reconnect and is what every brand_id parameter accepts.
Affects
GET /v1/subnetwork/feeds
Read instead
brand_id
Announced
2026-09-29
Removed on
2026-09-29
removedbrand_public_idfield
Removed with no deprecation window (owner ruling, 2026-09-29): brand ids are numbers only. brand_id now carries the brand's number, which is stable across a network disconnect and reconnect and is what every brand_id parameter accepts.
Affects
GET /v1/subnetwork/coupons, GET /v1/subnetwork/offers
Read instead
brand_id
Announced
2026-09-29
Removed on
2026-09-29
removedbrand_public_idfield
Removed with no deprecation window (owner ruling, 2026-09-29): brand ids are numbers only. brand_id now carries the brand's number, which is stable across a network disconnect and reconnect and is what every brand_id parameter accepts.
Affects
GET /v1/subnetwork/creatives
Read instead
brand_id
Announced
2026-09-29
Removed on
2026-09-29
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
removednamefield
Renamed to brand_name, with no deprecation window, as part of the same convergence.
Affects
GET /v1/subnetwork/links/domains
Read instead
brand_name
Announced
2026-08-14
Removed on
2026-08-14
removeddomainfield
Renamed to merchant_domain, with no deprecation window, matching the column and the name GET /subnetwork/brands already used.
Affects
GET /v1/subnetwork/links/domains
Read instead
merchant_domain
Announced
2026-08-14
Removed on
2026-08-14
removeddomainfield
Renamed to merchant_domain, with no deprecation window.
Affects
GET /v1/subnetwork/links/resolve
Read instead
merchant_domain
Announced
2026-08-14
Removed on
2026-08-14
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.