{"openapi":"3.0.3","info":{"title":"Affilitera SubNetwork API","version":"1.0.0","description":"Read a subnetwork's brand catalogue, product feeds, promotions, links and reporting.\n\nThe Affilitera SubNetwork API gives a subnetwork partner programmatic access to the inventory, links and reporting behind their own connected networks.\n\n## Concepts this API's field names assume\n\nRead this once. The field names below are short because these ideas are assumed, and several of them are not what an integrator coming from a single affiliate network would expect.\n\n**A subnetwork** is your own affiliate operation, running on top of the network accounts YOU already hold. Affilitera does not sit between you and a network: you connect your existing Awin, CJ, Rakuten (and so on) accounts, and this API reads what those accounts see. Throughout the API you are the OWNER.\n\n**A connection — `instance_id`** is ONE account at ONE network. You may hold several accounts at the same network, and they are separate commercial relationships that reconcile and are paid separately. NOTHING ON THIS API IS EVER SUMMED ACROSS CONNECTIONS. `network` / `network_slug` is only a family label such as `awin` and is NOT unique — two of your accounts at the same network both report it. Always key on `instance_id`.\n\n**A sub — `sn_sub`** is an affiliate: someone who promotes through YOU rather than through the network directly. It is an identity string you choose. It travels out inside the click URL and comes back on the network's own transaction report, so it is your value, round-tripped through the network verbatim. It is case-sensitive and is matched exactly.\n\n**A split** is how one sale's commission is divided. There are exactly THREE parties:\n\n- the SUB whose traffic drove the sale — `computed_sub_split`;\n- YOU, the owner — `computed_owner_split`;\n- Affilitera's own platform leg, which this API does not itemise.\n\nThose three add up to `pool_commission`, WHICH IS THE COMMISSION THE NETWORK ITSELF CREDITED FOR THE SALE. The split is always taken from that pool. It is NEVER taken from the sale value (`order_value`), and it is NEVER recomputed as sale value multiplied by a rate — the network's own figure is the one that will actually be paid, and re-deriving it produces a number that disagrees with reality because networks apply tiers, per-product rates and later corrections. If you remember one thing from this section: **`pool_commission` is the input, the two splits are the output, and `order_value` is neither.**\n\n**Informational only.** Affilitera does not disburse Type-2 commission. Your network pays YOU the whole pool, and you pay your subs. These endpoints tell you what you have earned and what you owe; they do not describe money we will send you.\n\n**Per-currency, and never converted.** THIS PLATFORM PERFORMS NO FX CONVERSION ANYWHERE. Every amount is emitted with its currency in scope — either a sibling field on the same object (`currency`, `split_currency`) or the currency that forms part of a bucket's key. Amounts in different currencies are NEVER added together by us, and one group of sales spanning three currencies is returned as three separate rows by design. If you total anything without grouping by currency first, the total is meaningless. Note also that a sale's currency and its COMMISSION's currency are tracked separately and, rarely and transiently, can differ — see `currency` and `split_currency` on the transaction shape.\n\n**Point-in-time resolution.** Every rate and contract term is resolved AS OF THE SALE'S OWN `transaction_date`, never as of now. A contract you change today does not move the split on a sale from last month. The consequence for you is that THE SPLIT YOU READ IS FINAL, not a current estimate that will drift — and that you must reconcile on `transaction_date`. An integrator who buckets these rows by the date they ingested them, or by the date money moved, will disagree with our figures permanently rather than temporarily.\n\n**Whose identifier is it?** Every row mixes three kinds of id, and confusing them is the most common integration error:\n\n- OURS, durable — `brand_public_id` (`brd_…`), `instance_id`, `slug`, `feed_id`, `job_id`. Safe to store.\n- OURS, NOT durable — `brand_id` and other internal uuids. These change if a network is disconnected and reconnected, so do not persist them; use the durable handle instead.\n- THE NETWORK'S — `transaction_id`, `merchant_id`, `external_id`, `upstream_creative_id`. Opaque strings, never to be parsed as numbers, and the right keys for reconciling against the network's own reporting.\n\n**`merchant_domain` is the only cross-connection key.** Because every other identifier is scoped to one connection, the same real merchant reached through two networks appears as two unrelated brands. `merchant_domain` is what lets you fold them together — but it is EVIDENCE, not an assertion of identity by us, and naive merging on it is unsafe for shared and multi-brand hosts.\n\nThe full normative contract — pagination, ordering guarantees, response and error envelopes, parameter handling, rate limits — is documented at https://affilitera.com/docs/api.\n\n**Scope of a key.** A key resolves to either one network instance or an owner's whole container. That scope is derived *solely from the key* and can never be named in a request, so there is no cross-tenant enumeration surface: naming another owner's brand or feed yields 404 or an empty page.\n\n**Quote your `request_id`.** Every response carries one, in the `X-Request-ID` header and in the body. The underlying cause of any 5xx is logged server-side against it, so quoting it is enough for us to recover the full cause.\n\n**Unsupported methods return a normal 405.** Calling a method an endpoint does not implement (e.g. `DELETE /subnetwork/brands`) returns HTTP 405 carrying this API's ordinary error envelope with `error.code: \"method_not_allowed\"`, an `X-Request-ID` header, an in-body `request_id`, and an RFC-9110 `Allow` header naming the verbs that path DOES implement. Read `Allow` rather than guessing. (Before 2026-08-12 this was the framework's own bare 405 — empty body, no `request_id`, no `Allow`. If you built a client against that, the body and headers are now populated; nothing that was present has changed.) The methods each path implements are also listed per path below.\n\n**Which scope an operation needs: read `x-required-scope`.** Every operation below carries an `x-required-scope` extension naming the ONE scope its handler checks, drawn from the twelve-scope taxonomy in force since 2026-08-11: `catalogue:read`, `promotions:read`, `trackedlinks:read`, `subaffiliates:read`, `instance:read`, `reports:read`, `contracts:read`, `payouts:read`, `trackedlinks:write`, `subaffiliates:write`, `reports:write`, `payouts:write`. Calling an operation with a key that lacks its scope returns `403 insufficient_scope`.\n\nThis is an extension rather than the `security` array on purpose, and the array's emptiness is NOT an omission to be filled in later: `bearerAuth` is an OpenAPI 3 HTTP-bearer scheme, and only `oauth2` and `openIdConnect` schemes may carry scopes. Populating `security: [{ bearerAuth: [ ... ] }]` would be invalid OpenAPI and generators are entitled to reject or silently drop it, so the scope lives where a generator can actually read it.\n\nTwo operations are MODE-KEYED and carry an `x-required-scope-note` saying so: `GET /subnetwork/links` and `GET /subnetwork/sub-affiliates` are one handler that picks its scope from whether `sn_sub` is present, not from the path. The three pre-2026-08-11 scope names — `links:read`, `links:write` and `reporting:read` — are RETIRED and cannot be minted; keys issued before that date still carry them and are expanded server-side.","contact":{"name":"Affilitera partner support","url":"https://affilitera.com/docs/api"}},"servers":[{"url":"https://app.affilitera.com/api/v1","description":"Production"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Instance","description":"What this key can see."},{"name":"Catalogue","description":"Brands, product feeds and products."},{"name":"Promotions","description":"Coupons and offers."},{"name":"Links","description":"Deep links, tracked links and domain resolution."},{"name":"Sub-affiliates","description":"The affiliate roster."},{"name":"Reporting","description":"Transactions, summaries and exports."},{"name":"Payouts","description":"Payout statements and claimable balances."},{"name":"Contracts","description":"Active commission contracts."},{"name":"Jobs","description":"Asynchronous export jobs."}],"paths":{"/subnetwork/instance":{"get":{"tags":["Instance"],"operationId":"getInstance","x-required-scope":"instance:read","summary":"The instance set this key authorises","description":"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.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The key's instance set.","content":{"application/json":{"schema":{"type":"object","properties":{"scope_kind":{"type":"string","enum":["instance","container"],"description":"WHAT YOUR KEY CAN SEE: `instance` means the key is bound to exactly ONE network connection, `container` means it spans every connection the owner holds. It is derived solely from the key and cannot be changed by a request. Read it before you interpret any other endpoint: under a `container` key, list endpoints span all your connections and `instance_id` becomes the field that tells them apart."},"scopes":{"type":"array","items":{"type":"string"},"description":"The exact closed-vocabulary scopes this key carries, post-legacy-expansion — the SAME list every endpoint's own scope gate checks (e.g. `payouts:write`, `catalogue:read`). Never the coarse legacy scope names a key issued under the earlier scheme may still carry internally."},"key_expires_at":{"type":"string","format":"date-time","nullable":true,"description":"ISO-8601, or null for a non-expiring key. The SAME value validateInstanceApiKey checks to deny an expired key (see the Unauthorized response's note on expiry) — read this to predict that 401 before it happens rather than discover it after. Never the key material itself; that is write-once at mint time and is never re-served by any endpoint."},"data":{"description":"The network connections this key can reach — exactly one entry for an `instance`-scoped key, and one entry per connection for a `container`-scoped key.","type":"array","items":{"type":"object","properties":{"instance_id":{"type":"string","format":"uuid","description":"OUR uuid for this network CONNECTION — the key that joins to `instance_id` on brands, transactions, links and payouts, and the only thing that distinguishes two accounts you hold at the same network."},"network":{"type":"string","example":"awin","description":"The network FAMILY slug, e.g. `awin` — a display label, NOT unique across your connections. Never use it as a key. May be an EMPTY STRING on an older connection record; treat empty as unknown."},"name":{"type":"string","nullable":true,"description":"The name YOU gave this connection when you set it up, for telling your own accounts apart in a UI. Yours, not the network's, and not an identifier. NULL MEANS THE CONNECTION'S METADATA ROW WAS NOT READ on this request — not that you left the name blank. A blank name is impossible to observe here: when the row IS read, an unset name falls back to the network name, then the slug, then a generic label, so something is always emitted. So null says nothing about your naming and everything about a failed lookup."},"status":{"type":"string","enum":["active","deactivated"],"description":"Whether this connection is currently ACTIVE. A deactivated connection still returns its historical reporting — backward-looking reads stay available so you can finish reconciling in-flight sales — but write operations against it are refused."},"created_at":{"type":"string","format":"date-time","nullable":true,"description":"When this network connection was established in Affilitera. ISO-8601. Ours, not the date your account at the network was opened. NULL MEANS THE CONNECTION'S METADATA ROW WAS NOT READ on this request. The stored column carries a default and no connection currently lacks one, so a null is a lookup failure rather than a connection of unknown age. Do not substitute today's date."},"capabilities":{"description":"WHAT THIS CONNECTOR CAN ACTUALLY DO — read it before building a feature on top of a connection, because capability varies by network and a missing one is a hard limit, not a setting you can enable.","type":"object","properties":{"sn_sub_links":{"type":"boolean","description":"Whether links minted on this connection can carry an AFFILIATE identity. `false` means the network's link format has nowhere to put one, so every click on this connection attributes to you rather than to a sub — sub-level reporting will be empty for it, and that is expected rather than a fault."},"product_links":{"type":"boolean","description":"Whether this connection can deep-link to an individual PRODUCT URL. `false` means only brand-level links are possible, so a product link request degrades to the merchant's home page rather than the item."},"sn_sub_param":{"type":"string","nullable":true,"description":"The name of the network's own query parameter that carries the sub identity on a click — Awin's `clickref`, CJ's `sid`, and so on. Useful when reconciling a minted URL by hand, or when checking why a sub value did not round-trip. `null` when the connector carries the sub some other way, or not at all."}}}}}},"request_id":{"allOf":[{"$ref":"#/components/schemas/RequestId"}],"description":"The identifier for this request, also in the `X-Request-ID` header. Log it; quoting it lets us recover the server-side cause of any failure."}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/Timeout"}}}},"/subnetwork/instance/summary":{"get":{"tags":["Instance"],"operationId":"getInstanceSummary","x-required-scope":"instance:read","summary":"Headline counts for the key's instance set","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Counts, plus per-currency earnings. `earnings[].total_earned` is a DECIMAL STRING (see MoneyString); the counts are integers.\n\nREAD `degraded` AND THE NULLS. A count of `null` means that sub-query failed and the number is UNKNOWN — it is never served as a confident `0`. `earnings: null` likewise means the per-currency totals could not be determined (the accrual scan hit its row cap), which is NOT the same as `earnings: []`, the truthful answer for an owner who has genuinely earned nothing.","content":{"application/json":{"schema":{"type":"object","properties":{"connections":{"type":"object","properties":{"total":{"type":"integer","description":"How many network connections this key can reach, active or not. `null` when the underlying read failed — check `degraded` before using any counter here."},"active":{"type":"integer","description":"How many of those connections are currently active and therefore usable for minting links. `null` when the read failed."}},"description":"Counts of your network connections."},"brands":{"type":"object","properties":{"total":{"type":"integer","nullable":true,"description":"How many brands this key can reach across all its connections. NOTE this counts brand ROWS, so one real merchant reachable through two of your networks counts twice — it is not a count of distinct merchants. `null` when the read failed."}},"description":"Counts of merchants in your catalogue."},"sub_affiliates":{"type":"object","properties":{"total":{"type":"integer","nullable":true,"description":"How many affiliates are on your roster, including those we created automatically from observed network traffic as well as those you registered. `null` when the read failed."}},"description":"Counts of your affiliate roster."},"earnings":{"type":"array","nullable":true,"description":"One row per currency. NEVER cross-summed — this platform performs no FX conversion. NULL MEANS NOT KNOWABLE FROM THIS READ, AND NEVER ZERO. The per-currency fold scans accrual rows under a hard row cap; at the cap the total is incomplete, so this reports null and sets `degraded` true rather than presenting a partial sum as a complete one — the single most damaging thing a money field can do quietly. A failed read reports null the same way, also with `degraded`. `[]` — an empty array, NOT null — is the honest 'nothing accrued'. So: `[]` means zero; null means ask again or ask a narrower question; and null must never be rendered as 0.","items":{"type":"object","properties":{"currency":{"type":"string","description":"ISO-4217. The currency of `total_earned`."},"total_earned":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"Total commission accrued to YOU across this key's scope, in the currency named on the same entry. One entry per currency, never cross-summed and never converted. It includes pending as well as approved sales, so it is not an amount you can rely on receiving."}}}},"last_sync_at":{"type":"string","format":"date-time","nullable":true,"description":"When we most recently completed a sync against ANY of this key's connections — the freshness of everything else in this response. ISO-8601. `null` when nothing has ever synced, or when the read failed."},"degraded":{"type":"boolean","description":"Present and true when at least one sub-query failed or was capped."},"request_id":{"allOf":[{"$ref":"#/components/schemas/RequestId"}],"description":"The identifier for this request, also in the `X-Request-ID` header."}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/subnetwork/brands":{"get":{"tags":["Catalogue"],"operationId":"listBrands","x-required-scope":"catalogue:read","summary":"List brands in this key's inventory","description":"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.\n\nAll filters are AND-ed and can only narrow *within* the key's scope.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"name":"q","in":"query","required":false,"description":"Case-insensitive substring match on the brand name. An empty value is treated as absent.","schema":{"type":"string"}},{"name":"country","in":"query","required":false,"description":"ISO-3166 alpha-2 merchant country, case-insensitive. An empty value is treated as absent.","schema":{"type":"string","minLength":2,"maxLength":2,"example":"GB"}},{"name":"has_products","in":"query","required":false,"description":"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.","schema":{"type":"string","enum":["true","1","yes","on"]}},{"name":"category","in":"query","required":false,"description":"Substring match on the retail category path or leaf name. An over-broad value returns 400 `filter_too_broad`.","schema":{"type":"string"}},{"name":"min_traffic","in":"query","required":false,"description":"Only brands whose domain has at least this many estimated monthly visits. An over-broad value returns 400 `filter_too_broad`.","schema":{"type":"integer","minimum":1}},{"name":"network","in":"query","required":false,"description":"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.","schema":{"type":"string","example":"awin"}},{"name":"relationship_status","in":"query","required":false,"description":"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.","schema":{"type":"string","enum":["approved","pending","declined","removed","ended","none","unknown"],"example":"approved,pending"}},{"$ref":"#/components/parameters/ModifiedSince"}],"responses":{"200":{"description":"A page of brands.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PageMeta"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Brand"},"description":"The page of brands. Read `has_more` to decide whether to fetch another page, never `total` — which is a planner estimate on this endpoint, not a count."},"modified_as_of":{"type":"string","format":"date-time","description":"Stamped before the query ran. Use as the next request's modified_since."}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/Timeout"}}}},"/subnetwork/products":{"get":{"tags":["Catalogue"],"operationId":"listProducts","x-required-scope":"catalogue:read","summary":"List products in this key's inventory","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"name":"brand_id","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one brand. Accepts EITHER the brand's minted `brand_public_id` (`brd_…`, the durable identity — preferred) OR its raw `brand_id` uuid. Both forms select the same rows. A `brd_…` value naming no brand is a `validation_error` (422), never an empty page; a value that is neither form is also a 422."},{"name":"feed_id","in":"query","required":false,"schema":{"type":"string","format":"uuid"},"description":"Restrict to one feed."},{"$ref":"#/components/parameters/ModifiedSince"},{"$ref":"#/components/parameters/IncludeDelisted"}],"responses":{"200":{"$ref":"#/components/responses/ProductPage"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/Timeout"}}}},"/subnetwork/products/search":{"get":{"tags":["Catalogue"],"operationId":"searchProducts","x-required-scope":"catalogue:read","summary":"Search products by name","description":"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.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"name":"q","in":"query","required":true,"schema":{"type":"string"},"description":"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."},{"name":"brand_id","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one brand. Accepts EITHER the brand's minted `brand_public_id` (`brd_…`, the durable identity — preferred) OR its raw `brand_id` uuid. Both forms select the same rows. A `brd_…` value naming no brand is a `validation_error` (422), never an empty page; a value that is neither form is also a 422."},{"name":"feed_id","in":"query","required":false,"schema":{"type":"string","format":"uuid"},"description":"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."},{"$ref":"#/components/parameters/ModifiedSince"},{"$ref":"#/components/parameters/IncludeDelisted"},{"name":"sort","in":"query","required":false,"schema":{"type":"string","enum":["name","relevance"]},"description":"`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."},{"name":"min_price","in":"query","required":false,"schema":{"type":"string"},"description":"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."},{"name":"max_price","in":"query","required":false,"schema":{"type":"string"},"description":"Maximum price, inclusive. Same currency requirement as `min_price`."},{"name":"currency","in":"query","required":false,"schema":{"type":"string"},"description":"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."},{"name":"category","in":"query","required":false,"schema":{"type":"string"},"description":"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."},{"name":"include_brand_match","in":"query","required":false,"schema":{"type":"string","enum":["true","false"]},"description":"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."}],"responses":{"200":{"$ref":"#/components/responses/ProductPage"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/Timeout"}}}},"/subnetwork/feeds":{"get":{"tags":["Catalogue"],"operationId":"listFeeds","x-required-scope":"catalogue:read","summary":"List product feeds","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/ModifiedSince"}],"responses":{"200":{"description":"A page of feeds.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PageMeta"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Feed"},"description":"The page of product feeds. Read `has_more` to decide whether to fetch another page."},"modified_as_of":{"type":"string","format":"date-time","description":"A timestamp STAMPED BEFORE THIS QUERY RAN. Feed it back as `modified_since` on your next incremental sync: using this rather than your own clock is what makes the sync gapless, because it cannot fall inside the window this response was built over. ISO-8601."}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/Timeout"}}}},"/subnetwork/feeds/{feed_id}/products":{"get":{"tags":["Catalogue"],"operationId":"listFeedProducts","x-required-scope":"catalogue:read","summary":"List the products in one feed","security":[{"bearerAuth":[]}],"parameters":[{"name":"feed_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"A feed inside this key's scope. A feed belonging to anyone else returns 404."},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/ModifiedSince"},{"$ref":"#/components/parameters/IncludeDelisted"}],"responses":{"200":{"$ref":"#/components/responses/ProductPage"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/Timeout"}}}},"/subnetwork/coupons":{"get":{"tags":["Promotions"],"operationId":"listCoupons","x-required-scope":"promotions:read","summary":"List coupon promotions","description":"Coupon promotions currently IN FORCE for the merchants in your scope.\n\nCHANGED 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.\n\nWHAT 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.\n\nThe filter compares absolute instants in UTC and is inclusive at both bounds.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"name":"brand_id","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one brand. Accepts EITHER the brand's minted `brand_public_id` (`brd_…`, the durable identity — preferred) OR its raw `brand_id` uuid. Both forms select the same rows. A `brd_…` value naming no brand is a `validation_error` (422), never an empty page; a value that is neither form is also a 422."},{"name":"offer_type","in":"query","required":false,"schema":{"type":"string"},"description":"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."}],"responses":{"200":{"$ref":"#/components/responses/PromotionPage"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/subnetwork/creatives":{"get":{"tags":["Promotions"],"operationId":"listCreatives","x-required-scope":"promotions:read","summary":"List network creatives","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"name":"brand_id","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one brand. Accepts EITHER the brand's minted `brand_public_id` (`brd_…`, the durable identity — preferred) OR its raw `brand_id` uuid. Both forms select the same rows. A `brd_…` value naming no brand is a `validation_error` (422), never an empty page; a value that is neither form is also a 422."},{"name":"kind","in":"query","required":false,"schema":{"type":"string","enum":["image_banner","text_link"]},"description":"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."}],"responses":{"200":{"$ref":"#/components/responses/CreativePage"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/BadRequest"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/subnetwork/offers":{"get":{"tags":["Promotions"],"operationId":"listOffers","x-required-scope":"promotions:read","summary":"List non-coupon offers","description":"Code-less offers currently IN FORCE for the merchants in your scope.\n\nCHANGED 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.\n\nWHAT 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.\n\nThe filter compares absolute instants in UTC and is inclusive at both bounds.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"name":"brand_id","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one brand. Accepts EITHER the brand's minted `brand_public_id` (`brd_…`, the durable identity — preferred) OR its raw `brand_id` uuid. Both forms select the same rows. A `brd_…` value naming no brand is a `validation_error` (422), never an empty page; a value that is neither form is also a 422."},{"name":"offer_type","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one offer type (connector-supplied, e.g. `sale`/`free_shipping` on CJ)."}],"responses":{"200":{"$ref":"#/components/responses/PromotionPage"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/subnetwork/contracts":{"get":{"tags":["Contracts"],"operationId":"listContracts","x-required-scope":"contracts:read","summary":"List active commission contracts","deprecated":true,"security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"}],"responses":{"200":{"description":"A page of contracts. Carries the full PageMeta envelope, the same as every other list endpoint — `has_more`, `limit`, `limit_clamped`, `limit_max` and an in-body `request_id`. `total` is present but is an estimate; page on `has_more`.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PageMeta"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Contract"},"description":"The page of commission contracts. Only contracts stored as active are returned — but expiry is evaluated when a contract is USED, not by this filter, so check `effective_from`/`effective_to` yourself to know what is in force today."}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"A dependency could not be reached. `error.code` is `service_unavailable` when the server has no database configured, or `timeout` when a read exceeded its statement-timeout budget. Both are TRANSIENT and both carry `Retry-After` — retry rather than reading either as a permanent fault. ADDED TO THE SPEC 2026-08-29, correcting an omission that was already wrong before this change: this endpoint's very first statement is the not-configured check, so it has ALWAYS been able to return `503 service_unavailable`, and docs/public_subnetwork_api.md has listed `GET /contracts` under that status since 2026-08-16 while the machine-readable spec declared no 503 at all. It became able to return `503 timeout` in the same 2026-08-29 change that stopped its payout-group and rule reads from degrading a FAILED read into `payout_groups: []` / `rules: []` — see those fields on the Contract schema. The 200 envelope is unchanged: a contract that genuinely has no payout groups, and a group that genuinely has no rules, still answer 200 with their empty arrays.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying."}}}}}},"/subnetwork/links":{"get":{"tags":["Links"],"operationId":"listSubTrackedLinks","x-required-scope":"trackedlinks:read","x-required-scope-note":"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`.","summary":"List tracked links minted for an affiliate","security":[{"bearerAuth":[]}],"parameters":[{"name":"sn_sub","in":"query","required":false,"schema":{"type":"string"},"description":"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`."},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["active","observed","all"]},"description":"Only applies to the roster-listing form (ignored when `sn_sub` is present). Anything else returns 422 `validation_error`."},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"}],"responses":{"200":{"description":"A page of tracked links when `sn_sub` is supplied, or a page of the affiliate ROSTER when it is omitted — `data[]` carries a different shape in each case, and `sn_sub` is the only thing that decides which.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PageMeta"},{"type":"object","properties":{"data":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/SubTrackedLink"},{"$ref":"#/components/schemas/SubAffiliate"}]},"description":"The page of results. WHICH SHAPE THIS HOLDS DEPENDS ON YOUR REQUEST: with `sn_sub` set it is tracked links for that sub; without it, it is your affiliate roster. The two are different objects and need different scopes — branch on what you asked for."}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"tags":["Links"],"operationId":"createLink","x-required-scope":"trackedlinks:write","summary":"Mint a deep link for a brand","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["brand_id"],"properties":{"brand_id":{"type":"string","format":"uuid","description":"WHICH MERCHANT to mint the link for. Accepts EITHER the internal uuid (`brand_id`) or the durable handle (`brand_public_id`, `brd_…`) that `GET /subnetwork/brands` emits — prefer the handle, since the uuid changes if the network is disconnected and reconnected. A brand outside your key's scope is a 404, indistinguishable from one that does not exist."},"product_id":{"type":"string","format":"uuid","description":"OPTIONAL. Links to a specific product's deeplink instead of the brand's default destination. Must belong to brand_id."},"subid":{"type":"string","description":"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."},"label":{"type":"string","description":"OPTIONAL, cosmetic only."}}}}}},"responses":{"201":{"description":"The minted link. destination_url is NOT an input — it is derived server-side from brand_id/product_id.","content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","description":"The re-baked, sub-attributed URL. A bake that CAN'T carry the link never reaches a 201 — it returns 409 `link_not_supported` instead — so `url` is always present here."},"kind":{"type":"string","enum":["main","type2"],"description":"`type2` = native + sub1 (or the owner's own link when sub1 is absent); `main` is the producer's other non-blocked outcome. `blocked` is a real producer value but never reaches a 201 — see `url`."},"sn_sub":{"type":"string","description":"The subid baked into `url` — either the per-request `subid` you sent, or your stored per-{account, network} value."},"type":{"type":"string","enum":["brand","product"],"description":"What kind of target this link resolves to — `brand` for a merchant home page, `product` for a specific item. It describes the link's TARGET, and is unrelated to `link_kind`, which describes whether a sub identity could be carried."},"network":{"type":"string","nullable":true,"description":"The brand's network family slug (e.g. `awin`). NULL MEANS NOT YET RESOLVED: the connection could not be mapped to a network type while building this response. It is not vendor-supplied, so a null says nothing about the network, and no connection currently lacks a mapping. The minted link is unaffected and `instance_id` remains the key."},"instance_id":{"type":"string","description":"The brand's OWNING instance (which connection to that network)."}},"required":["url","kind","sn_sub","type","network","instance_id"]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/subnetwork/links/tracked":{"post":{"tags":["Links"],"operationId":"createTrackedLink","x-required-scope":"trackedlinks:write","summary":"Mint a click-tracked link","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["brand_id"],"properties":{"brand_id":{"type":"string","format":"uuid","description":"WHICH MERCHANT to mint the tracked link for. Accepts EITHER the internal uuid (`brand_id`) or the durable handle (`brand_public_id`, `brd_…`) from `GET /subnetwork/brands` — prefer the handle, which survives a network disconnect/reconnect. A brand outside your key's scope is a 404."},"product_id":{"type":"string","format":"uuid","description":"OPTIONAL. See POST /subnetwork/links."},"subid":{"type":"string","description":"OPTIONAL. See POST /subnetwork/links."},"label":{"type":"string","description":"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."}}}}}},"responses":{"201":{"description":"The minted tracked link.","content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","description":"An `aftrk.co/t/{slug}` first-party tracked URL — NOT the raw re-baked deeplink (that is what it redirects to)."},"slug":{"type":"string","description":"The tracked link's slug, e.g. for `GET /links/resolve?slug=`."},"sn_sub":{"type":"string","description":"The affiliate this tracked link is attributed to, echoed back exactly as stored. Every click on this link credits this sub."},"type":{"type":"string","enum":["brand","product"],"description":"What kind of target this link resolves to — `brand` for a merchant home page, `product` for a specific item. Unrelated to `link_kind`."},"network":{"type":"string","nullable":true,"description":"The network FAMILY slug this link runs through, e.g. `awin` — a display label, not unique across your connections. Use `instance_id` as the key. NULL MEANS NOT YET RESOLVED: the connection could not be mapped to a network type while building this response. The minted link is unaffected — use `instance_id`, which is the key and is always present."},"instance_id":{"type":"string","description":"OUR uuid for the network CONNECTION this link was minted on — which of your accounts will be credited for its clicks."}},"required":["url","slug","sn_sub","type","network","instance_id"]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/subnetwork/links/domains":{"get":{"tags":["Links"],"operationId":"listLinkableDomains","x-required-scope":"trackedlinks:read","summary":"List the merchant domains this key can link to","description":"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\".","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"name":"network","in":"query","required":false,"schema":{"type":"string"},"description":"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."}],"responses":{"200":{"description":"A page of domains.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PageMeta"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/BrandDomain"},"description":"The page of merchant domains you can mint links for. THE SAME DOMAIN MAY APPEAR MORE THAN ONCE, deliberately: a merchant reachable through two of your networks is two rows, because they are two separate commercial relationships. Separate them on `instance_id`."},"excluded_missing_domain":{"type":"integer","nullable":true,"description":"How many brands in the same scope as this request (after any `network` filter) were left out because no merchant domain is recorded for them. Scope-wide and stable across pages, unlike `total`. APPROXIMATE — a planner estimate, for the same cost reason as `total`; never use it as a loop bound. `0` means every in-scope brand has a domain. A non-zero value alongside an empty `data` array means the brands exist but their domains were never captured — raise it with support rather than assuming you have no inventory. `null` means the diagnostic count itself could not be read; the page of domains is still valid."}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/subnetwork/subid-budget":{"get":{"tags":["Links"],"operationId":"getSubidBudget","x-required-scope":"trackedlinks:read","summary":"The per-connector sub1 character budget","description":"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.\n\n`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.\n\nAn 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.\n\nWhen packing several registered subids into the one slot, segments are joined with `pack_delimiter` and the JOINED length is measured against `max_length`.\n\nThis response is static reference data — identical for every caller, carrying no account, instance or transaction identity.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The per-connector budget table.","content":{"application/json":{"schema":{"type":"object","properties":{"connector_count":{"type":"integer","description":"How many connectors are registered, counted from the live roster at request time rather than pinned, so it cannot go stale when a connector is added or removed."},"documented_count":{"type":"integer","description":"How many of those carry a cited vendor ceiling rather than the conservative floor. The remainder are undocumented, not unlimited."},"undocumented_floor":{"type":"integer","description":"The ceiling in characters applied to a connector that publishes no subid length. A floor we chose, never a figure a network published."},"pack_delimiter":{"type":"string","description":"The single character joining packed subid segments when several registered subids share the one slot. RFC 3986 unreserved, so it survives URL encoding unchanged on every connector that carries it."},"data":{"type":"array","description":"One entry per registered connector, sorted by network_slug for a stable order. Bounded by the connector roster rather than by tenant data, so it is returned unpaged and cannot grow with usage.","items":{"type":"object","properties":{"network_slug":{"type":"string","description":"The connector's slug, assigned by us and stable — the same value the `network` field carries on transaction and link rows. Join on it to match a budget to the network a link is minted against."},"subid_param":{"type":"string","nullable":true,"description":"The native outbound query parameter the subid rides on for this connector, as the network names it (e.g. `clickref`, `u1`, `s1`). Null when the connector carries no native subid at all."},"supported":{"type":"boolean","description":"False when the connector cannot carry a native subid at all, in which case no link minted on it carries one and max_length is not meaningful for it."},"max_length":{"type":"integer","description":"The ceiling in characters Affilitera enforces at mint time for this connector. Never null: an undocumented connector still gets the floor."},"max_length_documented":{"type":"boolean","description":"True when max_length is the network's OWN published figure, cited in source. False when it is the platform floor standing in for a ceiling we could not find."},"charset":{"type":"string","nullable":true,"description":"The network's documented permitted character set for this field, when it publishes one. Null means no charset is documented by that network, not that any character is safe."},"truncates":{"type":"boolean","description":"True when the network is documented to silently CUT an over-long value rather than reject it — which is why the budget is enforced on our side instead."},"source":{"type":"string","nullable":true,"description":"Where a documented figure came from, as a citable reference. Null when the connector is undocumented and max_length is the floor."},"provenance":{"type":"string","nullable":true,"enum":["connector-file","design-doc",null],"description":"Which tier of evidence source is: connector-file is cited to this repo and travels with the connector; design-doc is a figure the connector file does not itself corroborate. Null when undocumented."}}}},"_note":{"type":"string","description":"Human-readable restatement of the rules above, shipped in the response so a caller reading the payload alone still learns that max_length_documented=false means a floor we chose rather than a ceiling the network published, and that an over-budget subid is refused rather than truncated."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/subnetwork/links/resolve":{"get":{"tags":["Links"],"operationId":"resolveLink","x-required-scope":"trackedlinks:read","summary":"Resolve a tracked link to its attribution","description":"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.\n\nOnly 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.\n\nSEND 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.","security":[{"bearerAuth":[]}],"parameters":[{"name":"slug","in":"query","required":false,"schema":{"type":"string"},"description":"The tracked link's slug. Takes PRECEDENCE over `url` when both are sent."},{"name":"url","in":"query","required":false,"schema":{"type":"string"},"description":"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`."}],"responses":{"200":{"description":"The resolved tracked link.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResolvedLink"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/subnetwork/links/by-domain":{"post":{"tags":["Links"],"operationId":"createLinkByDomain","x-required-scope":"trackedlinks:write","summary":"Mint a link from a merchant URL","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["domain","affiliate_id","destination_url"],"properties":{"domain":{"type":"string","description":"The merchant's bare domain (not a full URL)."},"affiliate_id":{"type":"string","description":"The affiliate id to attribute the link to."},"destination_url":{"type":"string","format":"uri","description":"The full merchant page URL to deep-link to."},"network_slug":{"type":"string","description":"OPTIONAL. Disambiguates which merchant to use when the domain matches more than one (see the 409 domain_ambiguous response's candidates)."},"label":{"type":"string","description":"OPTIONAL, cosmetic only."}}}}}},"responses":{"201":{"description":"The minted link.","content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","description":"An `aftrk.co/t/{slug}` first-party tracked URL."},"slug":{"type":"string","description":"OUR short click identifier for the minted link — the durable handle. STORE THIS RATHER THAN `url`: the URL is rebuilt from the slug and its base can change between deploys, while the slug does not."},"sn_sub":{"type":"string","description":"Echoes `affiliate_id` from the request — this endpoint bakes the caller-supplied value directly, unlike `POST /links`."},"brand_id":{"type":"string","format":"uuid","description":"OUR internal uuid for the merchant the domain resolved to. Not stable across a network disconnect/reconnect — store `brand_public_id` if you need to remember which merchant this was."},"brand_public_id":{"type":"string","nullable":true,"description":"Durable brand identity — `brand_id` is an internal uuid a disconnect/reconnect re-mints. NULL MEANS NOT YET RESOLVED, never 'no brand': the domain resolved — that is what a 201 says — and `brand_id` on the same body identifies it. Every stored brand currently carries a handle, so a null here is a lookup that did not resolve on this request, not a brand without a handle."},"type":{"type":"string","enum":["domain"],"description":"Always `domain` on this endpoint, recording that the link was resolved from a merchant domain rather than from a brand id. Present so a mixed store of links from several endpoints stays self-describing."},"network":{"type":"string","nullable":true,"description":"The network FAMILY slug the domain resolved to, e.g. `awin` — this is the value to send back as `network_slug` if you need to pin the choice on a later call. Not unique across your connections. NULL MEANS NOT YET RESOLVED: the connection behind the resolved brand could not be mapped to a network type on this request. If you intended to pin the choice by echoing this value back as `network_slug` on a later call, you cannot when it is null — pin on `instance_id` instead, which is always present."},"instance_id":{"type":"string","description":"OUR uuid for the network CONNECTION this link was minted on — the only field that distinguishes two of your accounts at the same network."}},"required":["url","slug","sn_sub","brand_id","brand_public_id","type","network","instance_id"]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/subnetwork/links/by-domain/bulk":{"post":{"tags":["Links"],"operationId":"createLinksByDomainBulk","x-required-scope":"trackedlinks:write","summary":"Mint links from many merchant URLs in one call","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["links"],"properties":{"links":{"type":"array","description":"1 to a per-request maximum of items, each shaped like POST /subnetwork/links/by-domain's body.","items":{"type":"object","required":["domain","affiliate_id","destination_url"],"properties":{"domain":{"type":"string","description":"The merchant's BARE DOMAIN — a host such as `example.com`, with no scheme, no path and no `www.` requirement. Not a full URL: the page you want to land on goes in `destination_url`. Use the values from `GET /subnetwork/links/domains`."},"affiliate_id":{"type":"string","description":"THE AFFILIATE IDENTITY to attribute this link to — the same value every other endpoint calls `sn_sub`. DESPITE THE NAME THIS IS NOT AN AFFILIATE ACCOUNT ID AND NOT AN ID WE ISSUED: it is your own sub identity string, matched verbatim and case-sensitively. The name is retained because renaming a field on a published API is a breaking change."},"destination_url":{"type":"string","format":"uri","description":"The FULL merchant page URL you want the click to land on, including scheme. It must be on the merchant reached by `domain`; a URL pointing somewhere else is not deep-linked but rejected for that item."},"network_slug":{"type":"string","description":"OPTIONAL. Pins WHICH network to mint through when the domain matches more than one of your connections. Omit it and an ambiguous domain fails that ITEM ONLY with `domain_ambiguous` plus the candidates to choose from; send one of their `network` values here to resolve it."},"label":{"type":"string","description":"OPTIONAL, cosmetic only. A display name stored against the minted link; never part of its identity or attribution."}}}}}}}}},"responses":{"200":{"description":"Always 200 (even if every item errored) — per-item outcomes are in data[]; check each data[i].status individually. Each item is either {status:\"ok\", data} or {status:\"error\", error:{code, message, ...}} using the same closed error-code set as the single endpoint, including domain_ambiguous's candidates.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"description":"One entry PER REQUESTED ITEM, in request order. Note this endpoint returns 200 even when every item failed — check each entry's own `status`; the HTTP status tells you only that the batch was processed.","type":"array","items":{"oneOf":[{"type":"object","required":["index","status","data"],"properties":{"index":{"type":"integer","description":"Position in the request's `links[]` array."},"status":{"type":"string","enum":["ok"],"description":"Always `ok` in this branch — this item succeeded and its minted link is in `data`. BRANCH ON THIS FIELD to decide which of the two entry shapes you are holding; the HTTP status is 200 either way."},"data":{"type":"object","description":"Same shape as the single-item POST /links/by-domain 201.","properties":{"url":{"type":"string","description":"The tracked URL to publish. Rebuilt from `slug`, so store the slug rather than this string."},"slug":{"type":"string","description":"OUR short click identifier — the durable handle for this link, and the value `GET /subnetwork/links/resolve` accepts."},"sn_sub":{"type":"string","description":"The affiliate this link is attributed to, echoed back from the item's `affiliate_id`."},"brand_id":{"type":"string","format":"uuid","description":"OUR internal uuid for the merchant the domain resolved to. Not stable across a network disconnect/reconnect — store `brand_public_id` instead."},"brand_public_id":{"type":"string","nullable":true,"description":"OUR minted durable handle for that merchant (`brd_…`). Store THIS if you need to remember which merchant the domain resolved to. `null` when the brand has no minted handle."},"type":{"type":"string","enum":["domain"],"description":"Always `domain`, recording that this link was resolved from a merchant domain rather than from a brand id."},"network":{"type":"string","nullable":true,"description":"The network FAMILY slug this link was minted through — not unique across your connections; use `instance_id` as the key. NULL MEANS NOT YET RESOLVED: the connection could not be mapped to a network type while building this item. The item still SUCCEEDED — this is the success arm of the union — and the minted link is unaffected; use `instance_id` as the key."},"instance_id":{"type":"string","description":"OUR uuid for the network CONNECTION this link was minted on."}},"required":["url","slug","sn_sub","brand_id","brand_public_id","type","network","instance_id"]}}},{"type":"object","required":["index","status","error"],"properties":{"index":{"type":"integer","description":"Position of this item in the request's `links[]` array, zero-based. Entries are returned in request order, but match on this rather than on array position so a future change cannot silently misalign your items with their results."},"status":{"type":"string","enum":["error"],"description":"Always `error` in this branch — this item failed and the reason is in `error`. BRANCH ON THIS FIELD to decide which of the two entry shapes you are holding; the HTTP status is 200 even when every item failed."},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Machine-readable and stable — BRANCH ON THIS, never on `message`. Drawn from the same closed set as the top-level `error.code` elsewhere in this API."},"message":{"type":"string","description":"For humans. May change without notice; never parse it."},"kind":{"type":"string","enum":["blocked"],"description":"Only on `link_not_supported`."},"candidates":{"type":"array","description":"Only on `domain_ambiguous`.","items":{"type":"object","properties":{"brand_id":{"type":"string","description":"OUR internal uuid for this candidate merchant. Not stable across a disconnect/reconnect."},"brand_public_id":{"type":"string","nullable":true,"description":"OUR minted durable handle for this candidate merchant (`brd_…`). `null` when it has none."},"brand_name":{"type":"string","nullable":true,"description":"The merchant's programme name, for showing a human which candidate is which. Two candidates can share a name, so do not disambiguate on it. NULL MEANS NOT YET RESOLVED — the candidate's brand row was not joined while building this error — and NOT that the candidate is nameless. Resubmit on `network`/`instance_id`, which is what disambiguates; a missing label is never a reason to drop a candidate."},"network":{"type":"string","nullable":true,"description":"The network FAMILY slug for this candidate — THE VALUE TO RESUBMIT as the item's `network_slug`. If two candidates share it, they are two of your accounts at the same network and the slug alone will not separate them. NULL MEANS NOT YET RESOLVED: this candidate's connection could not be mapped to a network type. THIS IS THE FIELD YOU WERE TOLD TO RESUBMIT, so a null leaves you unable to follow the documented recovery — fall back to pinning the item on `instance_id`, which is always present and is strictly more precise anyway."},"instance_id":{"type":"string","description":"OUR uuid for this candidate's network CONNECTION — the only field guaranteed to tell two candidates apart."}}}}},"description":"Why this item failed, present only when its `status` is `error`. Uses the same closed error-code set as the rest of the API, so one handler covers both."}}}]}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/subnetwork/sub-affiliates":{"get":{"tags":["Sub-affiliates"],"operationId":"listSubAffiliates","x-required-scope":"subaffiliates:read","x-required-scope-note":"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.","summary":"List the affiliate roster","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["active","observed","all"]},"description":"Anything else returns 422 `validation_error`."}],"responses":{"200":{"description":"A page of the affiliate roster. NOTE: this path shares its handler with `GET /subnetwork/links`, so sending `?sn_sub=<id>` here returns that sub's TRACKED LINKS instead — a different `data[]` shape, and a different required scope.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PageMeta"},{"type":"object","properties":{"data":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/SubAffiliate"},{"$ref":"#/components/schemas/SubTrackedLink"}]},"description":"The page of results. WHICH SHAPE THIS HOLDS DEPENDS ON YOUR REQUEST: with `sn_sub` set it is that sub's tracked links; without it, your affiliate roster. Note the roster is filtered to `active` subs by default, so subs we created from observed traffic are HIDDEN unless you ask for them."}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"tags":["Sub-affiliates"],"operationId":"registerSubAffiliate","x-required-scope":"subaffiliates:write","summary":"Register an affiliate","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["sn_sub"],"properties":{"sn_sub":{"type":"string","description":"Letters, numbers, underscores and hyphens only, max 64 chars."},"label":{"type":"string","description":"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":{"type":"string","enum":["main"],"default":"main","description":"\"sub\" (sub-of-a-sub) is rejected 422 — not_supported; the commission engine is a fixed single-level sub tier."}}}}}},"responses":{"200":{"description":"Re-registering an existing active sub, or promoting an observed one — returned unchanged/promoted, created:false.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisteredSubAffiliate"}}}},"201":{"description":"First registration of this sn_sub for this owner — created:true.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisteredSubAffiliate"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/subnetwork/reporting/transactions":{"get":{"tags":["Reporting"],"operationId":"listTransactions","x-required-scope":"reports:read","summary":"List commission transactions","description":"Reporting endpoints use the TIGHTER rate-limit tier and a lower maximum page size (100).","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/ReportingLimit"},{"$ref":"#/components/parameters/Cursor"},{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"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."},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"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."},{"name":"brand_id","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one brand. Accepts EITHER the brand's minted `brand_public_id` (`brd_…`, the durable identity — preferred) OR its raw `brand_id` uuid. Both forms select the same rows. A `brd_…` value naming no brand is a `validation_error` (422), never an empty page; a value that is neither form is also a 422."},{"name":"sn_sub","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one affiliate's transactions — the exact stored `sn_sub` value."},{"name":"status","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one order status (network-reported, not the closed `status_bucket` set)."},{"name":"currency","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one ISO-4217 currency. Never converts or cross-sums — this only filters which single-currency rows are returned."}],"responses":{"200":{"description":"A page of transactions. Every monetary field is a DECIMAL STRING (see MoneyString). Carries the full PageMeta envelope, the same as every other list endpoint — `has_more`, `limit`, `limit_clamped`, `limit_max` and an in-body `request_id`. `total` is always null here: this is a keyset scan, not a counted query, so page on `has_more` and never on `total`.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PageMeta"},{"type":"object","properties":{"data_as_of":{"type":"string","format":"date-time","description":"HOW FRESH THIS REPORTING IS — the most recent moment at which commission was resolved anywhere in this key's scope. ISO-8601. Sales that reached us after this instant are not yet reflected in the split figures below, so quote it whenever you publish a total. `null` when nothing in scope has ever been resolved."},"data":{"type":"array","items":{"$ref":"#/components/schemas/Transaction"},"description":"The page of transactions, newest sale first. Read `has_more` to decide whether to fetch another page; `total` is deliberately not computed on this endpoint and is always null."},"_note":{"type":"string","description":"A fixed human-readable reminder that these figures are INFORMATIONAL: Affilitera does not disburse Type-2 commission, your own networks pay you, and amounts are per-currency and never cross-summed. Prose for a human reader — do not parse it or branch on it."}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/Timeout"}}}},"/subnetwork/reporting/summary":{"get":{"tags":["Reporting"],"operationId":"getReportingSummary","x-required-scope":"reports:read","summary":"Aggregated commission summary","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/SummaryLimit"},{"$ref":"#/components/parameters/Cursor"},{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"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."},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"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."},{"name":"brand_id","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one brand. Accepts EITHER the brand's minted `brand_public_id` (`brd_…`, the durable identity — preferred) OR its raw `brand_id` uuid. Both forms select the same rows. A `brd_…` value naming no brand is a `validation_error` (422), never an empty page; a value that is neither form is also a 422."},{"name":"sn_sub","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one affiliate's orders before aggregating."},{"name":"status","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one order status (network-reported) before aggregating."},{"name":"currency","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one ISO-4217 currency before aggregating. Never converts or cross-sums — every bucket is already single-currency."},{"name":"group_by","in":"query","required":false,"schema":{"type":"string"},"description":"Comma-separated subset of `brand`, `sn_sub`, `date` (e.g. `brand,date`). Unrecognised tokens are dropped, not rejected. Omit for network-only buckets. Echoed back on the response as the `group_by` array."},{"name":"granularity","in":"query","required":false,"schema":{"type":"string","enum":["day","month"],"default":"day"},"description":"Bucket width when `group_by` includes `date`. Ignored otherwise. Echoed back on the response ONLY when `group_by` includes `date`."}],"responses":{"200":{"description":"Summary totals, one row per (`group`, `currency`) — NEVER cross-summed across currencies. Every total is a DECIMAL STRING (see MoneyString); `orders` is a count and stays a number. Each bucket is a whole-period total: the aggregation runs in the database over the full filtered range (migration 0437), so `limit`/`cursor` page the result BUCKETS, not the underlying order scan. `truncated` is always `false` — retained for wire compatibility, not because a bucket can be partial — and `partial_totals` is never emitted; continue paging bucket pages with `next_cursor`.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PageMeta"},{"type":"object","properties":{"data_as_of":{"type":"string","format":"date-time","description":"HOW FRESH THIS REPORTING IS — the most recent moment at which commission was resolved anywhere in this key's scope. ISO-8601. Quote it alongside any total you publish. `null` when nothing in scope has ever been resolved."},"group_by":{"type":"array","items":{"type":"string"},"description":"The grouping dimensions actually applied, echoed back so a stored result stays self-describing. Note that network and connection are ALWAYS grouping dimensions whether or not you asked for them, because amounts are never summed across your separate network accounts."},"granularity":{"type":"string","enum":["day","month"],"description":"The date bucket size actually applied — `day` or `month` — echoed back. It affects only the `date` dimension, and only when `group_by` includes it."},"data":{"type":"array","items":{"$ref":"#/components/schemas/SummaryBucket"},"description":"The aggregate buckets. Each is keyed by its grouping dimensions AND its currency, so one group spanning three currencies is three buckets. Never add two buckets together without checking their `currency` first."},"truncated":{"type":"boolean","description":"Pinned `false`. Retained for wire compatibility; it can no longer be `true`, because every bucket is a whole-period total."},"_note":{"type":"string","description":"A fixed human-readable reminder that these figures are INFORMATIONAL and per-currency. Prose for a human reader — do not parse it."}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/Timeout"}}}},"/subnetwork/reporting/export":{"post":{"tags":["Reporting"],"operationId":"createExport","x-required-scope":"reports:write","summary":"Start an asynchronous report export","description":"Returns a job. Poll `/subnetwork/jobs/{job_id}` until it is ready, then follow `/subnetwork/jobs/{job_id}/download`.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["from","to"],"additionalProperties":false,"description":"An UNRECOGNISED BODY KEY IS REFUSED with `400 invalid_parameter`, never silently ignored (added 2026-08-29). Before that, `{\"brand\": \"…\"}` or `{\"merchant_id\": \"…\"}` was dropped without notice and the 202 enqueued an export of EVERY brand/sub in the key's scope — the caller believed they scoped it. Matching is exact and case-sensitive; the error message names the accepted keys and, for the common misnamings, the key you probably meant.","properties":{"from":{"type":"string","format":"date","description":"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":{"type":"string","format":"date","description":"End of the exported window, INCLUSIVE OF THE WHOLE CALENDAR DAY, compared against `transaction_date`. Calendar day (`YYYY-MM-DD`)."},"format":{"type":"string","enum":["csv"],"default":"csv","description":"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":{"type":"string","format":"uuid","nullable":true,"description":"Restrict the export to ONE merchant. Accepts either the internal uuid (`brand_id`) or the durable handle (`brand_public_id`, `brd_…`) — prefer the handle. Omit to export every brand in your key's scope. NULL MEANS NO RESTRICTION, and is identical to omitting the key. Every OTHER non-string value — `\"\"`, whitespace, a number, a boolean, an object, an array — is REJECTED with a 422 naming the key — a type-wrong filter never widens to 'every brand'. Send a non-empty string, or null, or nothing."},"sn_sub":{"type":"string","nullable":true,"description":"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":{"type":"string","nullable":true,"description":"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":{"type":"string","nullable":true,"description":"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":{"type":"string","nullable":true,"description":"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."}}}}}},"responses":{"202":{"description":"The created job — ASYNC, not the finished export. Poll /jobs/{job_id}.","content":{"application/json":{"schema":{"type":"object","description":"A NARROWER shape than GET /subnetwork/jobs/{job_id} returns — four fields only, not the full Job (no `row_count`/`error_message`/`started_at`/`completed_at`). Poll the GET endpoint for progress.","properties":{"job_id":{"type":"string","format":"uuid","description":"OUR identifier for the export job just enqueued — poll `GET /subnetwork/jobs/{job_id}` with it, and download from it once the job reports `done`."},"status":{"type":"string","enum":["pending"],"description":"Always `pending` — the job has just been enqueued."},"created_at":{"type":"string","format":"date-time","description":"When the job was enqueued. ISO-8601."},"poll_url":{"type":"string","description":"`/api/v1/subnetwork/jobs/{job_id}` — pass straight to a fetch of your API base."}},"required":["job_id","status","created_at","poll_url"]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/subnetwork/jobs/{job_id}":{"get":{"tags":["Jobs"],"operationId":"getJob","x-required-scope":"reports:read","summary":"Job status","security":[{"bearerAuth":[]}],"parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"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."}],"responses":{"200":{"description":"The job.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Job"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/subnetwork/jobs/{job_id}/download":{"get":{"tags":["Jobs"],"operationId":"downloadJob","x-required-scope":"reports:read","summary":"Download a finished export","description":"Responds 302 to a short-lived presigned URL. A job that has not finished returns 409 `job_not_ready`.","security":[{"bearerAuth":[]}],"parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"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."}],"responses":{"302":{"description":"Redirect to a presigned download URL.","headers":{"Location":{"schema":{"type":"string","format":"uri"}},"X-Request-ID":{"schema":{"type":"string"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/subnetwork/jobs/{job_id}/retry":{"post":{"tags":["Jobs"],"operationId":"retryJob","x-required-scope":"reports:write","summary":"Retry a failed export","description":"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`.","security":[{"bearerAuth":[]}],"parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"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."},{"$ref":"#/components/parameters/IdempotencyKey"}],"responses":{"200":{"description":"The re-queued job. NOTE this is a NARROWER shape than GET /subnetwork/jobs/{job_id} returns — three fields only, not the full Job. Poll the GET endpoint for progress.","content":{"application/json":{"schema":{"type":"object","properties":{"job_id":{"type":"string","format":"uuid","description":"The job that was re-queued — the SAME id you retried, not a new one. Keep polling the original id; a retry does not mint a new job."},"status":{"type":"string","enum":["pending"],"description":"Always `pending` — the job has just been re-queued."},"retry_count":{"type":"integer","description":"The count AFTER this retry. Maximum 3; a further retry returns 409 `max_retries_exceeded`."}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/subnetwork/payouts":{"get":{"tags":["Payouts"],"operationId":"listPayouts","x-required-scope":"payouts:read","summary":"Payout statements","description":"`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.\n\n`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.\n\nCOST 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.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"}],"responses":{"200":{"description":"One page of payout statements, plus the complete earned-vs-paid aggregate. READ `degraded` AND THE NULLS: `degraded` is present and true when a read behind this response failed, and the figures it feeds are then `null` rather than `0` — a `null` is UNKNOWN, never a settled zero. It is absent on a healthy response and is never emitted as `false`. Every monetary field — `data[].amount`, `data[].items[].amount`, and all six `earned_vs_paid` figures — is a DECIMAL STRING (see MoneyString). `outstanding` and `settled_drift` are SIGNED and go negative when a sub has been overpaid; they are never clamped.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PageMeta"},{"type":"object","properties":{"data":{"type":"array","description":"One page of payout records, ordered (created_at DESC, id DESC). `total` is EXACT and scope-wide here, and does not narrow as you page — but still page until `has_more` is false rather than counting up to it.","items":{"$ref":"#/components/schemas/Payout"}},"earned_vs_paid":{"type":"array","items":{"$ref":"#/components/schemas/EarnedVsPaid"},"description":"A per-(sn_sub, currency) aggregate over the WHOLE scope, not over `data`. NOT a paged collection — it has no cursor, is not the resource this endpoint lists, and is repeated in full on every page."},"degraded":{"type":"boolean","description":"Present and true when at least one read behind this response failed. The money fields it affects are `null` rather than `0` — a `null` means UNKNOWN, never a settled zero. `items[]` and `items[].transaction_id` have no null form, so on a degraded response they may be SHORT: do not treat an empty `items[]` as proof a payout settled nothing. Absent on a healthy response; never emitted as `false`."}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/Timeout"}}},"post":{"tags":["Payouts"],"operationId":"recordPayout","x-required-scope":"payouts:write","summary":"Record a payout you have made to an affiliate","description":"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.\n\nIDEMPOTENCY: 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.\n\nITEMIZED 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.\n\nITEMIZED-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.\n\nITEMIZED-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).\n\nNOT 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.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["sn_sub","amount","currency"],"properties":{"sn_sub":{"type":"string","description":"The affiliate paid. Must be registered under this subnetwork."},"amount":{"oneOf":[{"type":"string","format":"decimal","pattern":"^(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?$"},{"type":"number"}],"description":"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.\n\nSEND 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.\n\nNOTHING ELSE IS. `\"0x10\"`, `\"1e5\"`, `\"Infinity\"`, `\"$25\"` and `\"1,234.00\"` are 422 `validation_error`. (`\"0x10\"` previously recorded a payout of 16.00, silently.)"},"currency":{"type":"string","description":"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":{"type":"string","nullable":true,"description":"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":{"type":"string","nullable":true,"description":"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":{"type":"string","format":"uuid","description":"Required for container keys. For a single-instance key it is resolved from the key."},"payment_method":{"type":"string","nullable":true,"description":"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":{"type":"string","nullable":true,"description":"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":{"type":"string","nullable":true,"description":"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":{"type":"string","nullable":true,"description":"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":{"type":"string","enum":["itemized"],"description":"Switches to itemized mode."},"items":{"type":"array","description":"Per-order breakdown. Presence alone switches to itemized mode.","items":{"type":"object","required":["transaction_id","snapshot_seq"],"properties":{"transaction_id":{"type":"string","description":"The NETWORK'S own transaction id — the same value `GET /subnetwork/reporting/transactions` emits as `transaction_id`, NOT the internal order uuid. Must be distinct across the array; must resolve inside the KEYED instance or the whole call is 404 `not_found`."},"kind":{"type":"string","enum":["earning","adjustment"],"default":"earning","description":"What this line does. `earning` CLAIMS an order's current accrued amount — the normal case, and what you send when settling ordinary earnings. `adjustment` NETS OUT A CLAWBACK on an order that an earlier, already-settled payout paid for: the sale reversed after you paid your sub, and this line recovers it. The sign must follow the kind — an `earning` claims a positive amount and an `adjustment` a negative one — and a mismatch is rejected rather than corrected."},"snapshot_seq":{"type":"integer","description":"The accrual sequence you last read for this position (from GET /claimable). A stale value is rejected — see `stale_snapshot` in the sub-codes above."}}}},"direction":{"type":"string","enum":["disbursement","recovery","write_off"],"default":"disbursement","description":"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":{"type":"string","enum":["recorded","pending"],"default":"recorded","description":"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."}}}}}},"responses":{"201":{"description":"The recorded payout (plain: {payout}; itemized: {payout, items}). `payout.amount` and `items[].amount` are DECIMAL STRINGS.","content":{"application/json":{"schema":{"type":"object","properties":{"payout":{"allOf":[{"$ref":"#/components/schemas/Payout"}],"description":"The payout record that was created, in the same shape `GET /subnetwork/payouts` returns."},"items":{"type":"array","items":{"$ref":"#/components/schemas/PayoutItem"},"description":"The lines that were created for an itemized payout, naming exactly which transactions it settles. Empty for a cumulative payout, which states no coverage. NOTE these entries omit `voided_at`, which the GET listing includes."},"request_id":{"allOf":[{"$ref":"#/components/schemas/RequestId"}],"description":"The identifier for this request, also in the `X-Request-ID` header."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Idempotency-key replay (returns the existing payout), or an itemized-mode conflict — see the ITEMIZED-MODE SUB-CODES note above.\n\n⚠ THE ITEMIZED REPLAY BODY IS SHAPED DIFFERENTLY FROM THE 201. Its `items[]` are the stored rows verbatim: the money key is `claim_amount` (not `amount`) and the transaction is identified by the internal `order_id` (not `transaction_id`). `claim_amount` IS a decimal string like every other money field, and it is signed — an adjustment line is negative. The key-name divergence is a known gap (docs/api-standard.md §11 G-22): do not assume the 201 shape here.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","required":["code","message","request_id"],"properties":{"code":{"type":"string","enum":["conflict"],"description":"Machine-readable and stable — branch on this, never on `message`."},"message":{"type":"string","description":"For humans. May change without notice."},"request_id":{"allOf":[{"$ref":"#/components/schemas/RequestId"}],"description":"The identifier for this request, also in the `X-Request-ID` header."}},"description":"Why the write was refused. THE MOST COMMON CAUSE IS A DELIBERATE ONE: reusing a `client_reference` or `Idempotency-Key` returns the ORIGINAL payout here rather than paying twice, so a 409 on retry is success, not failure — compare the returned `payout.id` with what you already stored."},"code":{"type":"string","description":"Present only on an itemized-mode sub-conflict (see ITEMIZED-MODE SUB-CODES above) — absent on a plain idempotency-key replay, where `error.code` alone (`conflict`) is the whole signal."},"payout":{"allOf":[{"$ref":"#/components/schemas/Payout"}],"nullable":true,"description":"THE PAYOUT THAT ALREADY EXISTED — the original record your idempotency key resolved to, not a new one. Treat its presence as confirmation that your earlier call succeeded. NULL MEANS NOT RETRIEVABLE, NOT 'no conflict': the 409 status is the authoritative statement that your key already resolved to a payout. The body is null only in the narrow race where that row could not be read back — it was removed between the uniqueness violation and this read. Treat null as 'the earlier call succeeded, fetch the record yourself via `GET /subnetwork/payouts`', and NEVER as licence to retry the create: retrying is what produces the duplicate payment the idempotency key exists to prevent."},"items":{"type":"array","description":"RAW stored rows on the itemized replay path — see the warning above.","items":{"type":"object","properties":{"order_id":{"type":"string","format":"uuid","description":"OUR INTERNAL order uuid — note that this replay body uses it where every other payout shape publishes the network's `transaction_id`. It is not the network's id and cannot be reconciled against the network's reporting; join it back through your own record of the original 201 instead."},"kind":{"type":"string","description":"What this line does — `earning` claims an order's accrued amount, `adjustment` recovers a clawback on an order an earlier settled payout had already paid for."},"claim_amount":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"The amount this line claims, denominated in the `currency` on this same line — note this replay body uses the raw column name where the 201 and the GET listing both call the same value `amount`. Negative for an `adjustment`."},"currency":{"type":"string","description":"ISO-4217 code governing `claim_amount` on this line. Carried per line rather than inherited, because amounts in different currencies are never summed."},"snapshot_seq":{"type":"integer","description":"The ledger sequence number this claim was pinned to — the audit anchor that makes the line reproducible, and the value to echo back when re-claiming the same transaction later."},"voided_at":{"type":"string","format":"date-time","nullable":true,"description":"When this claim was cancelled because its parent payout failed or was cancelled. `null` — the normal case — means the claim is live and the transaction still counts as paid."}}}}}}}}},"422":{"description":"Standard validation_error, or an itemized-mode sub-code — see the ITEMIZED-MODE SUB-CODES note above.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message","request_id"],"properties":{"code":{"type":"string","enum":["validation_error"],"description":"Machine-readable and stable — branch on this, never on `message`. For itemized failures, also read the top-level `code` sibling, which is more specific."},"message":{"type":"string","description":"For humans. May change without notice."},"request_id":{"allOf":[{"$ref":"#/components/schemas/RequestId"}],"description":"The identifier for this request, also in the `X-Request-ID` header."}},"description":"Why the request was rejected as semantically impossible. For an itemized payout the body also carries a TOP-LEVEL `code` alongside this object, drawn from a SECOND and more specific vocabulary than `error.code` — read that one to find out which item failed and why."},"code":{"type":"string","description":"Present only on an itemized-mode sub-code drawn from the RPC's own vocabulary (see ITEMIZED-MODE SUB-CODES above) — absent on every other validation_error."}}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"Not configured — the server has no database configured. `error.code` is `service_unavailable`, and a `Retry-After` header is set. Corrected 2026-08-16: this used to carry `internal_error` at 503, a code/status inconsistency tracked as gap G-29 and now closed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/subnetwork/payouts/claimable":{"get":{"tags":["Payouts"],"operationId":"getClaimablePayouts","x-required-scope":"payouts:read","summary":"Claimable balance","description":"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`.","security":[{"bearerAuth":[]}],"parameters":[{"name":"sn_sub","in":"query","required":true,"schema":{"type":"string"},"description":"REQUIRED. The affiliate to build the proposal for. Absent or blank returns 422 (`sn_sub is required.`)."},{"name":"currency","in":"query","required":true,"schema":{"type":"string","pattern":"^[A-Za-z]{3}$"},"description":"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.`)."},{"name":"instance_id","in":"query","required":false,"schema":{"type":"string","format":"uuid"},"description":"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`)."}],"responses":{"200":{"description":"Claimable balance for one (sn_sub, currency) — never cross-summed across currencies. Every amount is a DECIMAL STRING (see MoneyString). `net_proposed`, `settled_drift` and `adjustments[].amount` are SIGNED and legitimately negative; they are never floored at zero. `can_disburse` is the boolean to branch on, and is decided server-side from the numeric value.","content":{"application/json":{"schema":{"type":"object","properties":{"sn_sub":{"type":"string","description":"The affiliate this proposal is for, echoed back from your request."},"instance_id":{"type":"string","format":"uuid","description":"OUR uuid for the network CONNECTION this proposal covers, echoed back from your request. A claim is always scoped to one connection, because earnings from two are never pooled."},"currency":{"type":"string","description":"ISO-4217. The currency of every amount below."},"earnings":{"type":"array","items":{"type":"object","properties":{"transaction_id":{"type":"string","nullable":true,"description":"THE NETWORK'S OWN transaction id for the sale — the same value `GET /subnetwork/reporting/transactions` emits, so you can show your sub exactly which sales a payment covers. NULL MEANS NOT RESOLVED — the order behind this line could not be read while building the proposal — and NEVER 'no transaction': the stored column is NOT NULL, so every real sale has one. The `amount` on this line is still owed and still counts toward the proposal; you simply cannot show the sub which sale it came from. A null is a defect worth reporting, not a line to drop."},"brand_public_id":{"type":"string","nullable":true,"description":"The durable brand handle (`brd_…`) — a payout line is reconciled against your own ledger, and `brand_name` is not a join key: two merchants can share a name and a merchant can be renamed upstream. NULL MEANS NOT MINTED OR NOT RESOLVED, and it is never fabricated. Every stored brand currently carries a handle, so a null on a payout line is a lookup that did not resolve rather than a brand without a handle. The line's money is unaffected; what you lose is the join key, so reconcile that line by `transaction_id` instead."},"brand_name":{"type":"string","nullable":true,"description":"The merchant's programme name, for showing a human which sale this is. Two merchants can share a name, so never use it as a key — join on `brand_public_id`. NULL MEANS NOT RESOLVED — the brand row was not joined while building this line — and NOT that the merchant is nameless: a resolved brand always has a name. The amount is unaffected. It was never a key, so a null costs you only the human label."},"transaction_date":{"type":"string","format":"date","nullable":true,"description":"When the SALE occurred, as reported by the network — the date this line's split was resolved as of, and the date to reconcile it on. ISO-8601. NULL MEANS NOT RESOLVED — the order behind this line could not be read — and NEVER 'undated sale': the stored column is NOT NULL. Do NOT substitute today's date to make the line reconcile: that would file the sale in the wrong period, which is precisely the reconciliation error this field exists to prevent."},"amount":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"The sub's claimable share of this one sale, denominated in the `currency` ON THIS SAME LINE. Claims are all-or-nothing: this whole amount is claimed, or none of it."},"currency":{"type":"string","description":"Repeated per line ON PURPOSE — an amount you re-serialise must not travel without it."},"snapshot_seq":{"type":"integer","description":"The ledger sequence number this line is pinned to. ECHO IT BACK UNCHANGED when you claim this transaction: it is what proves you are claiming the amount you were shown, and a stale value is rejected rather than silently re-priced."}}},"description":"THE TRANSACTIONS YOU CAN CLAIM RIGHT NOW for this sub — confirmed, unpaid, and not held back. These are the lines to send as `items[]` on an itemized `POST /subnetwork/payouts`."},"adjustments":{"type":"array","items":{"type":"object","properties":{"transaction_id":{"type":"string","nullable":true,"description":"THE NETWORK'S OWN transaction id for the sale whose value moved after it was paid for. NULL MEANS NOT RESOLVED — the order behind this adjustment could not be read — and NEVER 'no transaction': the stored column is NOT NULL. The adjustment still applies and its `amount` is still owed, or owed BACK: adjustments are legitimately negative, so dropping an unlabelled line silently overstates what the sub is due."},"brand_public_id":{"type":"string","nullable":true,"description":"The durable brand handle (`brd_…`) — see `earnings[].brand_public_id`. NULL MEANS NOT MINTED OR NOT RESOLVED, exactly as on `earnings[]`, and is never fabricated. The adjustment's amount and sign are unaffected; reconcile the line by `transaction_id`."},"brand_name":{"type":"string","nullable":true,"description":"The merchant's programme name, for showing a human which sale reversed. Not a key. NULL MEANS NOT RESOLVED — the brand row was not joined while building this line — and NOT that the merchant is nameless. It was never a key, and the adjustment's amount and sign are unaffected."},"amount":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"The correction for this transaction, denominated in the `currency` on this same line. NEGATIVE in the normal case — the sale reversed after your sub was paid, so this much comes back off the settlement. It is not clamped at zero."},"currency":{"type":"string","description":"ISO-4217 code governing `amount` on this line. Repeated per line deliberately: an amount that travels without its currency is the one value that can be silently added into a different currency's column downstream."},"reason":{"type":"string","description":"Why this correction exists — what changed on the transaction after it was settled. Prose for a human, so you can explain a deduction to your sub; not machine-readable and not a stable vocabulary."},"snapshot_seq":{"type":"integer","description":"The ledger sequence number this correction is pinned to. Echo it back unchanged when submitting the adjustment."}}},"description":"CLAWBACKS TO NET OFF — transactions an already-settled payout paid for whose value has since moved, almost always because the sale reversed after your sub was paid. Send these as `adjustment` items to recover the money against this settlement. Empty when nothing has drifted."},"net_proposed":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"THE AMOUNT TO ACTUALLY PAY THIS SUB — `earnings` minus the `adjustments` above, denominated in `currency`. This is the headline number this endpoint exists to give you. IT CAN BE NEGATIVE and is not clamped: if reversals outweigh new earnings, the sub owes you, and you should pay nothing rather than paying a negative amount."},"can_disburse":{"type":"boolean","description":"WHETHER IT IS SAFE TO PAY NOW. `false` means do not disburse — typically because `net_proposed` is zero or negative, or nothing is claimable yet. Branch on this rather than on the sign of `net_proposed` yourself; it is decided server-side from the unrounded figure."},"unclaimed_confirmed":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"Total confirmed, unpaid earnings for this sub before any deduction, denominated in `currency` — the gross that `net_proposed` is built from. It excludes sales the network has not confirmed (see `pending_not_yet_claimable`) and those we have not yet been paid for (see `held_awaiting_network_payment`)."},"settled_drift":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"Total value that has MOVED ON TRANSACTIONS ALREADY PAID FOR, denominated in `currency` — the sum of the `adjustments` above. NEGATIVE in the normal case, and not clamped: this is money already in your sub's hands that a later reversal took back. There is no stored overpayment balance anywhere; this is recomputed from the ledger on every read."},"pending_not_yet_claimable":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"Earnings withheld because THE NETWORK HAS NOT CONFIRMED THE SALE yet, denominated in `currency`. It is not payable and some of it will reverse. Distinct from `held_awaiting_network_payment`, which is about the network not having paid US for sales it has already confirmed."},"held_awaiting_network_payment":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"Confirmed earnings withheld because THE NETWORK HAS NOT PAID US for them, denominated in `currency`. Distinct from `pending_not_yet_claimable`, which is the network not having CONFIRMED the sale: these sales ARE confirmed and are expected to be paid, but the money has not reached us, so it is not claimable. READ IT WHENEVER YOU RECONCILE A DROP: without this field, `unclaimed_confirmed` can fall with no other value on this response accounting for where the money went — surfacing that is the whole reason it exists. Not payable, and not included in `net_proposed`."},"unitemized_paid":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"Amounts already paid to this sub through CUMULATIVE payouts, which stated no coverage and so cannot be matched to specific transactions, denominated in `currency`. It is subtracted when computing what remains claimable, which is why a lump-sum payment reduces future claims even though it names no sales."}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"The data behind the proposal could not be read. Two causes, one answer: the server has no database configured, OR the itemization read behind this proposal failed (added 2026-08-29 — until then a failed read published `net_proposed: \"0\"` under a 200, byte-identical to 'nothing to claim'; an unknown balance is now this error, never a fabricated zero). `error.code` is `service_unavailable`, and a `Retry-After` header is set. ADDED TO THE SPEC 2026-08-16: this endpoint has always been able to return a 503 and never declared one, so the status was entirely undocumented; it also carried the `internal_error`-at-503 mismatch (gap G-29), corrected in the same change as the two `POST /subnetwork/payouts` write paths.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"Your SubNetwork API key, sent as `Authorization: Bearer snk_…`. Keys are issued from the Affilitera app; there is no self-service enrolment endpoint, so `expires_at` is set at issue time and is not settable over this API.\n\n**Keys expire.** Since 2026-08-11 a key minted without an explicit expiry gets `expires_at` = **365 days from mint**. A non-expiring key is still issuable but requires an explicit `expires_at: null` at mint — it is a deliberate choice, not the default. Keys issued before 2026-08-11 keep whatever expiry they were issued with, including none: the new default was NOT applied retroactively, because changing a default must not silently revoke a live credential.\n\nAn expired key returns `401 unauthorized`, indistinguishable from a revoked or unknown one — check the expiry date before treating a sudden 401 as a compromise. Remaining life is visible in the Affilitera app (with a warning from 30 days out); `GET /instance` does not currently expose it. **Rotation does not extend a key**: the replacement inherits the original `expires_at` verbatim, so renewing means minting a new key, not rotating."}},"parameters":{"Limit":{"name":"limit","in":"query","required":false,"description":"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`.","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},"ReportingLimit":{"name":"limit","in":"query","required":false,"description":"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.","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}},"SummaryLimit":{"name":"limit","in":"query","required":false,"description":"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.","schema":{"type":"integer","minimum":1,"maximum":10000,"default":50}},"Cursor":{"name":"cursor","in":"query","required":false,"description":"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.","schema":{"type":"string"}},"ModifiedSince":{"name":"modified_since","in":"query","required":false,"description":"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.","schema":{"type":"string","format":"date-time"}},"IncludeDelisted":{"name":"include_delisted","in":"query","required":false,"description":"Include products whose `status` is `delisted`. Delisted rows are EXCLUDED by default: a delisted product is one the feed STOPPED carrying, so its `destination_url` is expected to 404, and serving it unmarked hands you a dead link. The literal string `true` is the ONLY value that opts in — `1`, `yes`, `TRUE` and a bare `?include_delisted` all leave the default exclusion in place rather than silently widening the result set. When opted in, read `status` on each row to tell live from delisted.","schema":{"type":"string","enum":["true"]}},"IdempotencyKey":{"name":"Idempotency-Key","in":"header","required":false,"description":"Makes this write safe to retry. Send any unique string (a UUID is typical) and reuse it for the retry.\n\nA 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.\n\nKeys 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.","schema":{"type":"string","maxLength":255}}},"schemas":{"RequestId":{"type":"string","description":"Quote this when reporting a problem. Also returned in the X-Request-ID header.","example":"req_a90fa758cb0447d8"},"PageMeta":{"type":"object","description":"The paging metadata on every list response.","properties":{"next_cursor":{"type":"string","nullable":true,"description":"Pass as `cursor` to fetch the next page. Null on the last page."},"has_more":{"type":"boolean","description":"Whether another page exists. Loop on THIS, never on `total`."},"total":{"type":"integer","nullable":true,"description":"APPROXIMATE on most endpoints — a planner estimate, not a count. A display hint only; never use it as a loop bound. NULL MEANS NOT SUPPLIED BY THIS READ: the endpoint obtained no count for this page — either it does not count at all, or the count degraded — and a total is never inferred from `data.length`, which is one page. NULL IS NOT ZERO, and the two are distinguishable: an empty result reports `total: 0`, an uncounted one reports null. Paging is unaffected either way; `has_more` and `next_cursor` are authoritative and this field never is."},"limit":{"type":"integer","description":"The page size actually applied."},"limit_clamped":{"type":"boolean","description":"True when the requested `limit` exceeded the maximum and was reduced."},"limit_max":{"type":"integer","description":"This endpoint's maximum page size."},"request_id":{"allOf":[{"$ref":"#/components/schemas/RequestId"}],"description":"The identifier for THIS request, also returned in the `X-Request-ID` header. We log the underlying cause of any 5xx against it, so quoting it in a support report is enough for us to recover the full server-side cause. Log it on every call you make."},"filter_notices":{"type":"array","items":{"$ref":"#/components/schemas/FilterNotice"},"description":"PRESENT ONLY WHEN A FILTER YOU SENT WAS LOSSY, and OMITTED ENTIRELY otherwise — this key is absent from a response that used no such filter, and is never an empty array. A notice NEVER changes `data`: it states in-band that the filter dropped rows whose underlying column is SQL NULL, so those rows are represented in neither `data` nor `total`. Read it whenever you filter and intend to treat the page as complete. CURRENTLY EMITTED ONLY BY `GET /subnetwork/brands`, and there only for `country`, `category`, `min_traffic` and `relationship_status`; every other list endpoint omits it today. It is declared on the shared envelope because that is where it structurally lives — do not read its presence here as a claim that other endpoints emit it."}}},"FilterNotice":{"type":"object","description":"AN IN-BAND STATEMENT THAT A FILTER WAS LOSSY — that rows were EXCLUDED by it rather than returned as non-matching. That distinction is what makes a filtered page a lower bound rather than a complete answer. It is not an error and it never changes `data`.","properties":{"filter":{"type":"string","description":"The query parameter this notice is about, spelled as the caller sent it — for example `country`."},"reason":{"type":"string","enum":["excludes_unknown"],"description":"Machine-readable reason — a closed set that currently holds exactly one value. `excludes_unknown` means rows whose underlying column is SQL NULL were dropped by this filter and are represented in neither `data` nor `total`. DELIBERATELY NOT NAMED `code`: the envelope's `error.code` is drawn from a different closed set, and a second `code` meaning something else in the same body is exactly the ambiguity a partner would mis-handle."},"field":{"type":"string","description":"The underlying column whose absence causes the exclusion, so the notice is actionable rather than vague — for example `merchant_country`."},"message":{"type":"string","description":"Human-readable explanation of what this filter excluded and how to see the excluded rows. Safe to surface to your own operators. It is prose for humans: branch on `reason` and `filter`, never on this text."}}},"MoneyString":{"type":"string","nullable":true,"format":"decimal","pattern":"^-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?$","description":"A MONETARY AMOUNT, ENCODED AS A DECIMAL STRING — never a JSON number.\n\nWHY. JSON numbers are IEEE-754 doubles in most parsers, and this surface is a commission ledger: a long chain of additions in which a cent must survive every hop. Parse this with your language's decimal/BigDecimal type, not its float.\n\nGUARANTEED FORM: an optional leading `-`, digits, and an optional `.` with more digits. NEVER a thousands separator, a currency symbol, a leading `+`, or exponent notation. Amounts are in MAJOR units (dollars, not cents) at the currency's natural exponent, so do NOT assume two decimal places — a JPY amount has none, a BHD/KWD/TND amount has three. Trailing zeros are not padded: `12.5`, not `12.50`.\n\nSIGN: negative values are real and are never clamped. `outstanding`, `net_proposed`, `settled_drift` and `adjustments[].amount` all go negative when a sub has been overpaid or a sale reverses after payment. `-0` is never emitted; an exact zero is `0`.\n\nNULL: `null` means the amount is unknown or not yet computed. It is NEVER `0`, and never the string `\"null\"`.\n\nCURRENCY: this platform performs NO FX conversion and never cross-sums currencies. Every amount is emitted with its currency in scope — as a sibling `currency` / `split_currency` field, or via a currency-keyed map. Never add two amounts whose currencies differ."},"Rate":{"type":"object","description":"A STRUCTURED rate, never a bare number — a rate is meaningless without its type and currency.","properties":{"type":{"type":"string","description":"WHAT `value` MEANS — branch on this BEFORE interpreting `value`, which is a bare decimal string for every type. `flat`/`cpa`/`cpl`/`cpc`: a MONETARY amount, denominated in `currency` (per action, lead and click respectively for the last three). `percent`/`range`: a PERCENTAGE OF THE SALE VALUE. (`currency` is populated on these too — it denominates the PROGRAMME, not this number. See `currency`.) `revshare`: a PERCENTAGE OF THE NETWORK'S OWN COMMISSION — which is itself already a fraction of the sale — so it is NOT COMMENSURABLE WITH `percent` even though both are bare percentages in the same units. A `revshare` of 70 and a `percent` of 7 can describe similar real earnings, so SORTING OR COMPARING `value` ACROSS THE TWO TYPES RANKS REVSHARE BRANDS WILDLY TOO HIGH; segment by `type` first. `unknown`: the network published a rate we could not classify — treat `value` as uninterpretable rather than as a percentage."},"value":{"type":"string","nullable":true,"format":"decimal","description":"A DECIMAL STRING for EVERY `type`, so one field always has one wire type regardless of the data. What it MEANS depends on `type`: currency units for flat/cpa/cpl/cpc, percent for percent/range/revshare. ON A `range` IT IS THE LOWER BOUND ONLY, and `value_max` carries the upper — read the PAIR, never `value` alone, because most range rates in this catalogue start at zero and a lone \"0\" reads as a zero-rate programme when it is nothing of the kind. `null` MEANS THE NETWORK STATED NO LOWER BOUND, and `value_max` tells its two causes apart: `value_max` NON-NULL is a CEILING term (\"Up to 8%\") whose only published number is that ceiling; `value_max` ALSO NULL means the network published no rate value at all. A null is never a substituted default, and this field is never \"0\" to mean absent — a \"0\" here is a lower bound the network actually stated, sitting beside the `value_max` that completes it."},"value_max":{"type":"string","nullable":true,"format":"decimal","description":"THE UPPER BOUND of the term, a DECIMAL STRING in the SAME UNITS as `value`, or `null` when the term has none. IT CARRIES TWO SHAPES AND BOTH ARE LOAD-BEARING. On a `range`, `value` is the lower bound and this is the upper: the PAIR is the term, and reading `value` alone understates it — usually all the way to zero, which is where most of this catalogue's ranges start. On a CEILING term (\"Up to 8%\") `value` is `null` and this field carries the ONLY number the network published, so losing it does not degrade the rate, it deletes it. If you need one figure, read `value ?? value_max` and treat the result as an upper bound whenever `value` was null. `commission_summary` carries the network's own human-readable span where one exists, but it is display prose and is not parseable."},"currency":{"type":"string","nullable":true,"description":"ISO-4217 code denominating THIS RATE'S OWN `value`, exactly as the network stated it on this rate. It is therefore NULL on every `percent`, `range` and `revshare` — a ratio has nothing to denominate — and is populated only on the money types (`flat`/`cpa`/`cpl`/`cpc`). IT IS NOT THE PROGRAMME'S CURRENCY: read `programme_currency` for what the programme settles in, which is populated for every rate type. The brand-level `currency` one nesting level up is the aggregate of THIS field across the rate card, so the two agree by construction rather than by coincidence. Network-supplied, never defaulted; a null means the network denominated no rate here and is not a placeholder."},"currency_symbol":{"type":"string","nullable":true,"description":"A display symbol for `currency` (for example the pound or dollar sign), ASSIGNED BY US from a small lookup table purely so you need not carry one. Presentation only: never parse it, and never treat it as identifying a currency, since several currencies share a symbol. Always key logic on `currency`. `null` when `currency` is null, or when the code is not in our table — we return null rather than guessing. On a percentage rate it is null by construction; use `programme_currency_symbol` for the programme's symbol."},"programme_currency":{"type":"string","nullable":true,"description":"ISO-4217 code for the currency THE PROGRAMME PAYS IN, populated for EVERY `type` including `percent`, `range` and `revshare`. This is the field to read when you want to know what a percentage rate settles in — the fact that 7.23 is dimensionless does not make the programme's denomination unknowable. IT DOES NOT TELL YOU WHETHER `value` IS AN AMOUNT OF MONEY: `type` is the only field that does, and formatting `value` with `programme_currency_symbol` without branching on `type` renders a 7.23% rate as a price. SOURCE, most specific first: the currency the network attached to THIS EXACT RATE where it attached one (only ever `flat`/`cpa`/`cpl`/`cpc` — that is this object's own `currency` field); otherwise the currency the network states for the PROGRAMME as a whole. Both are network-reported verbatim; neither is computed by us. NULL MEANS NO NETWORK STATED ONE AT EITHER LEVEL. It is not a placeholder: we do not substitute a default, derive one from the merchant's country, or fall back to a platform currency. A null is common — some networks state a programme currency for all their brands and others state none at all — so treat it as denomination-unknown. ONE EXCEPTION TO THAT READING: the programme-currency enrichment is a separate read, and when IT fails we log it server-side and serve every rate on the page with `programme_currency` null — withheld, not a statement that these programmes have no currency, and not distinguishable here from one. See `default_rate` for the same limitation on the rate card itself."},"programme_currency_symbol":{"type":"string","nullable":true,"description":"A display symbol for `programme_currency`, ASSIGNED BY US from a small lookup table purely so you need not carry one. Presentation only: never parse it, never treat it as identifying a currency (several share a symbol), and always key logic on `programme_currency`. It does NOT mean `value` is money — it tracks `programme_currency`, which is populated on percentage rates too, so concatenating it with `value` without branching on `type` produces a price for a percentage. `null` when `programme_currency` is null, or when the code is not in our table — we return null rather than guessing."},"group_id":{"type":"string","description":"Present on `groups[]` entries only."},"label":{"type":"string","description":"Present on `groups[]` entries only."},"applicability":{"type":"string","enum":["unconditional","undecidable"],"description":"Present on `groups[]` entries only."},"applicability_note":{"type":"string","nullable":true,"description":"Free-text explanation of why `applicability` is `undecidable` — i.e. what about the network's published conditions we could not evaluate. Diagnostic prose for a human, not machine-readable, and its wording is not stable. `null` when there is nothing to explain."}}},"Transaction":{"type":"object","description":"One commission transaction. INFORMATIONAL ONLY — Affilitera does not disburse Type-2 commission.","properties":{"transaction_id":{"type":"string","description":"THE NETWORK'S OWN transaction id, stored verbatim exactly as the network reported it — never minted, renumbered or normalised by us. This is the value to quote when reconciling a row against that network's own reporting UI or API. Treat it as an OPAQUE STRING and never parse it as a number: networks issue numeric-looking, hyphenated and prefixed forms, and leading zeros are significant. It is unique only WITHIN one network connection (the database key is (`instance_id`, `transaction_id`)), so key your own records on that PAIR — two different networks can legitimately issue the same id, and deduplicating on this field alone will silently merge two unrelated sales."},"network":{"type":"string","nullable":true,"description":"The network FAMILY slug this sale came through, e.g. `awin`, `cj`, `rakuten` — a human-readable LABEL assigned by us, not by the network. IT IS NOT UNIQUE AND IS NOT A KEY: one owner may hold several connections to the same network, and every one of them reports `awin` here. Use `instance_id` to identify the connection; use this only for display. `null` when the key's scope no longer resolves the owning connection to a slug."},"instance_id":{"type":"string","format":"uuid","description":"OUR uuid for the network CONNECTION (the linked network account) this sale belongs to — the real scoping and grouping key on this row, and the same value `GET /subnetwork/brands` and `GET /subnetwork/reporting/summary` emit, so the three join on it. One connection is one account you hold at one network; an owner with two Awin accounts has two `instance_id`s that reconcile and get paid separately, and must never be summed as one. Assigned by us and stable for the life of the connection."},"instance_public_id":{"type":"string","example":"inst_9ViVV9aGEXa","description":"The instance's OPAQUE PUBLIC handle (`inst_...`) — the stable identifier to key your own records on. DERIVED from `instance_id` and never stored, so it is stable for the life of the connection and byte-identical on every endpoint that emits it: `GET /instance`, `/brands`, `/products`, `/feeds`, `/feeds/{feed_id}/products`, `/coupons`, `/offers`, `/reporting/transactions` and `/reporting/summary`. That is what lets you join across all of them without ever handling the internal uuid. Always present: this endpoint is scoped to the key's own connections, so it has no foreign-row case."},"brand_id":{"type":"string","format":"uuid","nullable":true,"description":"OUR INTERNAL uuid for the brand, scoped to this one network connection. NOT STABLE: disconnecting and reconnecting a network re-ingests the catalogue and mints a new uuid for the same real brand, so anything you persist against it breaks silently. Prefer `brand_public_id` (durable) for storage and joins, and `merchant_id` for reconciling against the network. `null` when the sale could not be matched to a brand in the catalogue — the transaction and its money are still valid."},"brand_public_id":{"type":"string","nullable":true,"description":"OUR minted, opaque, stable identity for the brand (`brd_…`) — the durable key to join this row back to `GET /subnetwork/brands`, and a value the `brand_id` filter accepts. `brand_id` is the internal uuid and is NOT stable across a network disconnect/reconnect: a re-ingested brand gets a new one, while this survives. `null` only if the brand has no minted handle."},"brand_name":{"type":"string","nullable":true,"description":"The advertiser's display name as the NETWORK supplied it at ingestion, passed through unedited. A display label only: networks rename advertisers, and two connections routinely spell the same merchant differently (`ACME Ltd` vs `Acme`), so never join, group or deduplicate on it — use `merchant_domain` for that. `null` when the row resolved no brand."},"merchant_id":{"type":"string","nullable":true,"description":"The NETWORK'S own merchant/advertiser id for this brand, reported verbatim at ingestion — for Awin the `advertiserId`, the same value that appears as `awinmid=` in the click URLs we mint. The join key for reconciling this transaction against that network's own reporting. Opaque STRING, never parse as a number. Same field, same meaning — including its null semantics — as `merchant_id` on `Brand`: null means the NETWORK supplies no advertiser id of its own, not that we hold one and withheld it."},"merchant_domain":{"type":"string","nullable":true,"description":"The merchant's own web domain, e.g. `101blockchains.com`. THE ONLY CROSS-INSTANCE GROUPING KEY ON THIS ROW: `instance_id`, `brand_id`, `brand_public_id` and `merchant_id` are all scoped to a SINGLE network connection, so the same real merchant reached through two networks yields transactions carrying four different identifiers with nothing linking them. Grouping on this field is how you total earnings per merchant across your networks. IT IS A HEURISTIC, NOT AN ASSERTION OF IDENTITY — a shared domain is EVIDENCE that two rows are the same merchant, not a statement by us that they are, and public-suffix hosts and PBN domains make naive merging unsafe. For an assertion rather than evidence, resolve the brand through `GET /subnetwork/brands` and read its `merchant_key`, the handle of the resolved dedup group's canonical brand. Same field, same meaning, as `merchant_domain` on `Brand` and `Promotion`. Null when the brand has no resolved domain."},"sn_sub":{"type":"string","nullable":true,"description":"The SUB identifier (the tier-3 `sub1`) this sale is attributed to. IT ROUND-TRIPS: it is the value your own system put in the sub slot when the click was tracked, handed out to the network in the click URL and handed back by the network on the transaction — yours in origin, network-reported in provenance, and never rewritten by us. Which native field it is read back from depends on the network (Awin `clickref`, CJ `sid`, Rakuten `u1`, Tradedoubler `epi1`, PerformCB `aff_sub`, Linkbux `click_ref`). It is DISTINCT from the promoting affiliate identity, which this endpoint does not emit — a sale carries both, and this is the sub. `null` when the click carried no sub value, or the network returned none: the sale is still real and still splits, it simply attributes to no sub and `computed_sub_split` is then `0`. Filter on it with `?sn_sub=`."},"sn_sub_dimensions":{"type":"array","nullable":true,"description":"Decoded property/placement labels packed into `sn_sub` via the subid-dimension encoding (design doc §4-5) — display-only, additive. `null` when `sn_sub` is null, or when it is a raw/manual value with nothing to decode.","items":{"type":"object","required":["id","context_type","label"],"properties":{"id":{"type":"integer","description":"A small per-(account, network) integer — not a global identity or a db sequence."},"context_type":{"type":"string","enum":["property","placement"],"description":"Which of the two owner-defined axes this decoded label belongs to — `property` (which of your sites/apps the click came from) or `placement` (where within it). These are the ONLY two values; an affiliate is not a dimension and never appears here. The axes are independent, so a packed subid may carry one, both, or neither."},"label":{"type":"string","description":"The human-readable name YOU gave this dimension when it was first used, echoed back verbatim — owner-supplied, never network-supplied and never invented by us. Labels are unique per (your account, network connection, axis), which is why the same label can carry different `id`s on two different connections."}}}},"order_value":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"GROSS SALE VALUE — the basket total the network reported for this sale, denominated in `currency` (NOT in `split_currency`). IT IS NOT THE BASIS OF ANY SPLIT, and never has been. Commission is split from `pool_commission`, the pool the network actually credited. DO NOT multiply this by a rate to derive commission: the answer will disagree with what is really payable, because networks apply tiers, per-product rates, currency handling and later corrections that we deliberately do not re-derive. Use it for average-order-value and conversion-value reporting only. `null` when the network reported no order value — which is NOT zero."},"currency":{"type":"string","description":"ISO-4217 code governing `order_value` ON THIS ROW — AND NOTHING ELSE ON IT. This is the currency the NETWORK recorded the SALE in. The three split amounts are governed by `split_currency`, not by this field. The two are normally identical and you should expect them to be: Affilitera performs NO FX CONVERSION anywhere, so a split is always taken in the currency the commission was credited in. THEY CAN NEVERTHELESS DIVERGE, and a reconciler must not assume they cannot. If a network restates a sale into a different currency, this field moves as soon as we re-ingest the sale, while `split_currency` goes on reporting the currency the commission was actually accrued in until the sale is re-resolved against the ledger. For that window `order_value` and the split legs on this one row are in DIFFERENT currencies. Never add them together and never convert one into the other — read the two fields independently and branch on each."},"status":{"type":"string","description":"The lifecycle state of the SALE, normalised at ingestion onto a CLOSED four-value vocabulary that is enforced by a database constraint, so no other value can ever appear here: `pending` (the network has recorded the sale but not yet validated it), `approved` (validated and payable), `declined` and `rejected` (REVERSED — the sale will not be paid). `declined` and `rejected` mean the same thing to us and differ only by which network emitted them (Awin says `declined`, most others `rejected`), so TREAT THE TWO IDENTICALLY. FOR RECONCILIATION: `approved` is the only state that confirms money; `declined`/`rejected` are what drive a reversal, and any commission already accrued for the sale is netted back to zero. AN ABSENT OR UNRECOGNISED UPSTREAM STATUS IS NEVER FABRICATED INTO `pending` — a row whose network status cannot be mapped onto this vocabulary is skipped at ingestion with a reason and never reaches this endpoint, precisely so that a reversal can never be silently served to you as pending."},"status_bucket":{"type":"string","enum":["confirmed","pending","reversed"],"description":"A DERIVED three-way rollup of `status`, computed on read for convenience and never stored: `approved` becomes `confirmed`, `declined` and `rejected` become `reversed`, `pending` stays `pending`. It is exhaustive over the four values `status` can hold. This is the SAME bucketing the `by_status` totals on `GET /subnetwork/reporting/summary` use, so a transaction listing and an aggregate can never disagree about which bucket a sale is in. Use `status` when you need the exact network state, and this when you only need confirmed-vs-pending-vs-reversed."},"transaction_date":{"type":"string","format":"date-time","nullable":true,"description":"When the SALE occurred, as reported by the network — not when we ingested it, not when its commission was resolved, and not when anything was or will be paid. ISO-8601 timestamp. THIS IS THE AS-OF ANCHOR FOR THE WHOLE SPLIT: every rate and contract term is resolved as of this date and never as of `now()`, so a contract you change tomorrow does not move the split on a sale dated today. The split you are reading is therefore final, not a current-best-estimate that will drift. RECONCILE ON THIS DATE — an integrator who buckets these rows by ingestion date or by payment date will disagree with our figures permanently rather than temporarily, and the disagreement will never converge. It is also the field `?from=`/`?to=` filter on. `null` when the network reported no date; such a row can be neither date-filtered nor date-bucketed and falls outside every `from`/`to` window."},"pool_commission":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"THE PUBLISHER COMMISSION POOL — the total commission the NETWORK credited for this sale, taken from the network's own report and denominated in `split_currency` (NOT in `currency`). This is the single input the split is taken from, and it is the sum of every party's leg: `pool_commission` = `computed_owner_split` + `computed_sub_split` + Affilitera's own platform leg, which this API does not itemise. Any shortfall you see between the pool and the two splits below is that platform leg. IT IS NEVER RECOMPUTED FROM `order_value` MULTIPLIED BY A RATE — it is the network's own figure, because the network's own figure is the one that will actually be paid. `null` when no commission has been accrued for the sale yet; that is NOT zero, and it is not the same as a reversal, which nets to `0`."},"computed_owner_split":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"YOUR share of `pool_commission` — the subnetwork owner's leg of the split, denominated in `split_currency`. 'Computed' means resolved once by us from the contract terms in force as of `transaction_date` and then written to an immutable ledger; it is not re-derived on read, so repeating this request cannot change it. INFORMATIONAL ONLY: Affilitera does not disburse this to you — your own network pays you the whole pool, and this figure tells you which part of that payment is yours rather than your sub's. NEGATIVE when a previously accrued sale has since reversed. `null` when the sale has no accrual yet."},"computed_sub_split":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"The AFFILIATE's share of `pool_commission` — the leg owed to the sub named in `sn_sub`, denominated in `split_currency`. Resolved from the contract in force as of `transaction_date` and then fixed. THIS IS THE AMOUNT YOU OWE YOUR SUB, and it is what accumulates into `GET /subnetwork/payouts/claimable`. `0` when the sale attributes to no sub, or when the sub's rate resolves to nothing — in both cases you keep the whole non-platform remainder. NEGATIVE on a reversal. `null` when the sale has no accrual yet."},"split_currency":{"type":"string","nullable":true,"description":"ISO-4217 code governing the three split amounts — `pool_commission`, `computed_owner_split` and `computed_sub_split` — and only those. It is the currency the commission was ACCRUED in, read from the commission ledger rather than from the sale, which is exactly why it is a field of its own and not merged into `currency`: the ledger records what the network actually credited, and that is the figure that gets paid. `null` precisely when all three amounts are null — a sale for which no commission has been accrued yet. WHEN THIS FIELD AND `currency` DISAGREE, THIS ONE GOVERNS THE MONEY; see `currency` for how that transient disagreement arises. Affilitera never converts between currencies, so amounts carrying different codes must never be summed."}}},"SummaryBucket":{"type":"object","description":"One aggregate bucket, keyed by (`group`, `currency`). Amounts in different currencies are NEVER folded together: currency is always part of the group key.","properties":{"group":{"type":"object","description":"The grouping dimensions. `network` and `instance_id` are ALWAYS present; `brand_id`/`brand_public_id`/`brand_name`/`merchant_id`/`merchant_domain`, `sn_sub` (with its decoded companion `sn_sub_dimensions`) and `date` appear per `group_by`. `instance_id` is the real grouping key: `network` is the network FAMILY slug ('awin') and is NOT unique, because one org may hold several connections to the same network. Two such connections emit two buckets that both say network 'awin', so key your rollups on `instance_id`, never on `network`. Same `instance_id` GET /brands and GET /reporting/transactions emit, so the three join. NOTE that `network`/`instance_id` are UNCONDITIONAL dimensions, so a brand bucket is always per-connection: buckets are never summed across your networks server-side. `merchant_domain` is what lets you fold them together yourself, after the fact — it does not change the grain.","required":["network","instance_id"],"additionalProperties":false,"properties":{"network":{"type":"string","nullable":true,"description":"The network FAMILY slug for this bucket, e.g. `awin` — a display LABEL, not a key. It is NOT unique: an owner holding two accounts at the same network produces two buckets that both say `awin`. Key your rollups on `instance_id`. `null` when the connection no longer resolves to a slug."},"instance_id":{"type":"string","description":"OUR uuid for the network CONNECTION this bucket belongs to, and the bucket's real identity. ALWAYS present, whatever `group_by` you asked for, because amounts are never summed across your separate network accounts — those reconcile and get paid separately. The same `instance_id` that `GET /subnetwork/brands` and `GET /subnetwork/reporting/transactions` emit, so all three join on it."},"brand_id":{"type":"string","format":"uuid","nullable":true,"description":"Present only when `group_by` includes `brand`. NULL MEANS NOT APPLICABLE. Buckets are keyed BY this value when `group_by` includes `brand`, so a null here is not a brand to go and look up — there is nothing to resolve it against. When `group_by` omits `brand` the key is absent entirely, together with the whole `brand_public_id`/`brand_name`/`merchant_id`/`merchant_domain` set; absent and null are not different states to a JSON reader, and both mean the same thing: this response is not grouped by brand."},"brand_public_id":{"type":"string","nullable":true,"description":"Present only when `group_by` includes `brand`. NULL MEANS NOT YET RESOLVED, and never 'no such brand': `brand_id` on the same bucket still identifies it. The handle is a cached column and every stored brand currently carries one, so in practice this is null only when the identity lookup did not resolve for this bucket, never because the brand has no handle. Either way the brand stays fully resolvable by `brand_id` and the bucket's money figures are unaffected."},"brand_name":{"type":"string","nullable":true,"description":"Present only when `group_by` includes `brand`. NULL MEANS NOT YET RESOLVED — the brand row behind this bucket was not joined — and NOT that the network published no name: a resolved brand always has one, because the stored name column is NOT NULL. So a null is a missing display label, never a nameless merchant. `brand_id` still identifies the brand and the amounts are unaffected."},"merchant_id":{"type":"string","nullable":true,"description":"Present only when `group_by` includes `brand`. The NETWORK'S own merchant/advertiser id, reported verbatim — the join key for reconciling a bucket against that network's own reporting. Opaque STRING, never parse as a number. Same field, same meaning — including its null semantics — as `merchant_id` on `Brand`: null means the NETWORK supplies no advertiser id of its own, not that we hold one and withheld it."},"merchant_domain":{"type":"string","nullable":true,"description":"Present only when `group_by` includes `brand`. THE ONLY CROSS-INSTANCE KEY IN THIS BUCKET: `instance_id`, `brand_id`, `brand_public_id` and `merchant_id` are all scoped to a SINGLE network connection, so one real merchant sold through two networks is always at least two buckets. Fold them on this field to total a merchant across your networks. IT IS A HEURISTIC, NOT AN ASSERTION OF IDENTITY — a shared domain is EVIDENCE two buckets are the same merchant, not a statement by us that they are, and public-suffix hosts and PBN domains make naive merging unsafe. Folding buckets on it also folds their currencies, which are never cross-summed here — group by currency first. For an assertion rather than evidence, resolve the brand through `GET /subnetwork/brands` and read its `merchant_key`, the handle of the resolved dedup group's canonical brand. Null when the brand has no resolved domain."},"sn_sub":{"type":"string","nullable":true,"description":"Present only when `group_by` includes `sn_sub`. The RAW stored subid, and the grouping key. Unchanged. NULL MEANS NOT APPLICABLE: this bucket holds sales carrying NO affiliate attribution — traffic that arrived with no subid at all. IT IS A REAL BUCKET, NOT A GAP. Dropping it is the mistake to avoid: it is where unattributed earnings accumulate, so a client that filters it out will find the per-sub buckets no longer reconcile against the unfiltered total. Distinct from the key being ABSENT, which means `group_by` does not include `sn_sub`."},"sn_sub_dimensions":{"type":"array","nullable":true,"description":"Present only when `group_by` includes `sn_sub`. The DECODED meaning of `sn_sub` when it is a packed multi-dimension subid; `null` when it is an ordinary raw/manual affiliate id, or does not decode. Same field name, same item shape and the same null semantics as `sn_sub_dimensions` on `GET /subnetwork/reporting/transactions`, so an aggregate bucket and the transactions composing it resolve one raw value identically instead of the aggregate showing an opaque code. STRICTLY ADDITIVE: `sn_sub` above remains the grouping key and still carries the raw value, and this field is a pure function of (`instance_id`, `sn_sub`) — both already in the key — so it can neither split nor merge a bucket and NO total changes. Order within the pack is the order the labels were encoded in; a segment that does not resolve is omitted, so this array may be SHORTER than the pack.","items":{"type":"object","required":["id","context_type","label"],"properties":{"id":{"type":"integer","description":"Dimension id, scoped per (owner, instance) — NOT globally unique, and not comparable across instances."},"context_type":{"type":"string","enum":["property","placement"],"description":"Which of the two owner-defined axes this decoded label belongs to — `property` (which of your sites/apps) or `placement` (where within it). These are the only two values; an affiliate is never a dimension."},"label":{"type":"string","description":"The owner-supplied human label."}}}},"date":{"type":"string","nullable":true,"description":"Present only when `group_by` includes `date`. A day (`YYYY-MM-DD`) or month (`YYYY-MM`) key per `granularity`. NULL IS NOT REACHABLE for a stored sale: the underlying transaction-date column is NOT NULL, so every order yields a day or month key. The axis is declared nullable because the fold accepts a dateless row shape, not because production holds one. A null here would therefore be a defect worth reporting — but it would still be a REAL bucket whose amounts belong in your total, not a malformed row to skip. Distinct from the key being ABSENT, which simply means `group_by` does not include `date`."}}},"currency":{"type":"string","description":"ISO-4217, and PART OF THE BUCKET KEY: every amount in this bucket is denominated in it, and amounts in different currencies are never folded together — one group split across three currencies is three buckets, by design. Affilitera performs no FX conversion, so group by this field before summing anything. NOTE this is the COMMISSION-RESOLVED currency, i.e. the one the commission ledger is denominated in, and not necessarily the raw sale currency that `GET /subnetwork/reporting/transactions` reports as its `currency`. For almost every sale the two are the same value. For a sale a network has restated into another currency and which has not yet been re-resolved, they are not: this endpoint buckets it under the ledger's currency while the transaction row still shows the sale's, so the two endpoints will disagree about that one sale until it re-resolves."},"orders":{"type":"integer","description":"A COUNT, not money — this one stays a JSON number."},"order_value_total":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"The sum of `order_value` across every sale in this bucket, denominated in the bucket's `currency`. GROSS SALE VALUE, NOT a commission and NOT the basis of any split — see `order_value` on `Transaction`. Sales whose order value the network never reported contribute nothing to this sum, so a bucket whose orders all lack an order value totals `0`; read it together with `orders` rather than treating `0` as 'no sales'."},"pool_commission_total":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"The sum of the network-credited commission POOL across every sale in this bucket, denominated in the bucket's `currency`. This is the quantity the splits below are taken from — it is never derived from `order_value_total` and never recomputed from a rate. It includes Affilitera's platform leg, so it is larger than the two split totals added together."},"computed_owner_split_total":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"The sum of YOUR leg of the split across every sale in this bucket, denominated in the bucket's `currency`. Informational: your network pays you the pool, and this is the part of it that is yours rather than your subs'. Reversals are included as negative amounts, so this total is already net of them."},"computed_sub_split_total":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"The sum of your AFFILIATE' legs across every sale in this bucket, denominated in the bucket's `currency` — the total you owe subs for this bucket. Reversals are included as negative amounts, so this total is already net of them. It does NOT tell you what is currently payable; use `GET /subnetwork/payouts/claimable` for that."},"by_status":{"type":"object","description":"The OWNER's split bucketed by order status. These are money and are decimal strings; `reversed` is normally negative.","properties":{"confirmed":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"YOUR split on sales the network has APPROVED — the part of this bucket that is validated and expected to be paid. Denominated in the bucket's `currency`. This is the figure to treat as earned."},"pending":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"YOUR split on sales the network has recorded but NOT yet validated. Denominated in the bucket's `currency`. It is not earned and a portion of it will typically reverse; do not recognise it as revenue."},"reversed":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"YOUR split on sales the network has DECLINED or REJECTED — commission that was accrued and then taken back. Denominated in the bucket's `currency`. NORMALLY NEGATIVE, because it is the net of the original accrual and its reversal; treat a positive value here as a signal to investigate rather than as earnings. It is already included in `computed_owner_split_total`, so adding the two double-counts the reversal."}}}}},"PayoutItem":{"type":"object","description":"One transaction covered by an itemized payout.","properties":{"transaction_id":{"type":"string","nullable":true,"description":"THE NETWORK'S OWN transaction id for the sale this line settles — the same value `GET /subnetwork/reporting/transactions` emits as `transaction_id`, and the join key back to it and to the network's own reporting. We deliberately publish this rather than our internal order id so the line is reconcilable on your side. `null` when we could not resolve our internal order back to a network id (the order row is gone, or the lookup degraded); the line's money is still correct, but that line cannot be joined."},"kind":{"type":"string","enum":["earning","adjustment"],"description":"What this line does. `earning` CLAIMS an order's current net accrual — the normal case. `adjustment` NETS OUT A CLAWBACK on an order that an EARLIER, already-settled payout had paid for: the sale reversed after you paid your sub, and this line recovers it. The sign follows the kind: an `earning` amount is positive, an `adjustment` amount is negative."},"amount":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"The amount this line claims, denominated in the `currency` ON THIS SAME LINE (not necessarily the parent payout's). Claims are ALL-OR-NOTHING: a line always claims a transaction's whole accrued amount, never part of it, so you cannot part-pay a transaction. NEGATIVE for an `adjustment`, which is a recovery of money already paid. `null` only if the stored amount could not be serialised."},"currency":{"type":"string","description":"ISO-4217. Repeated on every line ON PURPOSE — an amount you re-serialise into your own ledger must not travel without it."},"snapshot_seq":{"type":"integer","description":"THE LEDGER SEQUENCE NUMBER THIS CLAIM WAS PINNED TO — the audit anchor that makes the line replayable: replaying the order's accrual up to and including this sequence reproduces `amount` exactly, forever, even after later activity on the same order. THIS IS THE VALUE YOU ECHO BACK when claiming the same transaction in a later `POST /subnetwork/payouts`; sending a stale one is rejected rather than silently re-claiming at a newer amount. An integer."},"voided_at":{"type":"string","format":"date-time","nullable":true,"description":"When this claim was CANCELLED, because its parent payout moved to a failed or cancelled state and the transactions it claimed were released back. `null` — the normal case — means the claim is LIVE and the transaction is still counted as paid. A non-null value means this line no longer settles anything, so exclude it when totalling what you have paid."}}},"Payout":{"type":"object","description":"One payout record you have logged. Affilitera never holds or moves this money.","properties":{"id":{"type":"string","format":"uuid","description":"OUR identifier for this payout record. Ours alone — not a bank reference, not a network id, and not anything the payment provider issued; for your own reference see `reference` and `client_reference`."},"instance_id":{"type":"string","format":"uuid","description":"OUR uuid for the network CONNECTION this payout relates to — which of your network accounts the settled earnings came through. A key scoped to your whole container MUST name it when creating a payout, because earnings from two connections are never pooled."},"sn_sub":{"type":"string","description":"The AFFILIATE this payment was made to, as the owner-chosen identity string. CASE-SENSITIVE and matched verbatim — `John123` and `john123` are two different subs. It must already exist on your roster before you can record a payout against it. ALWAYS PRESENT AND NEVER NULL on this endpoint: every payout readable here is owner-scoped, and a stored constraint forbids such a row from existing without a sub."},"currency":{"type":"string","description":"ISO-4217. The currency of `amount`."},"amount":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"THE MAGNITUDE of the payment, denominated in `currency` on this same row. IT IS ALWAYS STRICTLY POSITIVE, INCLUDING FOR A RECOVERY — the direction of the money is carried by `direction`, NOT by this field's sign. Reading `amount` without reading `direction` will get the sign of your ledger wrong. On an itemized payout you must NOT supply it: it is computed from the lines and a supplied value is rejected."},"status":{"type":"string","description":"The state of this payout record. ON CREATION you may set only `recorded` (the default — you have paid the sub and are recording that fact) or `pending` (you intend to pay); a `pending` row is excluded from `paid` while suppressing the same amount from `claimable`. THIS API EXPOSES NO TRANSITION — there is no PATCH here — BUT THE VALUE IS NOT FROZEN. The owner settles a `pending` row in the Affilitera portal, which advances it `pending` then `sent` then `paid`, or voids it to `canceled`. So a value you never wrote can appear on a later read of a row you created. TREAT THIS FIELD AS READ-ONLY AND RE-READ IT; do not cache the value you created or assume a `pending` row stays that way. Record payments as `recorded` unless you specifically want the hold."},"coverage_mode":{"type":"string","nullable":true,"enum":["cumulative","itemized",null],"description":"Whether this payout STATES WHICH TRANSACTIONS IT SETTLED. `itemized` means `items[]` lists them, each transaction claimed exactly once across all your payouts, and drift on them is tracked afterwards. `cumulative` means a lump sum with no statement of coverage — it reduces the sub's outstanding balance but cannot tell you which sales it covered. BRANCH ON THIS FIELD, NEVER ON `items.length`: an itemized payout can come back with an empty `items[]` if the item read degraded. NULL IS NOT REACHABLE for a stored payout: the column is NOT NULL with a default of `cumulative`, applied to every pre-existing row when itemization landed. It is declared nullable only because the same serializer emits a payout on the 201, the 409 replay and the listing, and one of those can carry no row at all. So a null is a defect worth reporting — and, critically, is NOT a third coverage mode to branch on."},"direction":{"type":"string","nullable":true,"enum":["disbursement","recovery","write_off",null],"description":"`amount` is ALWAYS positive; the direction carries the sign. A `recovery` of 70 is money coming BACK. NULL IS NOT REACHABLE for a stored payout: the column is NOT NULL with a default of `disbursement`. Declared nullable for the same serializer-sharing reason as `coverage_mode`. NEVER treat a null as 'unsigned': `amount` is always positive and the sign lives entirely here, so a direction you cannot read is an amount whose meaning you do not know — refuse it rather than assuming money went out."},"payment_method":{"type":"string","nullable":true,"description":"How you paid the sub, as FREE TEXT that you supply — for example a bank transfer, PayPal or Wise. IT IS NOT AN ENUM and nothing validates or normalises it, so it is only as consistent as your own writes. `null` when you did not state one."},"reference":{"type":"string","nullable":true,"description":"YOUR OWN transfer or invoice reference for the payment, free text, stored verbatim. It is not our id and not the payment provider's — we never generate or interpret it. Use it to tie this record back to your accounting or banking system. `null` when you did not supply one."},"note":{"type":"string","nullable":true,"description":"Free-text note you supplied against the payment. Never interpreted by us. `null` when unset."},"client_reference":{"type":"string","nullable":true,"description":"THE IDEMPOTENCY KEY that was actually stored for this payout. Reusing it on a later create returns the ORIGINAL payout with a 409 instead of paying twice, which is what makes retries safe. When you send both an `Idempotency-Key` header and a body `client_reference`, THE HEADER WINS and is what appears here. `null` when you supplied neither — in which case that create was not idempotent and a retry would duplicate the payment."},"paid_at":{"type":"string","format":"date-time","nullable":true,"description":"WHEN THE PAYMENT WAS CONFIRMED SETTLED. `null` on a payout you have just recorded, and `null` for as long as one sits in `pending`. It is stamped when the owner confirms the settlement batch carrying the row — an action taken in the Affilitera portal, NOT through this API, which exposes no way to set it. A non-null value therefore means the money is confirmed gone and travels with `status: \"paid\"`. Because that confirmation happens elsewhere, this can become non-null on a row you created here: re-read it rather than assuming the value you first saw. Affilitera does not itself move this money."},"created_at":{"type":"string","format":"date-time","description":"When WE WROTE THIS RECORD — not when the money moved, which we do not observe. ISO-8601. It is also the primary sort key of this list (newest first), so it is what the pagination cursor advances through."},"updated_at":{"type":"string","format":"date-time","description":"When this record was last modified. Because nothing in the affiliate payout flow can mutate a row after creation, expect it to equal `created_at`; a difference means the record was changed through another surface."},"items":{"type":"array","items":{"$ref":"#/components/schemas/PayoutItem"},"description":"Empty for a cumulative payout."}}},"EarnedVsPaid":{"type":"object","description":"Earned-vs-paid at the (instance, sn_sub, currency) grain. NEVER rolled up across currencies.","properties":{"instance_id":{"type":"string","format":"uuid","description":"OUR uuid for the network CONNECTION this balance line covers. Balances are never pooled across your connections, so a sub you use on two networks has two lines here that must be settled separately."},"sn_sub":{"type":"string","description":"The AFFILIATE this balance line is for. A sub appears here if it has EITHER earnings OR payouts, so a sub you have paid in advance and that has not yet earned still gets a line."},"currency":{"type":"string","description":"ISO-4217. The currency of every amount in this row."},"earned":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"TOTAL COMMISSION ACCRUED TO THIS SUB, denominated in `currency` on this same line. IT INCLUDES PENDING AS WELL AS APPROVED SALES, so it is NOT the amount you can safely pay — a portion of it may still reverse. For what is actually safe to pay, read `claimable`. Reversals are already netted in, so this figure can fall as well as rise."},"paid":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"TOTAL YOU HAVE ALREADY PAID this sub, denominated in `currency`. SIGNED AND NET OF DIRECTION: a recovery or a write-off SUBTRACTS from it, so this can legitimately be negative if a sub has returned more than they were paid. It counts settled payouts only; a payout you left in `pending` is NOT included here (see `in_flight`)."},"in_flight":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"AMOUNT SITTING IN PAYOUTS YOU MARKED `pending` and that have not settled, denominated in `currency`. For an API caller this is exactly the `pending` rows you created — and which nothing can advance, since Affilitera does not move this money. It does NOT reduce `outstanding`, but it IS removed from `claimable`, so leaving payouts in `pending` will make a sub look unpayable while still showing a balance owed."},"outstanding":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"`earned` MINUS `paid`, denominated in `currency` — the headline balance you owe this sub. SIGNED AND NEVER CLAMPED: a NEGATIVE value means you have OVERPAID the sub and are owed money back. It does NOT subtract `in_flight`, and it INCLUDES pending, not-yet-confirmed earnings — which is why it is usually LARGER than `claimable`. Do not pay against this figure; pay against `claimable`."},"claimable":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"WHAT IS ACTUALLY SAFE TO PAY THIS SUB RIGHT NOW, denominated in `currency`. It differs from `outstanding` by excluding earnings the network has not yet confirmed, and by removing amounts already held in `pending` payouts. THIS IS THE FIGURE TO SETTLE AGAINST. It is recomputed from the ledger on every read rather than stored, so it moves as sales confirm and reverse.\n\nIT CAN BE `null`, AND A NULL IS NEVER `0`. There are exactly two causes and both mean \"we cannot state this figure\", never \"there is nothing to pay\". (1) NO ITEMIZATION GROUP exists for this (`sn_sub`, `currency`) position, so the aggregate this figure is built from returns no row for it. It deliberately does NOT fall back to `outstanding`: that is a different quantity — earned minus paid, INCLUDING commission the network has not yet confirmed — so publishing it under this field's name would overstate what is safe to pay by exactly the unconfirmed amount, and a plausible wrong number here is worse than an absent one because nothing downstream can detect it. (2) THE READ WAS DEGRADED: the aggregate came back short, so the figure is unknowable, and `degraded` is set on the response. Note that `settled_drift` deliberately does NOT go null in case (1) — a drift aggregate over zero settled claims is a genuine, knowable `0`, and nulling it would report a known-nothing as unknown."},"settled_drift":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"VALUE THAT MOVED ON TRANSACTIONS AN ALREADY-SETTLED PAYOUT HAD CLAIMED, denominated in `currency` — the after-the-fact correction on money that has already gone out. NEGATIVE is the common and important case: a sale reversed after you paid your sub, so the sub owes that amount back, and it is netted into `claimable` against their future earnings. SIGNED and never clamped. There is no stored overpayment balance anywhere; this is recomputed from the ledger on every read."}}},"ContractRule":{"type":"object","description":"One condition on a payout group. Returned verbatim from `sn_contract_rules` — the columns below are the whole projection.","properties":{"id":{"type":"string","format":"uuid","description":"OUR identifier for this rule row. Ours alone; of no meaning outside this API."},"group_id":{"type":"string","format":"uuid","description":"The payout group this rule belongs to. Already implied by nesting; returned because the row is passed through unmapped."},"field":{"type":"string","description":"WHICH ATTRIBUTE OF THE ORDER this rule tests. One of: `customer_status` (new versus returning customer, as the network reports it), `promo_code` (the voucher code used), `order_category` (the order's product category), `order_value` (the order total), or `currency` (the order's currency)."},"operator":{"type":"string","description":"HOW `value` IS COMPARED against the order's attribute: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `between` (inclusive of `value` and `value2`), `contains` (substring), `is_null` and `is_not_null` (which test presence and take no operand)."},"value":{"type":"string","nullable":true,"description":"THE OPERAND this rule compares against, ALWAYS SERIALISED AS A STRING even when it is numeric — so an `order_value` threshold arrives as `\"100\"` and must be parsed before any numeric comparison. `null` exactly when `operator` is `is_null` or `is_not_null`, which take no operand. WHEN `field` IS `order_value` THIS IS A MONETARY AMOUNT AND THE RULE ITSELF CARRIES NO CURRENCY: its denomination comes from a SIBLING rule in the same payout group with `field: \"currency\"` and `operator: \"eq\"`, whose `value` is the ISO-4217 code. Read that sibling to denominate this number. Do NOT fall back to the payout group's own `currency`, which denominates `rate_flat` and is unrelated to this threshold, and do NOT assume the order's own currency. A threshold with no such sibling is a MISCONFIGURED group whose amount has no denomination and cannot be interpreted at all — treat it as unusable rather than guessing a currency for it."},"value2":{"type":"string","nullable":true,"description":"The upper bound of a two-sided operator (e.g. a range); null for a one-sided one."}}},"ContractPayoutGroup":{"type":"object","description":"One payout group on a contract, in rank order.","properties":{"rank":{"type":"integer","description":"Ordering within the contract. Groups are returned already sorted by it."},"name":{"type":"string","nullable":true,"description":"A display label for this payout group, set by whoever configured the contract. Presentation only — never match on it, and never treat it as identifying the group. `null` when no label was given."},"rate_kind":{"type":"string","description":"`pct` or `flat`."},"rate_pct":{"type":"number","nullable":true,"description":"Percentage rate. A JSON NUMBER, not a decimal string — a percentage is not a currency-denominated amount, so the money convention does not apply to it. Currency-agnostic, so `currency` is null alongside it."},"rate_flat":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"A FIXED CASH AMOUNT paid per order (or per click or per item, according to the contract's `payout_level`), denominated in `currency` ON THIS SAME GROUP. Meaningful only when `rate_kind` is `flat`; `null` when `rate_kind` is `pct`. Because Affilitera performs no FX conversion, a flat group only applies to orders ALREADY IN ITS OWN CURRENCY — an order in any other currency does not match this group at all, rather than being converted into it."},"currency":{"type":"string","nullable":true,"description":"ISO-4217 code that `rate_flat` is denominated in. ALWAYS non-null when `rate_kind` is `flat` — a database CHECK constraint guarantees it — and always null when `rate_kind` is `pct`. This platform performs no FX conversion, so a flat group only ever applies to orders already in this currency. IT DENOMINATES `rate_flat` AND NOTHING ELSE: it does NOT denominate an `order_value` threshold in this group's `rules`, which takes its denomination from a `currency` rule inside `rules` itself. The two are independent and routinely differ."},"rules":{"type":"array","items":{"$ref":"#/components/schemas/ContractRule"},"description":"The conditions an order must satisfy for this group to apply. ALL of them must hold — they are ANDed, never ORed. Empty when the group is unconditional. WHERE A THRESHOLD'S CURRENCY COMES FROM: a rule comparing `order_value` with `eq`, `neq`, `gt`, `gte`, `lt`, `lte` or `between` is a monetary amount, and the ONLY thing that denominates it is a rule in this same array with `field: \"currency\"` and `operator: \"eq\"`, whose `value` is the ISO-4217 code. One such rule denominates every threshold in the group. If this array holds a threshold and no such rule, the group is misconfigured and its threshold has no denomination — the group's own `currency` does not supply one."}}},"Contract":{"type":"object","description":"One commission contract and its payout terms. Only ACTIVE contracts are returned — the handler filters on `status = 'active'`, so there is no status field to branch on.","properties":{"id":{"type":"string","format":"uuid","description":"OUR identifier for this contract. Ours alone; the network has no knowledge of it."},"instance_id":{"type":"string","format":"uuid","description":"OUR uuid for the network CONNECTION this contract governs. A contract applies to exactly one of your network accounts."},"name":{"type":"string","description":"The contract's label, set by whoever configured it in the Affilitera portal — not by the network and not settable through this API. Display only."},"description":{"type":"string","nullable":true,"description":"Free-text description set by whoever configured the contract. Never interpreted by us. `null` when unset."},"effective_from":{"type":"string","format":"date","description":"A DATE, not a timestamp, and not unique — several contracts routinely share one."},"effective_to":{"type":"string","format":"date","nullable":true,"description":"Null means open-ended."},"payout_level":{"type":"string","enum":["order","click","item"],"description":"WHAT THE RATE IS APPLIED TO: `order` (once per order, on the order total — the normal case), `click` (per click, regardless of any sale), or `item` (per line item within an order). Read this BEFORE interpreting any rate on the contract: the same numeric rate means a completely different amount of money at each level. ALWAYS PRESENT AND NEVER NULL — a contract that states no level is stored as `order`. These three values are the whole domain; it is a stored enumeration, so a fourth cannot appear without a schema change."},"affiliate_scope":{"type":"string","enum":["individual","group","all"],"description":"DERIVED, not a stored column: `individual` when the contract names one affiliate account, `group` when it names a group, `all` otherwise. The underlying `affiliate_account_id`/`affiliate_group_id` are internal and are NOT returned."},"brand_id":{"type":"string","format":"uuid","nullable":true,"description":"The merchant brand this contract scopes to. Same field name, same meaning, as `brand_id` on every other brand-bearing shape. NULL MEANS NOT APPLICABLE, and it is a normal, deliberate state: the contract is NOT scoped to one merchant and applies across the instance. Null is therefore WIDER in effect than any value, which is the opposite of how a null usually reads — do not filter these rows out when working out which terms apply to a sale, or you will drop the contracts that apply to all of them."},"brand_public_id":{"type":"string","nullable":true,"description":"OUR minted, opaque, stable identity for the brand (`brd_…`) — the durable key to join this row back to `GET /subnetwork/brands`, and a value the `brand_id` filter accepts. `brand_id` is the internal uuid and is NOT stable across a network disconnect/reconnect: a re-ingested brand gets a new one, while this survives. `null` only if the brand has no minted handle."},"payout_groups":{"type":"array","items":{"$ref":"#/components/schemas/ContractPayoutGroup"},"description":"The contract's payout groups, in rank order."}}},"SubAffiliate":{"type":"object","description":"One row of the affiliate roster — the shape returned by `GET /subnetwork/sub-affiliates` and by `GET /subnetwork/links` when `sn_sub` is OMITTED.","properties":{"sn_sub":{"type":"string","description":"The affiliate id. This is the identifier every other endpoint's `sn_sub` parameter takes."},"label":{"type":"string","nullable":true,"description":"A DISPLAY NAME for this sub, chosen by you. It is never used as an identity and never appears on the money path — `sn_sub` is the identity. Changing it is safe and affects nothing but presentation. `null` when unset."},"status":{"type":"string","description":"`active` or `observed`. Never null on the wire — a null column is served as `active`. An `observed` sub was seen in incoming traffic but never registered through `POST /sub-affiliates`."},"source":{"type":"string","nullable":true,"description":"HOW THIS ROW CAME TO EXIST: `api` means you created it deliberately (through `POST /subnetwork/sub-affiliates`, or by generating a link for it), and `sync` means WE created it because the sub identity turned up in traffic from a network without you having registered it first. `null` on older rows that predate this field being recorded — which is not a third kind of source."},"registered_at":{"type":"string","format":"date-time","nullable":true,"description":"WHEN THIS SUB MOST RECENTLY BECAME ACTIVE — and on an `observed` sub IT IS NOT A REGISTRATION DATE AT ALL. An observed row is stamped when its identity first arrives in your traffic and RE-STAMPED every time that identity arrives again, so on those rows THIS VALUE MOVES and is in effect the last sighting. Do not read it as when the sub joined, do not use it to age or expire a roster, and do not diff it between reads expecting stability. `created_at` is the stable first-insert time. `null` only on rows old enough to predate this being recorded."},"created_at":{"type":"string","format":"date-time","nullable":true,"description":"When this row was FIRST inserted — the earliest we knew of the sub, whether you registered it or traffic revealed it. ISO-8601. It is also the sort key of this list (newest first) and what the pagination cursor advances through. Distinct from `registered_at`, which moves. NULL WOULD BREAK PAGING, which is why it does not occur: this is the sort key and the value the cursor advances through, so a row with no timestamp has no position in the sequence and could not be returned. Treat a null as a defect to report rather than a row to sort last."},"first_seen_network":{"type":"string","nullable":true,"description":"NULLED OUT rather than returned when the instance it names is outside this key's authorized set — so a null here means either genuinely unknown or not yours to see, and the two are deliberately indistinguishable."},"first_seen_instance_id":{"type":"string","format":"uuid","nullable":true,"description":"Nulled out under the same rule as `first_seen_network`, and always null or non-null together with it."}}},"RegisteredSubAffiliate":{"type":"object","description":"The shape returned by `POST /subnetwork/sub-affiliates` — distinct from `SubAffiliate` (the roster row `GET` returns): it carries `created`/`kind` instead of `created_at`/`first_seen_network`/`first_seen_instance_id`.","properties":{"sn_sub":{"type":"string","description":"The affiliate identity as STORED — echoed back so you can confirm exactly what was persisted. Surrounding whitespace is trimmed from your input, but the value is otherwise kept verbatim and is CASE-SENSITIVE. This is the value every other endpoint's `sn_sub` parameter takes."},"label":{"type":"string","nullable":true,"description":"The display name now stored against this sub. IMPORTANT: IF THE SUB WAS ALREADY REGISTERED, THE EXISTING LABEL IS RETURNED AND THE ONE YOU SENT IS NOT APPLIED — this endpoint registers, it does not rename. Check this value against what you sent if you were expecting a rename. `null` when no label is stored."},"status":{"type":"string","description":"`active` on both a fresh registration and a re-registration/promotion."},"source":{"type":"string","nullable":true,"description":"How the stored row came to exist: `api` (registered deliberately) or `sync` (created by us from observed network traffic). On a fresh registration or a promotion this is `api`. ON AN IDEMPOTENT REPEAT OF AN ALREADY-REGISTERED SUB THE PRE-EXISTING VALUE IS RETURNED, so it may be `sync` or `null` even though you just called this endpoint — it describes the row's origin, not this request."},"registered_at":{"type":"string","format":"date-time","nullable":true,"description":"When this sub most recently BECAME ACTIVE — set now if this call created or promoted it. ON AN IDEMPOTENT REPEAT OF AN ALREADY-ACTIVE SUB THE ORIGINAL VALUE IS RETURNED UNCHANGED: this call did not refresh it. ISO-8601. `null` on older rows that predate this field, and in the rare case where the write succeeded but its read-back returned nothing."},"created":{"type":"boolean","description":"`true` only on the 201 (first registration). `false` on the 200 (re-registering an active sub, or promoting an `observed` one)."},"kind":{"type":"string","enum":["main"],"description":"`\"sub\"` is rejected 422 before this response can ever be built — see the request body's `kind` note."}},"required":["sn_sub","label","status","source","registered_at","created","kind"]},"SubTrackedLink":{"type":"object","description":"One persisted tracked link — the shape returned by `GET /subnetwork/links` and `GET /subnetwork/sub-affiliates` when `sn_sub` IS supplied. A different shape from SubAffiliate: the same operation returns one or the other depending on that parameter.","properties":{"sn_sub":{"type":"string","description":"Echoes the requested `sn_sub`; every row on the page carries the same value."},"slug":{"type":"string","description":"Pass this to `GET /subnetwork/links/resolve`."},"url":{"type":"string","description":"The full tracked link, built from `slug`."},"brand_id":{"type":"string","format":"uuid","nullable":true,"description":"OUR internal uuid for the merchant this link points at, scoped to one network connection. NOT STABLE across a network disconnect/reconnect — persist `brand_public_id` instead if you are storing anything against the brand. `null` when the link carries no brand."},"brand_public_id":{"type":"string","nullable":true,"description":"OUR minted, opaque, stable identity for the brand (`brd_…`) — the durable key to join this row back to `GET /subnetwork/brands`, and a value the `brand_id` filter accepts. `brand_id` is the internal uuid and is NOT stable across a network disconnect/reconnect: a re-ingested brand gets a new one, while this survives. `null` only if the brand has no minted handle."},"brand_name":{"type":"string","nullable":true,"description":"The merchant's programme name as the network published it, carried on this row so a link list can name its merchant without a second call per page. Whitespace-trimmed at the response boundary. NULL MEANS NOT YET RESOLVED — the brand row behind this link was not joined on this read — and NOT that the merchant is nameless: a resolved brand always has a name, because the stored column is NOT NULL. The link itself is unaffected and still works; you lose the label, not the destination."},"merchant_id":{"type":"string","nullable":true,"description":"The NETWORK'S own merchant/advertiser id for this brand, reported verbatim at ingestion — for Awin the `advertiserId`, the same value that appears as `awinmid=` in the click URLs we mint. The join key for reconciling this row against that network's own reporting. Opaque STRING, never parse as a number. Same field, same meaning — including its null semantics — as `merchant_id` on `Brand`: null means the NETWORK supplies no advertiser id of its own, not that we hold one and withheld it."},"merchant_domain":{"type":"string","nullable":true,"description":"The merchant's own web domain, e.g. `101blockchains.com`. THE ONLY CROSS-INSTANCE GROUPING KEY ON THIS ROW: `brand_id`, `brand_public_id` and `merchant_id` are all scoped to a SINGLE network connection, so the same real merchant reached through two networks appears as two rows with three different identifiers and nothing linking them. Grouping on this field is how a client rolls those rows up. IT IS A HEURISTIC, NOT AN ASSERTION OF IDENTITY — a shared domain is EVIDENCE that two rows are the same merchant, not a statement by us that they are, and public-suffix hosts and PBN domains make naive merging unsafe. For an assertion rather than evidence, resolve the brand through `GET /subnetwork/brands` and read its `merchant_key`, the handle of the resolved dedup group's canonical brand. Same field, same meaning, as `merchant_domain` on `Brand` and `Promotion`. Null when the brand has no resolved domain."},"destination_url":{"type":"string","nullable":true,"description":"The URL the click ultimately LANDS ON, with the sub identity already baked into the network's own tracking template. This is the end of the redirect chain, shown for transparency — DO NOT PUBLISH IT DIRECTLY: use `url`, which additionally records the click on our side. Publishing this instead silently loses your click data. `null` when none is stored."},"created_at":{"type":"string","format":"date-time","nullable":true,"description":"When this tracked link was MINTED. ISO-8601. It is also the sort key of this list (newest first) and what the pagination cursor advances through. NULL WOULD BREAK PAGING, which is why it does not occur: this is the sort key and the value the cursor advances through, so a link with no mint time has no position in the sequence. Treat a null as a defect to report."}}},"ResolvedLink":{"type":"object","description":"A tracked link's attribution, as returned by `GET /subnetwork/links/resolve`. Note there is NO in-body `request_id` on this endpoint — it is unpaged, and only the paged envelope carries one. Use the `X-Request-ID` header.","properties":{"url":{"type":"string","description":"The canonical tracked-link URL for this slug, rebuilt server-side — not necessarily byte-identical to whatever you passed as `url`."},"slug":{"type":"string","description":"OUR short click identifier for this tracked link, as STORED — echoed back from the record rather than from your input, so it is the canonical form. This is the durable handle for the link: store this rather than `url`."},"sn_sub":{"type":"string","description":"The affiliate this link is attributed to."},"destination_url":{"type":"string","description":"The URL the click ultimately LANDS ON, with the sub identity already baked into the network's tracking template. Shown for transparency; publish `url` instead so the click is recorded."},"brand_id":{"type":"string","format":"uuid","description":"OUR internal uuid for the merchant this link points at, scoped to one network connection. Not stable across a network disconnect/reconnect — persist `brand_public_id` instead."},"brand_public_id":{"type":"string","nullable":true,"description":"OUR minted, opaque, stable identity for the brand (`brd_…`) — the durable key to join this row back to `GET /subnetwork/brands`, and a value the `brand_id` filter accepts. `brand_id` is the internal uuid and is NOT stable across a network disconnect/reconnect: a re-ingested brand gets a new one, while this survives. `null` only if the brand has no minted handle."},"brand_name":{"type":"string","nullable":true,"description":"The merchant's programme name as the NETWORK published it. A display label only: two different merchants can share a name, so never use it as a join or grouping key. NULL MEANS NOT YET RESOLVED — the brand row was not joined on this read — and NOT that the merchant is nameless: a resolved brand always has a name. The resolution itself succeeded regardless; a null costs you the display label and nothing about the link."},"merchant_domain":{"type":"string","nullable":true,"description":"The brand's merchant domain — the same value `GET /subnetwork/brands` emits as `merchant_domain`. Named in full rather than as a bare `domain` because this payload also carries a tracked-link `url` and a `destination_url`, and a bare `domain` would not say whose. NULL MEANS NOT YET DERIVED: we hold no merchant domain for this brand — the network published no usable website and none has been inferred. Real but uncommon — a low single-digit percentage of brands. It is OUR derived value, not the network's, so a null is a gap on our side and may fill in later; never reconstruct one from `brand_name`, and never treat it as an assertion that the merchant has no site."},"merchant_id":{"type":"string","nullable":true,"description":"The NETWORK'S own merchant/advertiser id for this brand, reported verbatim at ingestion — for Awin the `advertiserId`, the same value that appears as `awinmid=` in the click URLs we mint. The join key for reconciling this row against that network's own reporting. Opaque STRING, never parse as a number. Same field, same meaning — including its null semantics — as `merchant_id` on `Brand`: null means the NETWORK supplies no advertiser id of its own, not that we hold one and withheld it."},"network":{"type":"string","nullable":true,"description":"The network FAMILY slug this link runs through, e.g. `awin` — a display label assigned by us, and NOT UNIQUE: two of your accounts at the same network both report it. Use `instance_id` to identify which connection. `null` when the owning connection is not in this key's scope."},"instance_id":{"type":"string","format":"uuid","description":"OUR uuid for the network CONNECTION this link belongs to — the key that tells two of your accounts at the same network apart, and the value that joins this link to `GET /subnetwork/brands` and to reporting."},"link_type":{"type":"string","description":"Always `sub_tracked` — the lookup filters on it, so no other value can be returned."},"created_at":{"type":"string","format":"date-time","description":"When this tracked link was MINTED — not when it was last clicked, which this endpoint does not report. ISO-8601."}}},"Job":{"type":"object","description":"An export job's status, as returned by `GET /subnetwork/jobs/{job_id}`. Unpaged, so there is NO in-body `request_id` — use the `X-Request-ID` header.","properties":{"job_id":{"type":"string","format":"uuid","description":"OUR identifier for this export job — the value to poll `GET /subnetwork/jobs/{job_id}` with, returned when you submitted the export. Ours alone. Another owner's job id returns 404, indistinguishable from one that does not exist."},"status":{"type":"string","description":"`pending`, `running`, `done` or `failed`."},"row_count":{"type":"integer","nullable":true,"description":"Null until the job finishes."},"error_message":{"type":"string","nullable":true,"description":"Non-null only for a `failed` job. Cleared back to null by `POST /jobs/{job_id}/retry`."},"created_at":{"type":"string","format":"date-time","description":"When the job was ENQUEUED. ISO-8601. It is deliberately PRESERVED across an internal re-queue, so a large export that is being processed in several passes keeps its original position in the queue rather than losing its place. It is therefore not a reliable indicator of how long the job has been actively running."},"started_at":{"type":"string","format":"date-time","nullable":true,"description":"Null while `pending`, and reset to null by a retry."},"completed_at":{"type":"string","format":"date-time","nullable":true,"description":"Null until the job finishes, and reset to null by a retry."},"download_url":{"type":"string","description":"PRESENT ONLY when `status` is `done` — the key is ABSENT otherwise, not null. Branch on `status`, not on this field's truthiness."}}},"Brand":{"type":"object","properties":{"brand_id":{"type":"string","format":"uuid","description":"Raw internal id, and the CROSS-RESOURCE JOIN KEY: `GET /subnetwork/products` and `/subnetwork/products/search` filter on this exact value via their `brand_id` parameter, and `/subnetwork/feeds` and `/subnetwork/reporting/transactions` emit it on their own rows. NOT deprecated. That filter also accepts `brand_public_id` (`brd_…`), which is the preferred form. This id is NOT stable across a network disconnect and reconnect, so join on it within a set of responses and store `brand_public_id` as the durable identity."},"brand_public_id":{"type":"string","nullable":true,"description":"OUR minted, opaque, stable identity for this brand (`brd_…`), and the handle every other resource tells you to join back to. One of three distinct identities on this object — see also `merchant_id` (the network's own id for the merchant) and `network_slug` + `instance_id` (which network, and which connection to it). It survives a network disconnect and reconnect, which `brand_id` does not. NULL MEANS NOT YET MINTED — a small number of older brand rows predate the minting of public ids and have not been backfilled. It is never null for a brand ingested today. Fall back to `brand_id` for the current response only; do not persist that fallback, because `brand_id` is not durable."},"merchant_id":{"type":"string","nullable":true,"description":"The NETWORK'S own merchant/advertiser id, as supplied by the network at ingestion — for Awin this is the `advertiserId`, the same value that appears as `awinmid=` in the click URLs we mint. This is the join key for reconciling against the network's own reporting. Opaque STRING, never parse as a number: networks are not required to issue numeric ids. NULL MEANS NOT SUPPLIED BY THE NETWORK — some networks publish no advertiser id of their own on the catalogue endpoint we read. It is not an id we hold and withheld, and it will not appear later for the same brand unless the network starts publishing it. When it is null you cannot reconcile this brand against that network's reporting by id; use `merchant_domain` as evidence instead, bearing its caveats."},"brand_name":{"type":"string","description":"The merchant's programme name as the network published it. Whitespace-trimmed at the response boundary."},"logo_url":{"type":"string","nullable":true,"description":"URL of the merchant's logo asset as published by the NETWORK. Hotlink or re-host at your discretion; we do not proxy, resize or guarantee its availability. `null` when the network published none."},"categories":{"type":"array","nullable":true,"items":{"type":"string"},"description":"The NETWORK'S OWN sector/vertical labels FOR THE MERCHANT, passed through verbatim. THIS IS NOT A TAXONOMY and is not comparable across networks: each connector writes whatever vocabulary its network uses (Awin a sector name, Admitad category names, Linkbux a comma-split string, Impact a type, Partnerize a vertical), and where Awin publishes no sector name the entry is a BARE NUMERIC CATEGORY ID rendered as a string. Roughly eight connectors never populate it at all. CRUCIALLY, THIS IS A DIFFERENT VOCABULARY FROM THE `?category=` FILTER: that filter resolves against our internal canonical taxonomy, which is never emitted, so filtering by a category does NOT mean the returned `categories` array will contain that category — the two will frequently disagree, and neither is wrong. Treat this as a display hint, never as a key or a filter input. `null` when the connector wrote nothing."},"merchant_country":{"type":"string","nullable":true,"description":"The merchant's country as the NETWORK states it, verbatim — the country of the merchant/programme, not of the shopper and not of the currency. ISO-3166 alpha-2 in practice (the `?country=` filter upper-cases before comparing), but it is not validated, so treat an unexpected token as unknown rather than erroring. COVERAGE IS EXTREMELY UNEVEN by network and `null` is the common case on several of them — a `null` here means the network never told us, never that the merchant is international."},"merchant_domain":{"type":"string","nullable":true,"description":"The merchant's own web domain, e.g. `example.com` — a bare host, no scheme and no path. Resolved by US at ingestion, not supplied by the network. Two uses: it is the exact value `POST /subnetwork/links/by-domain` accepts, and it is THE ONLY CROSS-CONNECTION GROUPING KEY on this row — `brand_id`, `brand_public_id`, `merchant_id` and `instance_id` are every one of them scoped to a single network connection, so the same real merchant reached through two networks appears as two unrelated brands. IT IS A HEURISTIC, NOT AN ASSERTION OF IDENTITY: a shared domain is EVIDENCE two rows are the same merchant, not a statement by us that they are, and public-suffix and multi-brand hosts make naive merging unsafe. When you need our actual assertion of merchant identity, use `merchant_key`. `null` when no domain has been resolved for the brand."},"merchant_domains":{"type":"array","items":{"type":"string"},"description":"ADDITIONAL domains this merchant trades on, EXCLUDING `merchant_domain` itself — concatenate the two for the merchant's complete domain set. Always an ARRAY, never `null`: `[]` is a complete answer meaning the merchant has no others, so you may iterate unconditionally. A brand awaiting re-sync ALSO reads `[]`, so this field does NOT distinguish \"none\" from \"not recomputed\" — the wire shape must not change under a caller while the roster back-fills. THESE ARE FACTS ABOUT THE MERCHANT, NOT ALTERNATIVE KEYS: `merchant_domain` remains the single canonical domain, the only one used for deduplication, and the only one the `min_traffic` and `category` filters resolve through."},"merchant_key":{"type":"string","nullable":true,"description":"OUR ASSERTION OF MERCHANT IDENTITY — the one field on this API that states two brand rows are the same real merchant, as opposed to the `merchant_domain` heuristic that only offers evidence of it. It is the public handle (`brd_…`) of the CANONICAL BRAND of a RESOLVED merchant-dedup group, so two rows carrying the same `merchant_key` are the same merchant BECAUSE THE DEDUP MACHINERY RESOLVED THEM SO. Use it to roll one merchant's rows up across your network connections, which no other identifier on this row permits: `brand_id`, `brand_public_id`, `merchant_id` and `instance_id` are every one of them scoped to a single connection. EXPECT NULL FOR MOST BRANDS, AND DO NOT READ THAT AS AN ERROR: it is emitted ONLY once the brand's merchant group is resolved and carries a canonical anchor, which is true of a small minority of the roster, and the share grows as groups resolve. NULL IS DELIBERATE AND IS NOT A FALLBACK — never substitute `merchant_domain` when this is null. An unresolved group's canonical brand is exactly the thing still being decided, so publishing a key for it would hand you an identity that silently becomes a DIFFERENT identity once the group resolves, with no way for you to learn it moved. Withholding is reversible; a key that re-points is not. It is also null if the identity lookup itself could not be completed, which is indistinguishable on the wire from an unresolved group and is the honest degradation: a read we could not finish must never be reported as \"this merchant is definitively X\"."},"monthly_visits":{"type":"integer","nullable":true,"description":"Estimated total monthly visits to `merchant_domain`, from a THIRD-PARTY traffic provider — neither ours nor the network's, and ingested on its own schedule, so it lags and is an estimate rather than a measurement. A sizing signal for prioritising merchants, not a metric to report as fact. It is what `?min_traffic=` filters on. `null` when the brand has no `merchant_domain`, when the provider has no row for that domain, or when the row carries no figure — none of which means low traffic."},"product_count":{"type":"integer","description":"The count the upstream NETWORK advertises. See `has_products` for what we actually hold."},"has_products":{"type":"boolean","description":"True iff we hold at least one ingested product for this brand. Authoritative."},"commission_summary":{"type":"string","nullable":true,"description":"The network's own headline commission line for the programme, as a HUMAN-READABLE STRING exactly as published — e.g. `Up to 8%`. A display string only: it is NOT PARSEABLE, carries no currency, and cannot express a multi-group rate card. Use `default_rate` and `groups[]` for anything you intend to compute with. Not every connector writes it. `null` when the network published none."},"relationship_status":{"type":"string","nullable":true,"description":"WHETHER THE NETWORK HAS APPROVED YOU AS A PUBLISHER FOR THIS MERCHANT — the field that decides whether a link you mint for this brand can ever be paid. Derived by us, normalised from each network's own vocabulary onto: `approved` (you may promote and be paid), `pending` (application submitted, not yet decided), `declined` (application refused), `removed` (you were removed from the programme), `ended` (the programme itself has ended), `none` (no relationship exists), `unknown` (the network gave a value we could not map). `null` is DISTINCT from `unknown`: it means the relationship has never been evaluated for this brand, not that the answer is unclear. NOTE THE ASYMMETRY ACROSS ENDPOINTS: this endpoint does not filter on it BY DEFAULT, so brands you cannot yet earn from are listed here unless you narrow with the `relationship_status` query parameter; `/subnetwork/coupons`, `/offers` and `/creatives` fail closed and return content only for `approved` brands. An empty page on those endpoints therefore often means 'not approved', not 'nothing exists'."},"relationship_status_raw":{"type":"string","nullable":true,"description":"The NETWORK'S OWN word for your partnership state, verbatim — never normalised, case-folded or trimmed (\"No Relationship\", \"temp-decline\", \"Expired\"). Read this when a network's semantics do not map cleanly onto the seven normalised tokens in `relationship_status`; FILTER on `relationship_status`, INTERPRET with this. IT MAY DISAGREE WITH `relationship_status` FOR ONE SYNC CYCLE, BY DESIGN: this is an OBSERVATION of what the payload said, while `relationship_status` is a CONCLUSION held back by flap hysteresis until the same new state is seen twice, so one noisy reading cannot commit a transition. It is also recorded when the token is one our mapping does not recognise — which is exactly when `relationship_status` does NOT move, and exactly when this field is the only information available. If the two disagree, this is the more recent observation and `relationship_status` is the more stable claim. CHECK `relationship_status_source` BEFORE ATTRIBUTING THIS VALUE TO THE NETWORK. Null when the payload carried no relationship signal, and permanently null for connectors that never supply one. Null exactly when `relationship_status_source` and `relationship_status_raw_field` are null."},"relationship_status_source":{"type":"string","nullable":true,"enum":["network","affilitera_request_scope"],"description":"PROVENANCE of `relationship_status_raw`, and you MUST branch on it before presenting that value as the network's word. `network` means the vendor put that value in its payload. `affilitera_request_scope` means WE derived it: some networks — Awin and Everflow — publish NO relationship field at all, so the value is the endpoint scope we requested, not anything the network said about this programme. \"Awin told us pending\" and \"we asked Awin's pending list and this brand was in it\" are different claims, and only the first says anything about what the network would call your relationship. Reporting the second as the first would be inventing a fact, so it is disclosed here instead. Null exactly when `relationship_status_raw` is null."},"relationship_status_raw_field":{"type":"string","nullable":true,"description":"The key `relationship_status_raw` was read from — `ContractStatus`, `relationship-status`, `connected`, `campaign_status`, or a dotted path such as `membership.status` for a nested read. Carried because the per-network vocabularies OVERLAP TEXTUALLY BUT NOT SEMANTICALLY: `active` from Impact's `ContractStatus` and `active` from Rakuten's `membership.status` are the same string from different fields under different rules, so a consumer building its own mapping cannot safely key on the value alone. Null exactly when `relationship_status_raw` is null."},"network":{"type":"string","nullable":true,"description":"Connector slug of the network this brand came from, e.g. `awin`. Null for granted-main brands — use `network_slug`, which is populated for those too. May also be an EMPTY STRING rather than null on an older connection record; treat empty as unknown, exactly as you would null."},"network_slug":{"type":"string","nullable":true,"description":"Stable identity of the NETWORK TYPE, e.g. `awin`. Distinct from `instance_id`: an org may hold several connections to the same network, and all of them share this slug. Populated for granted-main brands, unlike `network`. NULL MEANS NOT RESOLVABLE — the brand's connection could not be resolved to a known network type at read time, which is an internal inconsistency rather than a statement about the merchant. Treat it as unknown, not as “no network”."},"instance_id":{"type":"string","format":"uuid","nullable":true,"description":"Identifies ONE connection to a network — the org's specific Awin account, not Awin. Null for granted-main brands, which are read-only and belong to no instance of the caller's. All 44 connectors are multi-instance, so this and `network_slug` are genuinely different identities."},"instance_public_id":{"type":"string","nullable":true,"example":"inst_9ViVV9aGEXa","description":"The instance's OPAQUE PUBLIC handle (`inst_...`) — the stable identifier to key your own records on. DERIVED from `instance_id` and never stored, so it is stable for the life of the connection and byte-identical on every endpoint that emits it: `GET /instance`, `/brands`, `/products`, `/feeds`, `/feeds/{feed_id}/products`, `/coupons`, `/offers`, `/reporting/transactions` and `/reporting/summary`. That is what lets you join across all of them without ever handling the internal uuid. NULLABLE HERE, AND THE NULL IS LOAD-BEARING. It is null on a granted-main brand, exactly as `instance_id` is. A granted-main row belongs to ANOTHER org, and this handle is a pure function of that org's connection id, so emitting it would hand a read-only grantee a stable identifier for a connection that is not theirs. The key is always PRESENT with a null value, never absent, so you can tell 'not yours' from 'this deployment predates the field'."},"sub_linkable":{"type":"boolean","description":"Whether you can mint SUB-ATTRIBUTED (Type-2) links for this brand. `true` for your own connected inventory. `false` for inventory granted to you from another owner's main network: that is read-only, attributes under the granting owner's Type-1 relationship, and sub-level attribution is not available for it. Check this before offering a brand to your subs — a false here means their traffic cannot be separately credited."},"currency":{"type":"string","nullable":true,"description":"ISO-4217 code for the SINGLE currency this merchant's programme pays in overall. Network-reported where the network states one; where a network's payload never states it, we may establish it from that network account's own terms rather than leave it unknown — but never by guessing from country or any other proxy, and never by substituting a platform default. NULL MEANS NOBODY HAS ESTABLISHED ONE — never a placeholder. THIS DOES NOT SAY WHETHER ANY SPECIFIC RATE VALUE IS MONEY: it states what the programme pays in as a whole, not whether `default_rate.value` or a `groups[].value` is an amount — `type` is the only field that says that, and a percentage or share rate has nothing of its own to denominate even though the programme paying it still has a currency. DISAMBIGUATION — three fields on this response can carry a currency and they answer different questions, and are allowed to disagree: THIS one is the whole programme's currency, unconditional. `default_rate.currency` / `groups[].currency` is that ONE rate's own stated denomination — null on every percentage/range/share rate, populated only on the monetary types — and may legitimately differ from this field. `default_rate.programme_currency` / `groups[].programme_currency` report the same programme-level fact as this field, scoped to one rate, but prefer that rate's own denomination first when it states one more specific than the programme as a whole. If you want the single fact \"what currency does this merchant pay in\", read THIS field."},"currency_symbol":{"type":"string","nullable":true,"description":"A display symbol for `currency` (`$`, `£`, …), ASSIGNED BY US from a small lookup table purely so you need not carry one. Presentation only — never parse it, never treat it as identifying a currency (several currencies share `$`), and always key logic on `currency`. `null` when `currency` is null, or when the code is not in our table; we return null rather than guessing a symbol."},"default_rate":{"allOf":[{"$ref":"#/components/schemas/Rate"}],"nullable":true,"description":"The brand's headline rate. `value` is a DECIMAL STRING — see the Rate schema for what it means per `type`. NULL MEANS ONE OF EXACTLY TWO THINGS, distinguished by `groups[]`: NO RATE CARD AT ALL when `groups[]` is empty — up to three different facts, RELATIONSHIP-GATED (the network publishes rate terms only to joined partners, so it resolves if you join), NOT INGESTED BY US (our gap, and joining will not change it), or a GENUINE PER-BRAND ABSENCE (ingested, approved, and the vendor still published nothing for this one brand). Tell them apart with `relationship_status`, which is on this same object: if it is NOT `approved`, the network is gating the rate card behind the partnership; if it IS `approved` and the rate card is still null, that is our own gap only when it holds for EVERY brand on that network, and otherwise it is a genuine per-brand absence. See `default_rate_basis` for the full account. or NO DETERMINABLE DEFAULT when `groups[]` is non-empty, meaning the brand publishes several rate groups and none is labelled as the default, so we decline to choose. `default_rate_basis` is null in both cases and does not discriminate between them. A COMMISSION-READ FAILURE IS NOT DISTINGUISHABLE FROM AN ABSENT RATE CARD ON THIS RESPONSE. When the enrichment read behind the rate card fails, we log it server-side and still serve the page, with `default_rate`, `default_rate_basis` and `currency` null, `groups[]` empty and `groups_total` 0 — byte-identical to a brand that genuinely has no rate card. The response carries no signal telling the two apart, so a rate card you have seen before can be absent on a later page without anything in the body saying why. Quote `request_id` to us and we can recover which occurred from the server log; from the wire alone you cannot."},"default_rate_basis":{"type":"string","nullable":true,"description":"HOW `default_rate` was chosen, so you can judge how much to trust it: `labelled_default` means the network itself labelled that group as the default, and `sole_group` means the brand publishes exactly one rate group so there was nothing to choose between. WE NEVER INFER A DEFAULT — picking the highest or the first would be inventing a commercial fact. NULL MEANS ONE OF EXACTLY TWO THINGS, and this field alone cannot tell you which — read `groups[]`: NOT APPLICABLE when `groups[]` is non-empty, i.e. the brand has several groups and none is labelled default, so no basis exists to report (read `groups[]` yourself and choose); or NO RATE CARD AT ALL when `groups[]` is empty. THAT SECOND CASE IS ITSELF UP TO THREE DIFFERENT FACTS, and they need different actions from you, so do not collapse them. (a) RELATIONSHIP-GATED — the network publishes rate terms only for programmes you have JOINED. Awin is the documented case: its commission-groups endpoint is relationship-gated and refuses a programme you have not joined, so every Awin brand at `relationship_status` `pending` reports null here. Nothing was dropped and no rate card exists to fetch. THIS RESOLVES IF YOU JOIN. Rakuten belongs here too, EMPIRICALLY rather than by published spec — of every Rakuten brand asked so far, 0 at a non-`approved` `relationship_status` have ever returned a rate, against roughly 93% of asked `approved` brands that did. (b) NOT INGESTED BY US — we do not yet read commission data for that network at all, so a null here is OUR gap and not the network's silence — INCLUDING THE ONES YOU HAVE ALREADY JOINED, where joining changes nothing about our own code coverage. WHERE THIS CAUSE APPLIES, JOINING WILL NOT CHANGE IT, and re-applying to such a brand is wasted effort. Until recently this field named Rakuten as its standing example of (b); it no longer does. Rakuten's rate card is now read from `/v1/offers`, one advertiser at a time, and stored whenever the read succeeds, so (b) is no longer true of any Rakuten brand — see (a) and (c). (c) GENUINE PER-BRAND ABSENCE — the network's commission data IS ingested and you ARE approved for this specific brand, but the vendor published no usable rate for it. This is a real, per-brand fact, not a gate and not our gap: Rakuten's `/v1/offers` returns exactly this for a real fraction of brands we are approved with (roughly 7% of asked `approved` Rakuten brands at last measurement). Tell the three apart with `relationship_status`, which is on this same object, plus one further fact this response alone cannot give you: if `relationship_status` is NOT `approved`, (a) is the live possibility; if it IS `approved`, the null is (b) only when EVERY brand on that network is null regardless of approval — a fact about our own coverage, not about this one row — and otherwise it is (c). This is NOT a general rule about affiliate networks: Linkbux, Partnerboost and Admitad all publish full rate cards for programmes you have not joined — Linkbux prices 100% of its Rejected and No-Relationship programmes — so on those a null means (b), (c) or a genuine per-brand absence, never (a). The variable is the network's API and our own coverage of it, not a fixed rule per network."},"groups":{"type":"array","items":{"$ref":"#/components/schemas/Rate"},"description":"THE BRAND'S FULL RATE CARD — every commission group the network publishes for the programme, each as a `Rate`. This, not `commission_summary`, is what you compute with. Sorted default-first and then by label. Each entry carries its own `type` and `currency`, and entries within one brand may legitimately differ in both, so never compare two `value`s without first comparing their `type`s. Empty array (never null) when the network publishes no rate detail — OR when the read that populates it failed, which is not distinguishable here; see `default_rate`. An empty array is not a claim that commission is zero. May be truncated — check `groups_truncated`."},"groups_total":{"type":"integer","description":"How many rate groups this brand has, counted before any truncation — compare against the length of `groups[]` to see whether you are looking at the whole rate card. IT IS DERIVED FROM THE SAME READ AS `groups[]`, so it is `0` both for a brand that genuinely has no rate card and for one whose rate-card read failed; a `0` here is not independent confirmation that none exists. See `default_rate`."},"groups_truncated":{"type":"boolean","description":"`true` when the brand has more rate groups than we inline in a single response and `groups[]` therefore holds only the first page of them; `groups_total` tells you how many exist. A handful of brands publish rate cards in the thousands. When this is `true`, do not treat the absence of a group from `groups[]` as evidence that the group does not exist."}}},"BrandDomain":{"type":"object","description":"One linkable merchant domain — the row shape returned by `GET /subnetwork/links/domains`. This row is BRAND-shaped, so it uses the brand field names: `brand_name` and `merchant_domain` were renamed from `name` and `domain` on 2026-08-14, and `merchant_id` was added. Until then this row was undocumented (`additionalProperties: true`), which is part of how the divergence went unnoticed.","properties":{"brand_id":{"type":"string","format":"uuid","description":"OUR internal uuid for the brand — the same value `GET /subnetwork/brands` emits as `brand_id`. Scoped to one network connection and NOT stable across a disconnect/reconnect; persist `brand_public_id` instead."},"brand_public_id":{"type":"string","nullable":true,"description":"OUR minted, opaque, stable identity for the brand (`brd_…`) — the durable key to join this row back to `GET /subnetwork/brands`, and a value the `brand_id` filter accepts. `brand_id` is the internal uuid and is NOT stable across a network disconnect/reconnect: a re-ingested brand gets a new one, while this survives. `null` only if the brand has no minted handle."},"brand_name":{"type":"string","nullable":true,"description":"The merchant's programme name as the network published it. Whitespace-trimmed at the response boundary. NULL MEANS NOT YET RESOLVED — the brand row was not joined on this read — and NOT that the merchant is nameless: a resolved brand always has a name, because the stored column is NOT NULL. The domain on this row is unaffected."},"merchant_domain":{"type":"string","nullable":true,"description":"The merchant's bare domain — the value `POST /subnetwork/links/by-domain` takes as `domain`, and the same value `GET /subnetwork/brands` emits as `merchant_domain`. NULL MEANS NOT YET RESOLVED — the domain is derived BY US from what the network supplies, and for some brands the network supplies nothing we can resolve one from. It is not a claim that the merchant has no website, and it may become non-null later without the merchant having changed."},"alternate_domains":{"type":"array","items":{"type":"string"},"description":"The OTHER domains this same advertiser accepts, EXCLUDING `merchant_domain` itself — concatenate the two for the complete set. WHY IT IS LOAD-BEARING: `POST /subnetwork/links/by-domain` resolves EVERY domain in this array, not only `merchant_domain`, so without this field that endpoint's accepted input is not derivable from the spec. Always an ARRAY, never `null` — `[]` is the true and complete answer for an advertiser with no alternates. TWO CASES ALSO YIELD `[]` OR A SHORT LIST AND ARE NOT DISTINGUISHABLE FROM \"none\" ON THE WIRE: an alternate-domain lookup that failed (logged server-side, deliberately not fatal, because failing the page over an additive field is worse), and a page whose alternates hit an internal fetch cap, which can leave brands late in the page reporting FEWER alternates than they hold. Treat a short list as a floor, not a guarantee of completeness. `merchant_domain` remains the single canonical/display domain and the only deduplication key; these are additional facts about the advertiser, NOT alternative keys."},"merchant_id":{"type":"string","nullable":true,"description":"The NETWORK'S own merchant/advertiser id for this brand, reported verbatim at ingestion — for Awin the `advertiserId`, the same value that appears as `awinmid=` in the click URLs we mint. The join key for reconciling this row against that network's own reporting. Opaque STRING, never parse as a number. Same field, same meaning — including its null semantics — as `merchant_id` on `Brand`: null means the NETWORK supplies no advertiser id of its own, not that we hold one and withheld it."},"network":{"type":"string","nullable":true,"description":"The network FAMILY slug this brand is reached through, e.g. `awin` — a display label assigned by us, not unique, and not a key. The same field other schemas on this API call `network_slug`. `null` when the connection does not resolve to a slug."},"instance_id":{"type":"string","format":"uuid","description":"OUR uuid for the network CONNECTION this row belongs to. It is why the same domain legitimately appears MORE THAN ONCE in this list: one merchant reachable through two of your networks is two rows, deliberately not collapsed, because they are two separate commercial relationships that pay separately."}}},"Product":{"type":"object","properties":{"product_id":{"type":"string","format":"uuid","description":"OUR internal uuid for this product row. Ours alone — it is not the network's product id (that is `external_id`) and not the merchant's SKU (`sku`). Not stable across a network disconnect/reconnect, which re-ingests the catalogue."},"feed_id":{"type":"string","format":"uuid","nullable":true,"description":"OUR uuid for the ingested product feed that carried this row — the path segment for `GET /subnetwork/feeds/{feed_id}/products` and the value `?feed_id=` accepts. `null` when the row's feed no longer resolves. On the feed-scoped endpoint it is constant for the whole page and never null."},"brand_id":{"type":"string","format":"uuid","nullable":true,"description":"OUR internal uuid for the MERCHANT selling this product, scoped to one network connection. Not stable across a disconnect/reconnect — persist `brand_public_id`. `null` when the product's feed did not resolve to a brand."},"brand_public_id":{"type":"string","nullable":true,"description":"OUR minted, opaque, stable identity for the brand (`brd_…`) — the durable key to join this row back to `GET /subnetwork/brands`, and a value the `brand_id` filter accepts. `brand_id` is the internal uuid and is NOT stable across a network disconnect/reconnect: a re-ingested brand gets a new one, while this survives. `null` only if the brand has no minted handle."},"brand_name":{"type":"string","nullable":true,"description":"The BRAND's name — the merchant you work with. Distinct from `name` below, which is the PRODUCT's, and which is exactly why the brand's is not called `name` here. NULL MEANS NOT YET RESOLVED — the merchant brand behind this product was not joined on this read — and NOT that the merchant is nameless: a resolved brand always has a name, because the stored column is NOT NULL. `brand_id` still identifies it. Never fall back to `manufacturer` to fill this in: that is the PRODUCT's brand (\"Nike\"), not the merchant you work with (\"JD Sports\"), and substituting one for the other is the exact conflation these two field names exist to prevent."},"merchant_id":{"type":"string","nullable":true,"description":"The NETWORK'S own merchant/advertiser id for this brand, as supplied by the network at ingestion — for Awin the `advertiserId`, the same value that appears as `awinmid=` in the click URLs we mint. This is the join key for reconciling against the network's own reporting. Opaque STRING, never parse as a number. Same field, same meaning — including its null semantics — as `merchant_id` on `Brand`: null means the NETWORK supplies no advertiser id of its own, not that we hold one and withheld it. Note this is the BRAND's network id; `external_id` below is the PRODUCT's, from a different table."},"external_id":{"type":"string","nullable":true,"description":"The PRODUCT's own id at the network — not the brand's. See `merchant_id` above for the brand's. Opaque STRING; unique within one network connection. NULL IS NOT REACHABLE FOR A STORED PRODUCT: the underlying column is NOT NULL and no sampled row carries one. It is declared nullable so that `/products`, `/products/search` and `/feeds/{feed_id}/products` share ONE row schema rather than three that drift; treat a null, if you ever see one, as a defect worth reporting and not as a network that withheld its id."},"name":{"type":"string","description":"The PRODUCT's name. The brand's is `brand_name` above."},"price":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"THE CURRENT SELLING PRICE the feed states for this product, denominated in `currency`. Network-supplied and re-serialised by us as an exact decimal string; we neither convert nor re-round it. `null` when the feed carried no price, or carried one we could not parse as a plain decimal (`$5` and `1,234.00` are rejected rather than mangled). NEVER `0` for an absent price — a zero here is a real stated price of zero."},"currency":{"type":"string","nullable":true,"description":"ISO-4217, and it denominates `price` on THIS product only — not the brand's commission rate card (see `currency` on the Brand schema, which is a different denomination and may legitimately differ) and not your reporting or payout currency. Network-supplied, never defaulted. NULL MEANS NOT SUPPLIED BY THE FEED — the merchant's product feed carried a price with no currency, or carried no price at all. `price` is then uninterpretable as an amount: do not assume a currency from the merchant's country, and do not compare or sum it against a denominated price."},"image":{"type":"string","nullable":true,"description":"URL of the product's primary image as published in the feed. Further images, when the feed supplies them, are in `additional_images`. `null` when the feed carried none."},"destination_url":{"type":"string","nullable":true,"description":"The merchant URL. NOT a tracking link — mint one via /subnetwork/links. NULL MEANS NOT SUPPLIED BY THE FEED — the merchant published no product URL — so there is nothing to link to and no tracking link can be minted for this row. It is rare in practice — absent from a random production sample entirely. Do NOT substitute the brand's homepage or reconstruct a URL from `external_id`; a fabricated destination sends traffic somewhere the merchant never named."},"network":{"type":"string","nullable":true,"description":"The network FAMILY slug this product came through, e.g. `awin` — a display label, not unique and not a key; use `instance_id` to identify the connection. May be an EMPTY STRING rather than null on an older connection record; treat empty as unknown."},"instance_id":{"type":"string","format":"uuid","nullable":true,"description":"OUR uuid for the network CONNECTION this product was ingested through — the key that tells two of your accounts at the same network apart. `null` for products reached through inventory granted to you by another owner, where the owning connection is deliberately not disclosed."},"instance_public_id":{"type":"string","nullable":true,"example":"inst_9ViVV9aGEXa","description":"The instance's OPAQUE PUBLIC handle (`inst_...`) — the stable identifier to key your own records on. DERIVED from `instance_id` and never stored, so it is stable for the life of the connection and byte-identical on every endpoint that emits it: `GET /instance`, `/brands`, `/products`, `/feeds`, `/feeds/{feed_id}/products`, `/coupons`, `/offers`, `/reporting/transactions` and `/reporting/summary`. That is what lets you join across all of them without ever handling the internal uuid. Null exactly when `instance_id` is null, and for the same reason."},"sku":{"type":"string","nullable":true,"description":"The MERCHANT's own catalogue SKU, distinct from `external_id`, which is the NETWORK's id for the product. NULL ON EVERY NETWORK-SOURCED PRODUCT: only the native merchant-feed ingest path populates it, and no native merchant feed is in production. Join on `external_id`; do not key on `sku`."},"description":{"type":"string","nullable":true,"description":"The product description as the network supplied it. Populated for awin, cj, rakuten and partnerboost, and ALWAYS null for impact products, which supply no description at all. `null` means the network sent none; it never means the empty string."},"short_description":{"type":"string","nullable":true,"description":"A shorter summary, where the network supplies one separately from `description`. Awin-only in practice, and sparser than `description`. `null` when the network sent none."},"category":{"type":"string","nullable":true,"description":"The NETWORK's own PRODUCT category string, in that network's own vocabulary and not normalised across networks. `null` when the feed stated none. THREE UNRELATED VOCABULARIES, DO NOT CONFLATE THEM: this field describes the PRODUCT in the network's words; `Brand.categories` is a separate network-supplied vocabulary describing the MERCHANT; and the `?category=` filter resolves against our canonical taxonomy, which is never emitted anywhere on this API."},"manufacturer":{"type":"string","nullable":true,"description":"The PRODUCT's own brand/manufacturer (\"Nike\"), as the network supplied it. Deliberately NOT called `brand_name`, which on this row is the MERCHANT you work with (\"JD Sports\") — two different things that must never be conflated. NULL MEANS NOT SUPPLIED BY THE FEED: the merchant's feed carried none of the manufacturer columns we read, so there is nothing to report and no later sync will invent one. Roughly one row in thirty-five, sampled. Never fall back to `brand_name` — that is the MERCHANT, a different fact, and the substitution would be undetectable downstream."},"gtin":{"type":"string","nullable":true,"description":"Global Trade Item Number, normalised by us from whichever of `gtin`/`ean`/`upc` the network supplied — all three are GTIN encodings. Opaque STRING, never parse as a number: leading zeros are significant. `null` when the network supplied none."},"mpn":{"type":"string","nullable":true,"description":"Manufacturer Part Number, as the network supplied it. Sparser than `gtin`. A model number is NOT treated as an MPN — they are not the same identifier. `null` when the network supplied none."},"condition":{"type":"string","nullable":true,"description":"The network's own condition string (\"new\", \"refurbished\"), reported verbatim and NOT normalised across networks. `null` when the feed stated none."},"regular_price":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"The pre-discount / RRP price, where the network states one; `price` remains the CURRENT selling price. Both are denominated in `currency`. Null when the network published no reference price — never a copy of `price`, which would fabricate a discount of zero."},"shipping_cost":{"allOf":[{"$ref":"#/components/schemas/MoneyString"}],"description":"Delivery cost where the network states one, denominated in `currency`. Null when unstated — never \"0\", which would assert free delivery."},"shipping_weight":{"type":"string","nullable":true,"description":"The SHIPPING weight as the network stated it, verbatim and NOT normalised — a magnitude only. This is the parcel's weight, never the item's: where a network publishes both (Impact sends `ShippingWeight` and a separate `Weight`) only the shipping figure is reported, because collapsing the two would report one as the other with nothing in the response to say which arrived. MEANINGLESS WITHOUT `shipping_weight_unit`, WHICH IS OFTEN NULL — see that field. Null when the network published no weight."},"shipping_weight_unit":{"type":"string","nullable":true,"description":"The unit `shipping_weight` is denominated in, verbatim (\"g\"). NEVER INFERRED: Awin publishes a weight with no unit key at all, so an Awin product reports a magnitude here as `null`, meaning \"the network gave a number and did not say of what\". Defaulting it would invent the fact, and the plausible candidates differ by a factor of a thousand. Treat a null unit as unknown — do not assume grams or kilograms."},"availability":{"type":"string","nullable":true,"description":"Google Shopping availability vocabulary (\"in stock\"). NULL ON EVERY NETWORK-SOURCED PRODUCT — written only by the native merchant-feed path, which has no products in production. The stock signal the NETWORKS do send is `stock_status`."},"stock_status":{"type":"string","nullable":true,"description":"The network's OWN stock string, reported verbatim and deliberately NOT normalised: awin sends \"1\"/\"0\", impact \"InStock\", cj \"in stock\". Kept separate from `availability` precisely so that one field is not a mixture of vocabularies. Match per network; do not switch on it generically. NULL MEANS NOT SUPPLIED BY THE FEED — the feed carried no stock column at all — and it is common — about a third of rows. CRITICALLY, NULL IS NOT 'OUT OF STOCK'. Treating an unstated stock state as unavailable would hide a third of the catalogue; treating it as available asserts something the merchant never said. It is unknown, and the honest rendering says so."},"additional_images":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Further image URLs beyond `image`, de-duplicated by us. `null` — NOT `[]` — when the network supplied none: an empty array would assert that no further images exist, rather than that none were stated."},"variant_group_id":{"type":"string","nullable":true,"description":"Groups rows that are variants of the same item. VARIANTS ARRIVE AS SEPARATE PRODUCT ROWS, never as a nested list on one product — no network in the catalogue supplies a variant tree — so grouping is the caller's join: rows sharing a non-null value here are variants of one item. Normalised by us from `item_group_id`/`ItemGroupId`/`parent_product_id`/`ParentSku`. Populated for a minority of awin rows and effectively nowhere else; `null` otherwise."},"variant_attributes":{"allOf":[{"$ref":"#/components/schemas/VariantAttributes"}],"description":"The variant-defining attributes of this product (colour, size, material, pattern) as the feed stated them, holding ONLY the keys actually present — there are no null members. Use it together with `variant_group_id` to assemble a variant set: rows sharing a `variant_group_id` are variants of one item, and this object is what distinguishes them. Values are network-verbatim and NOT normalised, so `XL`, `x-large` and `Extra Large` all reach you unchanged and must be matched loosely. `null` — never `{}` — when the feed stated no variant attribute at all."},"status":{"type":"string","enum":["active","delisted"],"description":"`delisted` marks a product the feed STOPPED carrying, whose `destination_url` is expected to 404. Delisted rows are EXCLUDED from every listing unless `include_delisted=true` is passed."},"created_at":{"type":"string","format":"date-time","nullable":true,"description":"When Affilitera first ingested this product. ISO-8601. NULL MEANS NOT RECORDED, which for a product ingested by any current path does not happen — the column carries a default and no sampled row is null. Declared nullable so the three product endpoints share one row schema. It is OUR ingestion time and never the merchant's: a null here says nothing about the product's age."},"updated_at":{"type":"string","format":"date-time","nullable":true,"description":"When this row last changed — the value `modified_since` filters on, carried per row so an incremental sync can resume from the data rather than from the caller's own clock. ISO-8601. NULL MEANS NOT RECORDED, which does not happen for a product ingested by any current path — the column carries a default and no sampled row is null. It matters more than the other timestamps because `modified_since` filters on it: a row with a null here could not be selected by an incremental sync at all, so a null is a defect to report rather than a row to skip."}}},"VariantAttributes":{"type":"object","nullable":true,"description":"What distinguishes ONE variant row from its siblings sharing a `variant_group_id`. Only the attributes the network actually stated are present. The whole object is `null` — NOT `{}` — when the feed said nothing about variants: an empty object would assert \"this product has no variants\", a different claim from \"we were told nothing\".","properties":{"colour":{"type":"string","description":"Normalised from the network's `colour` or `color`."},"size":{"type":"string","description":"The variant's size as the feed stated it, verbatim and NOT normalised — `XL`, `x-large` and `Extra Large` are all possible for the same size, including within one feed. Match loosely; never treat it as an enum."},"material":{"type":"string","description":"The variant's material as the feed stated it, verbatim and not normalised. Free text, arbitrary vocabulary, and frequently a composite such as `80% cotton, 20% elastane`."},"pattern":{"type":"string","description":"The variant's pattern as the feed stated it, verbatim and not normalised. Free text (`striped`, `floral`, `Plain`), with no controlled vocabulary and inconsistent casing."}}},"Feed":{"type":"object","properties":{"feed_id":{"type":"string","format":"uuid","description":"OUR uuid for one ingested product feed — the path segment for `GET /subnetwork/feeds/{feed_id}/products` and the value `?feed_id=` accepts on the product endpoints. Ours alone; the network does not know this id."},"brand_id":{"type":"string","format":"uuid","description":"OUR internal uuid for the merchant this feed belongs to, scoped to one network connection. Not stable across a disconnect/reconnect — persist `brand_public_id`."},"brand_public_id":{"type":"string","nullable":true,"description":"OUR minted, opaque, stable identity for the brand (`brd_…`) — the durable key to join this row back to `GET /subnetwork/brands`, and a value the `brand_id` filter accepts. `brand_id` is the internal uuid and is NOT stable across a network disconnect/reconnect: a re-ingested brand gets a new one, while this survives. `null` only if the brand has no minted handle."},"brand_name":{"type":"string","nullable":true,"description":"The merchant's programme name as the NETWORK published it, whitespace-trimmed by us. A display label: networks rename advertisers and spell the same merchant differently across connections, so never join or group on it. NULL MEANS NOT YET RESOLVED — the brand behind this feed was not joined on this read — and NOT that the feed has no brand: every stored feed carries one, and a resolved brand always has a name. `brand_id` on the same row still identifies it, so a null costs you the label and nothing else."},"merchant_id":{"type":"string","nullable":true,"description":"The NETWORK'S own merchant/advertiser id for this brand, reported verbatim at ingestion — for Awin the `advertiserId`, the same value that appears as `awinmid=` in the click URLs we mint. The join key for reconciling this row against that network's own reporting. Opaque STRING, never parse as a number. Same field, same meaning — including its null semantics — as `merchant_id` on `Brand`: null means the NETWORK supplies no advertiser id of its own, not that we hold one and withheld it."},"product_count":{"type":"integer","description":"The product count recorded on this feed — READ IT AS A HINT, NOT A MEASUREMENT. Its provenance is MIXED and deliberately not reconciled: for most connectors it is the count the NETWORK DECLARED for the feed, for some it is the number of rows WE ACTUALLY INGESTED, and two connectors never write it at all. It can therefore disagree with the number of rows `GET /subnetwork/feeds/{feed_id}/products` returns, in either direction, without anything being wrong. `0` may mean an empty feed, a failed enrichment read, or a connector that simply never populates the column — it is NOT a reliable claim of emptiness. Page the products themselves if you need a true count."},"last_sync_at":{"type":"string","format":"date-time","nullable":true,"description":"When WE last ran a sync pass over this feed — our ingestion clock, not a merchant or network timestamp, and not a statement about when the feed's contents last changed. ISO-8601. `null` when the feed has never been synced."},"last_sync_status":{"type":"string","nullable":true,"description":"The outcome of the last sync pass we ran over this feed: `success` or `error`. These are the only two values ever written. `null` means no pass has completed. A feed that has never reported `success` has never completed a full pass, so its product rows may be partial — treat its `product_count` and its product listing with corresponding caution."},"network":{"type":"string","nullable":true,"description":"The network FAMILY slug this feed came through, e.g. `awin` — a display label, not unique and not a key. May be an EMPTY STRING rather than null on an older connection record; treat empty as unknown."},"instance_id":{"type":"string","format":"uuid","description":"OUR uuid for the network CONNECTION this feed belongs to — the key that distinguishes two of your accounts at the same network. Always present on this endpoint."},"instance_public_id":{"type":"string","example":"inst_9ViVV9aGEXa","description":"The instance's OPAQUE PUBLIC handle (`inst_...`) — the stable identifier to key your own records on. DERIVED from `instance_id` and never stored, so it is stable for the life of the connection and byte-identical on every endpoint that emits it: `GET /instance`, `/brands`, `/products`, `/feeds`, `/feeds/{feed_id}/products`, `/coupons`, `/offers`, `/reporting/transactions` and `/reporting/summary`. That is what lets you join across all of them without ever handling the internal uuid. Always present — a feed is always own-instance."}}},"Creative":{"type":"object","description":"One network-ingested creative (`GET /subnetwork/creatives`) — a banner or a text link the network publishes for a merchant, pulled in by a connector's creative sync. Rows are always own-instance, so `instance_id` is never null here. Merchant-authored assets are a separate, first-party surface and never appear on this endpoint.","properties":{"id":{"type":"string","format":"uuid","description":"Row identifier for this creative, and the field the keyset cursor orders on."},"brand_id":{"type":"string","format":"uuid","description":"The brand this creative belongs to. Joins to `brand_id` on `GET /subnetwork/brands`."},"brand_public_id":{"type":"string","nullable":true,"description":"OUR minted, opaque, stable identity for the brand (`brd_…`) — the durable key to join this row back to `GET /subnetwork/brands`, and a value the `brand_id` filter accepts. `brand_id` is the internal uuid and is NOT stable across a network disconnect/reconnect. NULL MEANS NOT YET RESOLVED, never 'no brand': `brand_id` on the same row still identifies it. The handle is a cached column and every stored brand currently carries one, so in practice this is null only when the identity lookup did not resolve for this page, never because the brand has no handle. It remains fully resolvable by `brand_id`. Nothing else on the creative is affected, `link` included."},"brand_name":{"type":"string","nullable":true,"description":"The merchant's programme name as the NETWORK published it, whitespace-trimmed by us. A display label only — never join or group on it. NULL MEANS NOT YET RESOLVED — the brand row behind this creative was not joined on this read — and NOT that the network published no name: a resolved brand always has one, because the stored name column is NOT NULL. A null is therefore a missing label, never a nameless merchant, and `brand_id` still identifies it."},"merchant_id":{"type":"string","nullable":true,"description":"The NETWORK'S own merchant/advertiser id, as supplied by the network at ingestion. The join key for reconciling against the network's own reporting. Opaque STRING, never parse as a number. NULL MEANS NOT YET RESOLVED, for the same single reason `brand_name` is null on the same row: the brand join did not resolve. It does NOT mean the network withheld its own id — every ingested brand carries one, so a resolved brand always yields this value. If `brand_name`, `merchant_id` and `brand_public_id` are all null together, that is one missing join and not three missing facts."},"merchant_domain":{"type":"string","nullable":true,"description":"The merchant's own web domain. The ONLY field on this row shared across network instances — group on it to roll one real merchant up across two networks, but read it as a HEURISTIC, not as a statement by us that two rows are the same merchant. `null` when the brand has no resolved domain."},"network_slug":{"type":"string","nullable":true,"description":"The connector slug of the network this creative came from (e.g. `tradedoubler`, `impact`). NULL MEANS NOT YET RESOLVED: the connection this creative belongs to could not be mapped to a network type on this read. `instance_id` on the same row is always present and is the key to use — this field is a display label and a family grouping, never an identifier. It is not a vendor-supplied value, so a null here says nothing about the network."},"instance_id":{"type":"string","format":"uuid","description":"The connector connection this creative was ingested from."},"upstream_creative_id":{"type":"string","nullable":true,"description":"The NETWORK'S own id for the creative, stable across re-syncs. The join key against the network's own creative reporting. Opaque STRING. NULL MEANS NOT SUPPLIED BY THE NETWORK: the creative arrived with no id of its own, so there is nothing to reconcile against the network's reporting and no later sync will fill it in. Rare rather than typical — well under one creative in a thousand. Use `id` (ours) to address the row; this field is only for talking to the network."},"kind":{"type":"string","enum":["image_banner","text_link"],"description":"`image_banner` carries `image_url` plus pixel dimensions; `text_link` carries `display_text` and has `image_url: null`."},"name":{"type":"string","description":"The creative's name. Where the network supplies no title, the connector generates a stable label from the kind and the upstream id rather than inventing a marketing name."},"image_url":{"type":"string","nullable":true,"description":"The network's hosted banner image. Serve it from this URL; do not re-host it. Always `null` for `kind: text_link`."},"width":{"type":"integer","nullable":true,"description":"Pixel width. `null` when the network reports no dimensions — never `0`, which would be a false claim about the asset."},"height":{"type":"integer","nullable":true,"description":"Pixel height. `null` when the network reports no dimensions — never `0`."},"display_text":{"type":"string","nullable":true,"description":"Alt text for a banner, or the link text for a text link. NULL MEANS NOT SUPPLIED BY THE NETWORK — no alt text or link text was published for this creative — and is common rather than exceptional, on roughly two creatives in five. Render the creative without it; do NOT substitute `name`, which is our own row label and is not alt text."},"language":{"type":"string","nullable":true,"description":"Upstream-declared creative language where the network exposes one. `null` when it does not — no locale is assumed."},"size_label":{"type":"string","nullable":true,"description":"The network's own name for the size (e.g. `leaderboard`), where it names one instead of, or in addition to, giving pixel dimensions. NULL MEANS NOT SUPPLIED BY THE NETWORK, and today that is EVERY row, because the only two networks currently publishing creatives name no size. Other connectors do map the field, so it is not a dead column, but a client should treat a size label as absent by default and read `width`/`height` instead. Never synthesise one from the pixel dimensions and present it as the network's own."},"upstream_created_at":{"type":"string","format":"date-time","nullable":true,"description":"Creation timestamp as reported by the network, distinct from when our row was written. NULL MEANS NOT SUPPLIED BY THE NETWORK, and today that is EVERY row, because neither network currently publishing creatives reports one. Do NOT fall back to our own ingestion time to fill the gap — that answers a different question (when we first saw it, not when they made it) and would silently date a creative wrong."},"upstream_updated_at":{"type":"string","format":"date-time","nullable":true,"description":"Last-modified timestamp as reported by the network. NULL MEANS NOT SUPPLIED BY THE NETWORK, and today that is EVERY row. It therefore cannot be used as an incremental-sync watermark; a null here is not evidence that a creative is unchanged."},"link":{"type":"string","nullable":true,"description":"The click destination, re-baked with your network-approved affiliate identity. Point the creative's href at THIS. We never expose the network's own pre-wrapped creative redirect, and never the raw landing URL: a creative whose upstream payload offered only a pre-wrapped link is skipped at ingestion rather than stored, so every row served here wraps a real landing URL. `null` only when the connector cannot carry a sub-attributed link at all, in which case `link_kind` is `blocked`."},"link_kind":{"type":"string","description":"WHETHER A LINK COULD BE MINTED FOR THIS CREATIVE. `type2` = a native link was built and is in `link`. `blocked` = this brand's connector cannot mint a sub-attributed link at all, in which case `link` is `null`. Those are the only two values this endpoint emits. `type2` MEANS ONLY THAT A NATIVE LINK COULD BE BUILT — IT DOES NOT GUARANTEE YOUR SUB IDENTITY IS ON IT. Where no sub applies, the connector's sub slot is omitted or its sub parameter is not appended, and the resulting owner-level link is still reported as `type2`. Do not use this field to decide whether a click will be credited to a sub."}}},"Promotion":{"type":"object","description":"One coupon (`GET /subnetwork/coupons`) or offer (`GET /subnetwork/offers`). The two endpoints serve the same shape; a coupon has a non-empty `code`, an offer has `code: null`. Rows are always own-instance — granted-main inventory is out of scope for these endpoints — so `instance_id` is never null here, unlike on `Brand`.","properties":{"id":{"type":"string","format":"uuid","description":"Row identifier for this promotion, and the field the keyset cursor orders on. See contract §10.1 for the identifier field name used by each collection."},"brand_id":{"type":"string","format":"uuid","description":"The brand this promotion belongs to. Joins to `brand_id` on `GET /subnetwork/brands`."},"brand_public_id":{"type":"string","nullable":true,"description":"OUR minted, opaque, stable identity for the brand (`brd_…`) — the durable key to join this row back to `GET /subnetwork/brands`, and a value the `brand_id` filter accepts. `brand_id` is the internal uuid and is NOT stable across a network disconnect/reconnect: a re-ingested brand gets a new one, while this survives. `null` only if the brand has no minted handle."},"brand_name":{"type":"string","nullable":true,"description":"The merchant's programme name as the NETWORK published it, whitespace-trimmed by us. A display label only — never join or group on it. NULL MEANS NOT YET RESOLVED — the brand row behind this promotion was not joined on this read — and NOT that the network published no name: every stored promotion carries a brand, and a resolved brand always has a name. So a null is a missing label, never an unbranded promotion."},"merchant_id":{"type":"string","nullable":true,"description":"The NETWORK'S own merchant/advertiser id, as supplied by the network at ingestion — for Awin the `advertiserId`, the same value that appears as `awinmid=` in the click URLs we mint. This is the join key for reconciling against the network's own reporting. Opaque STRING, never parse as a number. Same field, same meaning — including its null semantics — as `merchant_id` on `Brand`: null means the NETWORK supplies no advertiser id of its own, not that we hold one and withheld it."},"merchant_domain":{"type":"string","nullable":true,"description":"The merchant's own web domain, e.g. `101blockchains.com`. THE ONLY CROSS-INSTANCE GROUPING KEY ON THIS ROW: `brand_id`, `brand_public_id` and `merchant_id` are all scoped to a SINGLE network instance, so the same real merchant reached through two networks appears as two rows with three different identifiers and nothing linking them. Grouping on this field is how a client rolls those rows up. IT IS A HEURISTIC, NOT AN ASSERTION OF IDENTITY — a shared domain is EVIDENCE that two rows are the same merchant, not a statement by us that they are, and public-suffix hosts and PBN domains make naive merging unsafe. For an assertion rather than evidence, resolve the brand through `GET /subnetwork/brands` and read its `merchant_key`, the handle of the resolved dedup group's canonical brand. Same field, same meaning, as `merchant_domain` on `GET /subnetwork/links/domains`. Null when the brand has no resolved domain."},"network_slug":{"type":"string","nullable":true,"description":"Stable identity of the NETWORK TYPE, e.g. `awin`. Distinct from `instance_id`: an org may hold several connections to the same network, and all of them share this slug. Same field, same meaning, as `network_slug` on `Brand`. NULL MEANS NOT YET RESOLVED: the connection behind this promotion could not be mapped to a network type on this read. `instance_id` is always present and is the key; this is a label and a family grouping. Not vendor-supplied, so a null says nothing about the network."},"network":{"type":"string","nullable":true,"deprecated":true,"description":"DEPRECATED — byte-identical to `network_slug`, which is the name `Brand` uses for the same thing. Retained unchanged; use `network_slug`. It is removable no earlier than its `Sunset` date — read the `Deprecation`/`Sunset` headers on every response rather than this note. NULL MEANS exactly what a null `network_slug` means, because the two are byte-identical: NOT YET RESOLVED, the instance-to-network-type lookup did not resolve on this read. They are never null independently of each other."},"instance_id":{"type":"string","format":"uuid","description":"WHICH connection to that network — what tells two of the same org's Awin accounts apart. Never null on this endpoint."},"instance_public_id":{"type":"string","example":"inst_9ViVV9aGEXa","description":"The instance's OPAQUE PUBLIC handle (`inst_...`) — the stable identifier to key your own records on. DERIVED from `instance_id` and never stored, so it is stable for the life of the connection and byte-identical on every endpoint that emits it: `GET /instance`, `/brands`, `/products`, `/feeds`, `/feeds/{feed_id}/products`, `/coupons`, `/offers`, `/reporting/transactions` and `/reporting/summary`. That is what lets you join across all of them without ever handling the internal uuid. Always present: promotions are own-instance only (granted-main inventory is out of scope for these endpoints), so this is never null here, unlike on `Brand`."},"network_id":{"type":"string","format":"uuid","deprecated":true,"description":"DEPRECATED — despite the name this is the INSTANCE id, byte-identical to `instance_id` on every row this endpoint can serve. Retained unchanged rather than removed, because a partner may already read it. Use `instance_id`. It is removable no earlier than its `Sunset` date — read the `Deprecation`/`Sunset` headers on every response rather than this note. Note `Brand` carries no `network_id` at all: the field was removed there outright, because on a granted-main row it leaked the owning org's instance uuid to a read-only grantee. No granted-main row can appear on this endpoint, which is why the field survives here."},"code":{"type":"string","nullable":true,"description":"The coupon code. Null/empty means this is an offer, not a coupon."},"title":{"type":"string","description":"The promotion's headline as the NETWORK published it, passed through VERBATIM and NOT trimmed, so it may carry leading or trailing whitespace — trim before display. Always present. It is marketing copy, not a structured field: the discount, the expiry and the conditions are frequently stated only inside this string and nowhere else on the row."},"description":{"type":"string","nullable":true,"description":"The promotion's detail text as the NETWORK published it, verbatim and untrimmed. Free text that may contain the terms, exclusions and expiry in prose. `null` when the network published none — which does not mean the promotion is unconditional."},"offer_type":{"type":"string","nullable":true,"description":"The network's own promotion-type token, VERBATIM AND NOT NORMALISED ACROSS NETWORKS — this is NOT a closed enum and you must not switch on it generically. Each network uses its own vocabulary, and three connectors assign a fixed token of our own instead (`coupon`, `promotion`/`promo_code`, `voucher`/`offer`), so the same commercial idea is spelled differently depending on where it came from. The `?offer_type=` filter is an EXACT match, so you must know a given network's exact spelling for it to return anything. `null` when the network stated no type."},"starts_at":{"type":"string","format":"date-time","nullable":true,"description":"When the promotion becomes valid, as the NETWORK states it. ISO-8601. Network-supplied and not verified by us. This endpoint FILTERS on it, so every row you receive has already started. `null` when the network published no start — read that as 'already valid', not as 'starts never'; a null start never excludes a row."},"ends_at":{"type":"string","format":"date-time","nullable":true,"description":"When the promotion expires, as the NETWORK states it. ISO-8601. Network-supplied and not verified by us. This endpoint FILTERS on it, so an already-expired promotion is not returned and you do not have to check it yourself before displaying one. The comparison is against the request instant in UTC and is INCLUSIVE: a promotion is still served at exactly its stated end instant. `null` when the network published no end date, which means no stated expiry rather than a guaranteed indefinite one; a null end never excludes a row."},"link":{"type":"string","nullable":true,"description":"Sub-attributed tracking link, re-baked per row. Null when `link_kind` is `blocked`."},"link_kind":{"type":"string","enum":["main","type2","blocked"],"description":"WHETHER A LINK COULD BE MINTED FOR THIS ROW. `type2` = a native link was built and is in `link`. `blocked` = this brand's connector cannot carry a sub-attributed link at all, so `link` is `null`. `type2` MEANS ONLY THAT WE COULD BUILD A NATIVE LINK — IT DOES NOT GUARANTEE YOUR SUB IDENTITY IS ON IT. Where no sub applies to the request, the connector's sub slot is simply omitted or the sub parameter is not appended, and the resulting owner-level link is still reported as `type2`. This field cannot distinguish a sub-attributed link from an unattributed one. ALSO NOTE `main` IS PRESENT IN THIS ENUM BUT UNREACHABLE on this endpoint — it is retained only because removing an enum member is a breaking change to a published contract; no row will ever carry it."}}},"UnusableLinkOrigin":{"type":"string","enum":["vendor","merchant","platform","affilitera"],"description":"WHOSE FAILURE IT IS, when an item has no usable link. `vendor` = the affiliate network. `merchant` = the merchant, through its approval or its programme's lifecycle. `platform` = Affilitera, BY DESIGN and not in error. `affilitera` = Affilitera, IN ERROR — our own defect, published as ours. If you render only one field of a refusal, render this one: it is the difference between waiting for the network, applying to the merchant, and reporting the item to us. Machine-readable and stable — branch on this, never on a human-readable message."},"UnusableLinkReason":{"type":"string","enum":["vendor_no_destination","vendor_destination_unusable","vendor_link_type_unsupported","merchant_not_approved","merchant_unavailable","platform_link_not_offered","affilitera_defect"],"description":"WHY an item has no usable link, and where the failure originated. Machine-readable and stable — branch on THIS, never on a message, which may be reworded in any release. Each value maps to exactly one `UnusableLinkOrigin`, named in its entry below.\n\n`vendor_no_destination` (origin `vendor`) — THE NETWORK SUPPLIED NOTHING. It returned no destination for this item at all: the field was absent, null or blank in what it sent us. Nothing on your side or ours recovers one.\n\n`vendor_destination_unusable` (origin `vendor`) — THE NETWORK SUPPLIED SOMETHING UNUSABLE. A destination came back and it is not a usable web address — not an absolute http(s) URL, or still carrying an unsubstituted placeholder. Kept apart from `vendor_no_destination` because a value does exist: quote the `request_id` to us if you believe it should have resolved.\n\n`vendor_link_type_unsupported` (origin `vendor`) — THE NETWORK'S LINK FORMAT CANNOT CARRY THIS. The item is real and we are approved for it, but this network's link format has nowhere to put the kind of link requested — for example a product destination on a connector whose tracking URL has no destination slot. Brand-level links for the same merchant may still work.\n\n`merchant_not_approved` (origin `merchant`) — THE MERCHANT HAS NOT APPROVED US. We are not an approved publisher for it at the network, so any link minted would go unpaid. Fail-closed: pending, declined, removed and unknown approval states all report this. Apply to the merchant's programme to enable it.\n\n`merchant_unavailable` (origin `merchant`) — THE MERCHANT IS NOT CURRENTLY PROMOTABLE. Deactivated by its owner or by the platform, or its programme has ended at the network. Deliberately NOT reported as `merchant_not_approved`: an ended programme cannot be reapplied for, and telling you to reapply would waste your time.\n\n`platform_link_not_offered` (origin `platform`) — AFFILITERA DOES NOT OFFER THIS LINK HERE, BY DESIGN. The network supports it; we decline to serve it. What it covers today is a link that cannot carry your sub identity, which would credit somebody else for your traffic. A deliberate boundary, not a fault — and kept separate from `affilitera_defect` so a decision of ours is never reported as a bug of ours, nor a bug hidden among decisions.\n\n`affilitera_defect` (origin `affilitera`) — OUR DEFECT. We hold everything needed to build a usable link for this item and did not. Neither the network nor the merchant is at fault. This code is published deliberately rather than folded into a vendor code: an unusable item that is really our bug has to be reportable as our bug, or it never gets fixed. Quote the `request_id` when you report one."},"Error":{"type":"object","properties":{"error":{"type":"object","description":"THE ERROR ENVELOPE — present on every non-2xx response from this API, always with this same shape, so one handler can parse them all. Everything a caller should branch on is inside it. NOTE that some errors additionally carry DIAGNOSTIC FIELDS AS SIBLINGS OF THIS OBJECT, at the TOP LEVEL of the body rather than inside it: `parameter`, `kind` and `candidates` below. Look for those beside `error`, not within it.","required":["code","message","request_id"],"properties":{"code":{"type":"string","description":"Machine-readable and stable. Branch on THIS, never on `message`.","enum":["invalid_parameter","filter_too_broad","unauthorized","insufficient_scope","link_locked","not_found","method_not_allowed","conflict","not_registered","job_not_ready","job_not_failed","max_retries_exceeded","link_not_supported","merchant_unavailable","domain_ambiguous","product_unavailable","validation_error","not_supported","rate_limited","internal_error","service_unavailable","timeout"]},"message":{"type":"string","description":"For humans. May change without notice."},"request_id":{"allOf":[{"$ref":"#/components/schemas/RequestId"}],"description":"The identifier for the failed request, also returned in the `X-Request-ID` header. QUOTE IT WHEN REPORTING A PROBLEM: the underlying server-side cause of a 5xx is logged against it and is not otherwise recoverable from the response, whose `message` is deliberately generic."}}},"parameter":{"type":"string","description":"On `invalid_parameter`, the offending parameter's name."},"kind":{"type":"string","enum":["blocked"],"description":"Always `\"blocked\"` where present — the brand, connector or item cannot carry the requested link. Emitted on `link_not_supported` AND on `product_unavailable`; the earlier text naming only the first was wrong. It carries no information beyond its own presence — `unusable_reason` and `unusable_origin` are the fields that say what went wrong and whose fault it is."},"unusable_reason":{"allOf":[{"$ref":"#/components/schemas/UnusableLinkReason"}],"description":"WHY NO USABLE LINK COULD BE BUILT, on `product_unavailable`, `merchant_unavailable` and `link_not_supported` — present on every one of those refusals. Read it together with `unusable_origin`, which says whose failure it is. This API never returns an empty or placeholder link under a 2xx in order to avoid refusing: a link you receive is a link that works, and a link we cannot build is refused with one of these codes instead."},"unusable_origin":{"allOf":[{"$ref":"#/components/schemas/UnusableLinkOrigin"}],"description":"WHOSE FAILURE `unusable_reason` DESCRIBES. Always present beside it, and always consistent with it — it is derived from the reason server-side rather than sent independently, so the two cannot disagree. It includes `affilitera`, meaning the fault is ours: we publish that rather than reporting our own defects as the network's."},"candidates":{"type":"array","description":"On `domain_ambiguous`, every merchant the domain matched. Resubmit with `network_slug` from one of these.","items":{"type":"object","properties":{"brand_id":{"type":"string","description":"OUR internal uuid for this candidate brand, scoped to one network connection. Not stable across a network disconnect/reconnect — prefer `brand_public_id` if you are storing the choice."},"brand_public_id":{"type":"string","nullable":true,"description":"OUR minted durable handle for this candidate brand (`brd_…`). Store THIS if you intend to remember which candidate the caller picked; it survives a disconnect/reconnect that would change `brand_id`. `null` when the brand has no minted handle."},"brand_name":{"type":"string","nullable":true,"description":"The merchant's programme name as the network published it, for showing a human which candidate is which — a candidate here is a BRAND. Two candidates can legitimately share a name, so disambiguate on `network`/`instance_id`, never on this. NULL MEANS NOT YET RESOLVED — the candidate's brand row was not joined while building this error — and NOT that the candidate is nameless. It does not make the candidate unusable: resubmit using its `network`/`instance_id`, which are what actually disambiguate. A null here is a display problem, never a reason to drop a candidate."},"network":{"type":"string","nullable":true,"description":"The network FAMILY slug this candidate is reached through, e.g. `awin` — THE VALUE TO RESUBMIT as `network_slug` to resolve the ambiguity. If two candidates share this slug they are two of your accounts at the same network, and the slug alone will not disambiguate them; use `instance_id`. `null` when the connection does not resolve to a slug."},"instance_id":{"type":"string","description":"OUR uuid for the network CONNECTION this candidate belongs to — the only field guaranteed to tell two candidates apart, including two accounts you hold at the same network."}}}}}}},"responses":{"ProductPage":{"description":"A page of products.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PageMeta"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Product"},"description":"The page of products. Read `has_more` to decide whether to fetch another page; `total` is an estimate and must never be used as a loop bound."},"modified_as_of":{"type":"string","format":"date-time","description":"Stamped before the query ran. Use as the next request's modified_since."}}}]}}}},"CreativePage":{"description":"A page of network creatives.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PageMeta"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Creative"},"description":"The page of creatives. Read `has_more` to decide whether to fetch another page. An empty page can mean the network has not approved you as a publisher for these brands rather than that no creatives exist — check `relationship_status` on `GET /subnetwork/brands`."}}}]}}}},"PromotionPage":{"description":"A page of promotions.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PageMeta"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Promotion"},"description":"The page of promotions. Read `has_more` to decide whether to fetch another page. An empty page can mean the network has not approved you as a publisher for these brands rather than that none exist. Note also that EXPIRED PROMOTIONS ARE NOT FILTERED OUT — check `ends_at` yourself."}}}]}}}},"BadRequest":{"description":"Malformed request — `invalid_parameter` (the message names the parameter) or `filter_too_broad`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Unauthorized":{"description":"Missing, malformed, expired, revoked or unresolvable API key. These are NOT distinguished in the response — a key that has simply reached its `expires_at` (365 days after mint by default) returns exactly the same 401 as a revoked or unknown one, so check the key's expiry date in the Affilitera app before treating a sudden 401 as a compromise.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Forbidden":{"description":"`insufficient_scope` — the key lacks this endpoint's scope; or `link_locked` — the connection behind the key is deactivated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"No such resource WITHIN this key's scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Conflict":{"description":"The resource is in a state that forbids the operation. Where the conflict is that no usable link could be built — `product_unavailable`, `merchant_unavailable`, `link_not_supported` — the body additionally carries `unusable_reason` and `unusable_origin` at the TOP LEVEL, beside `error` rather than inside it, naming the cause and the party it originated with. A single-item request refuses here rather than returning a 2xx holding an empty or placeholder link.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"ValidationError":{"description":"Syntactically valid, semantically impossible.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"RateLimited":{"description":"Throttled. Always carries Retry-After.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"InternalError":{"description":"A genuine, non-transient server fault. Quote the request_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Timeout":{"description":"`timeout` — a read exceeded its time budget. TRANSIENT: retry after the interval in Retry-After. This is deliberately not a 500, because a 500 would tell you not to retry.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}