# Chia affiliate read API **Version 1.0.0 · Base path `/api/affiliate/v1` · Limited pilot** This is the machine-readable contract for authenticated read access to the tracking and reporting facts Chia holds for one affiliate. It exists so that nothing you need arrives only as an email attachment: attributed orders and their current refund state, renewal cycles, and refund/chargeback/reversal adjustments are queryable directly, alongside the exact daily and monthly reports Chia also emails. The canonical machine contract is the OpenAPI 3.1 document published at [`/contracts/affiliate/v1/openapi.yaml`](https://partners.chia.health/contracts/affiliate/v1/openapi.yaml). This document is the prose contract for the same surface; where a field is described in both, they are generated from the same implementation and must agree. **The disclosure boundary.** This API publishes nothing outside the union of what Chia already discloses to you on two existing rails: the daily and monthly CSV exports, and the postback rail that fires against your own tracker. Every field here is already on one of them. There is exactly one field-level difference from the CSV, and it is a difference in *transport*, not in disclosure: `sub_id3`, `sub_id4`, and `sub_id5` are published on every object carrying click dimensions, while the daily CSV truncates at `sub_id2`. Those three values are your own reporting dimensions, which you supplied on the `/go` link and which the postback rail has always delivered back to you in full. Without them you could attribute a conversion to a placement but not the refund that reverses it — the one case where money moves. Nothing else is new. No object, field, or derived value here originates outside those two rails, and the two things that would have been new — per-order renewal timelines and per-exclusion rows — are deliberately absent. See [What this API never returns](#what-this-api-never-returns). It is a reporting surface: it calculates no commission, creates no balance, and writes to no payout table. Every list response repeats that statement in a `disclaimer` field, and it applies equally to the responses that do not. This is not the Chia Partner API. That is a different product for marketplace publishers, with different credentials, a different origin, and a separate contract. Nothing here uses its OpenAPI operations, SDK, webhooks, or settlement endpoints. ## Availability There are two ways to hold a credential. - **You signed up in the Partner Portal** (partners.chia.health). Make a key yourself in Settings > Keys. It is shown once; store it in your vault. What it reads depends on your tracker, as described below. - **Your integration was set up by Chia by hand.** Your named Chia affiliate integration owner issues the credential into your approved vault through the agreed secure channel. Chia never sends a credential by email, chat, or ticket, and no operator reads its value. Your emailed CSV rail continues unchanged, so you can diff this API against the email before asking Chia to stop sending it. Until you hold a valid credential, every endpoint answers `401`. ### Partner Portal accounts A key made in the Partner Portal reads what the portal shows you, and nothing more. | | Before your tracker is live | Once your tracker is live | |---|---|---| | `GET /account` | yes | yes | | `GET /reports/daily/{date}`, `GET /reports/monthly/{month}` | yes, by day, the same file Reports downloads | yes, with each sale | | `GET /orders`, `GET /orders/{txid}`, `GET /events`, `GET /cursor` | `403 tracker_not_live` | yes | | `GET /renewals`, `GET /adjustments` | `404` | `404` | "Live" means the Tracker page in the portal shows your tracker as on: you saved your postback address, a test sale reached it, you confirmed it, and you turned it on. Each sale and its time come only then, because a single sale with its time can point to a person; daily totals cannot. For a portal account, an order carries an `outcome` (`paid`, `didnt_qualify`, or `being_priced`) and never the reason behind it. Reports are always in `America/New_York`. A key marked "sales only" in the portal reads `/orders` and nothing else, and needs a live tracker to be made. Any other endpoint answers it with `403 key_not_allowed`. ## Authentication Every request is server-to-server HTTPS: ```http Authorization: Bearer Accept: application/json ``` A live credential has the form `chia_aff_live_<12 lowercase hex>_<43 URL-safe characters>`; a test credential uses the `chia_aff_test_` scheme instead. The value is matched against that exact shape before any database work, so a session cookie, a patient token, or a Chia Partner API key can never be mistaken for an affiliate credential. **The credential owns affiliate identity.** There is no parameter, header, or body field anywhere in this contract that selects an affiliate. Sending `affiliate_id` — or any other unrecognised query parameter — is a `400 unknown_parameter`, on every endpoint, including the ones with no parameters at all. This is the same rule the inbound `/go` link follows, where the issued `link_code` owns affiliate identity. Every authentication and authorisation failure returns one identical opaque `401`: - the affiliate rail or the read API is switched off; - the credential is unknown, malformed, wrong, or revoked; - your affiliate record is suspended or terminated. You cannot tell those apart, by design. If you believe you should have access and receive `401`, contact your integration owner rather than probing. Rotating a credential does not reset your position. Cursors are a pure function of your affiliate's sequence, not of the credential, so a replacement token resumes exactly where the previous one stopped. The `livemode` field echoes which credential scheme you used. Test-mode tracking facts never appear on any endpoint under either scheme: test clicks, test links, and test purchases are excluded from the ledger the API reads. ### Methods and response headers Only `GET` is accepted. Any other method returns `405 method_not_allowed` in the usual error envelope. `POST`, `PUT`, `PATCH`, and `DELETE` are refused at the edge, with an `Allow: GET` header, before the request reaches the read API; any other non-`GET` method that does reach it is refused there with the same status and the same typed body, and no `Allow` header. Read-only is a property of the service that owns the data, not only of the boundary in front of it. Every response carries `Cache-Control: private, no-cache`, `Pragma: no-cache`, `Vary: Authorization, Accept`, `X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. `private` means no shared cache — your outbound proxy, a CDN, anything between us — may hold a byte of it. `no-cache` means your own client may keep a copy but must revalidate it with Chia before reusing it, which is exactly what the `If-None-Match` flow below does; a response you were forbidden to store at all could never have a validator sent back for it. Neither directive is permission to serve a stored page without asking. No response sets a cookie, and no request should send one. Do not log the `Authorization` header, and do not store a response body in a system that is not approved for your own click identifiers. Chia records an audit row for each authenticated request that disclosed something: endpoint, credential prefix, cursor range, row count, and status code. It records no row identifiers and no response body, so the audit log discloses nothing the request did not already carry. It exists to answer "what did this credential see" after a suspected compromise. A request that returned nothing — a `304`, a `200` with an empty page, or any `/cursor` call — is not recorded, because it disclosed nothing there is anything to answer about. ## Endpoints All paths are relative to `https://chia.health/api/affiliate/v1` unless your onboarding record names a different origin. Paths have no trailing slash. | Method and path | Returns | |---|---| | `GET /account` | Your integration state as one `account` object. | | `GET /cursor` | One `cursor` object: how far your ledger has got. | | `GET /orders` | A page of `order` objects with current refund state. | | `GET /orders/{txid}` | One `order` object, or `404`. | | `GET /renewals` | A page of `renewal` objects. | | `GET /adjustments` | A page of `adjustment` objects. | | `GET /events` | A page of every partner-visible object in cursor order. | | `GET /reports/daily/{YYYY-MM-DD}?timezone=` | One daily report, as JSON or CSV. | | `GET /reports/monthly/{YYYY-MM}?timezone=` | One monthly rollup, as JSON or CSV. | `/orders`, `/renewals`, `/adjustments`, and `/events` accept `cursor`, `since_seq`, and `limit`. `/account`, `/cursor`, and `/orders/{txid}` accept no query parameters. The report endpoints accept only `timezone`. `/renewals`, `/adjustments`, `/events`, and `/cursor` also accept the `If-None-Match` request header. `/orders` deliberately does not — see [Staying up to date without polling](#staying-up-to-date-without-polling). ## Paging and the cursor The four ledger collections return one keyset page in ascending `sequence` order. ```http GET /api/affiliate/v1/adjustments?limit=200 GET /api/affiliate/v1/adjustments?cursor=cur_eyJlIjoiYWRqdXN0bWVudHMiLCJzIjo0Mn0 GET /api/affiliate/v1/adjustments?since_seq=42 ``` | Parameter | Contract | |---|---| | `cursor` | An opaque `cur_`-prefixed string previously returned as `next_cursor`. | | `since_seq` | A plain integer; returns objects whose `sequence` is strictly greater. Minimum `0`. | | `limit` | Objects per page. Default `100`, minimum `1`, maximum `500`. | Rules, all of them enforced: - **Omit both `cursor` and `since_seq` to start from the beginning.** Full history is always re-pageable; Chia holds no per-partner read position and never advances one for you. - **Sending both is a `400 invalid_request`.** They are two spellings of one position. - **Cursors never expire.** You may resume from any cursor Chia has ever returned, at any later time, however long your outage lasted. That property is what makes this feed a genuine substitute for a push channel rather than merely a faster one. There is no `cursor_expired` condition on this API. - **The collection is bound inside the cursor.** Feeding an `/adjustments` cursor to `/orders` is a `400 invalid_cursor`, never a silently wrong page. - `since_seq` exists so your operations team can resume from a number stored in your own database without having persisted an opaque blob. The two forms are interchangeable for the same collection. - A malformed `limit`, a `limit` outside `1..500`, or a negative `since_seq` is a `400 invalid_request`. Every list response is the same envelope: ```json { "object": "list", "data": [], "has_more": false, "next_cursor": null, "next_seq": null, "livemode": true, "as_of": "2026-08-09T00:00:00Z", "disclaimer": "This is a reporting document. It is not an invoice or a statement." } ``` | Field | Type | Contract | |---|---|---| | `object` | string | Always `list`. | | `data` | array | The page, ascending by `sequence`. | | `has_more` | boolean | Whether another page exists right now. | | `next_cursor` | string or null | Pass as `cursor` for the next page. `null` when `has_more` is false. | | `next_seq` | integer or null | The same position as a plain integer, for `since_seq`. `null` when `has_more` is false. | | `livemode` | boolean | Which credential scheme authenticated the request. | | `as_of` | string or null | RFC 3339 UTC instant this response's derived state was evaluated at. | | `disclaimer` | string | Fixed reporting-document statement. | Terminate your loop on `has_more`, never on page length. A full page is not a promise that another exists, and Chia may return fewer objects than `limit`. ### What `sequence` means, and how to prove your copy is complete Every ledger row Chia records for your affiliate is assigned the next number in one per-affiliate sequence. Numbering happens after the underlying fact is durably committed, never during the write, so a number can never be handed out for a row a reader would then page past before it becomes visible. Numbers are never reused and never renumbered. Consequences you can rely on: - `sequence` is **strictly increasing** within a page and across pages of the same collection. - A `sequence` value is **never reused** and never renumbered, so a number you have already processed can never come to mean a different object. - The same object has the same `sequence` on `/events` and on its per-type collection. `/orders`, `/renewals`, and `/adjustments` are filtered views of the one sequence. - A number that appears twice, or an ordering inversion, is a defect; report it. - **`sequence` is a delivery order, not a business-time order.** A fact recorded late — a backfilled renewal, a repaired row — receives a *higher* number than facts that occurred after it. Sort by `occurred_at` whenever you mean business time, and use `sequence` only as a cursor. **`sequence` is not dense, on any endpoint, and gaps carry no information.** Chia numbers every ledger row it records, then filters what it serves you: by partner visibility, and again by what your credential is entitled to read. A number you never receive therefore belongs to a row you are not entitled to read — a test-mode fact, or a registration when your integration is not provisioned for registration events. This is structural and no change on Chia's side can remove it, because visibility is a per-row verdict and entitlement is a per-read filter, while numbering is neither. Gaps appear on `/events` as well as on the per-type collections. **So do not reconcile on the gaps. Re-page.** History is never rewritten, cursors never expire, and full re-paging is always available, so the authoritative completeness check runs entirely on your side and needs nothing from Chia: re-page a collection from the beginning and compare the object identifiers and counts you receive against your own store. Every object you have ever been served will be served again. Any check that infers a missing row from the distance between two `sequence` values will report failures that are not real, and it will do so more often as your entitlements change. You never need Chia to attest that your copy is complete. ## Staying up to date without polling Paging a feed that has not moved is the common case by a wide margin. There are three ways to avoid it, cheapest first. They compose: most integrations register a ping and keep a slow `/cursor` poll as the backstop. ### 1. Ask `/cursor` — one integer, no page ```http GET /api/affiliate/v1/cursor ``` ```json { "object": "cursor", "affiliate_id": "aff_demo", "latest_seq": 8421, "livemode": true, "as_of": "2026-08-10T09:15:00Z", "disclaimer": "This is a reporting document. It is not an invoice or a statement." } ``` | Field | Type | Contract | |---|---|---| | `object` | string | Always `cursor`. | | `affiliate_id` | string | Your Chia-issued opaque affiliate code. | | `latest_seq` | integer | The highest `sequence` you may currently read, scoped to your entitlement. See below. `0` means Chia holds no such row for you yet. | | `livemode` | boolean | Which credential scheme authenticated the request. | | `as_of` | string | RFC 3339 UTC instant the answer was taken at. | | `disclaimer` | string | Fixed reporting-document statement. | This is a single indexed maximum over your own ledger: no join, no page, no serialised object. Compare `latest_seq` against the highest `sequence` you have already processed and call `/events` only when it is greater. `GET /cursor` takes no query parameters — sending one is a `400 unknown_parameter` — and writes no audit row. **`latest_seq` is scoped to what your credential may actually be served.** It is the maximum over partner-visible, already-numbered rows *of the object types your integration is provisioned for*, not over every row Chia holds for you. Two exclusions follow from that, and both exist so the loop above always terminates: - A row that is committed but not yet numbered is excluded, so `latest_seq` never points at something you cannot page to. - A row of a type you are not entitled to read is excluded. If registration events are not provisioned for you, `latest_seq` never names a registration row — otherwise you would be told to catch up to a number `/events` will never deliver, and "poll until you have caught up" would never finish. The ledger ping in section 3 carries this same entitlement-scoped number, so the two agree by construction. ### 2. Send `If-None-Match` on the list endpoints `/renewals`, `/adjustments`, `/events`, and `/cursor` return an `ETag`. Send it back and an unchanged feed answers `304 Not Modified` with an empty body: ```http GET /api/affiliate/v1/adjustments?since_seq=8400&limit=200 If-None-Match: W/"adjustments-aff_demo-3f9c1a70-8400-200-8421" ``` - Reuse an `ETag` only with an **identical** request. A different `cursor`, `since_seq`, or `limit` is a different page and its validator will not match. Treat the value as opaque and store it verbatim; its internal form is not contractual and the example above is illustrative only. - The validator covers your affiliate, the collection, your entitlement, the page parameters, and your ledger's highest sequence. That last number is a sound version wherever the response body is built from ledger rows, because those rows are append-only and never edited. - **`/orders` returns no `ETag` and does not honour `If-None-Match`.** Its cumulative refund fields are read from the adjustment facts directly rather than from the ledger, and the two can diverge: a ledger pointer to an adjustment can be dropped without losing the adjustment. A validator built on the ledger's highest sequence would then answer `304` indefinitely while your refund state moved underneath it — the exact failure this rail exists to prevent. Poll `/cursor` for cheapness and fetch `/orders` unconditionally. - A `304` carries no body and no `data` array. Treat it as "nothing new", not as an empty page. - A malformed or unknown query parameter is never answered with `304`. It still returns its typed `400`, including for an affiliate selector — a `304` there would read as "your request was accepted and nothing has changed", which is the wrong answer to that request. ### 3. Register a ledger ping and stop polling Give your integration owner one HTTPS endpoint and Chia will `POST` to it whenever your ledger advances. Chia mints a signing secret for that endpoint and delivers it once, into your approved vault, through the same channel as your API credential. Ask for it to be rotated at any time; rotation issues a new secret and invalidates the old one, so install the new value before the switch. **The ping is a hint, never data. You must still pull.** The complete body is: ```json { "affiliate_id": "aff_demo", "latest_seq": 8421, "livemode": true, "schema": "chia-affiliate-ledger-ping/v1" } ``` There is no event, no amount, no identifier, and no count in it, and there never will be. Everything you learn, you learn from `GET /events?since_seq=`. Do not build a ledger from pings; build it from the pull the ping prompts. What that buys you, and what it costs you: - **Losing a ping is harmless.** Chia advances its record of what you have been told only on a delivered ping, so the next one carries a sequence at least as high and subsumes every ping you missed. There is no queue, no dead-letter, no ordering guarantee, and no idempotency key on this channel, because a duplicate and a miss are both self-correcting. - **Only a `2xx` counts as delivered.** Return it as soon as you have durably noted that you owe yourself a pull — not after the pull finishes. Response bodies are discarded and redirects are never followed. - **A dead endpoint backs off, and disarms nothing.** Consecutive failures back off through 1, 2, 5, 10, 20, and 30 minutes. Your attribution, your postback rail, your emailed CSVs, and this read API are unaffected while the endpoint is broken, and they are unaffected if you never register one at all. - **Pings are debounced to one per minute.** Fifty purchases inside a minute produce one ping carrying the highest sequence. - **The same ledger state reproduces the same bytes.** The timestamp lives in the header, not the body, so a retry of the same state has the same body and the same `Chia-Webhook-Id` with a fresh timestamp and signature. You may deduplicate on that ID, but you do not need to: acting on the same sequence twice is a no-op, because the reaction is the same idempotent pull. Headers on every ping: | Header | Contract | |---|---| | `Chia-Webhook-Id` | Stable event ID, `aff__`. | | `Chia-Webhook-Timestamp` | Unix seconds at send time, included in the signature. | | `Chia-Webhook-Signature` | Exactly one `v1=<64 lowercase hex characters>` HMAC-SHA256 value. | | `Chia-Webhook-Schema` | `chia-affiliate-ledger-ping/v1`. Must equal the body's `schema`. | ### Verifying a ping The recipe is the same one Chia's other signed webhooks use, so an existing verifier works unchanged: 1. Read the **exact raw request bytes** before any JSON parsing. The signature is over bytes; a re-serialised object will not verify. 2. Reject a `Chia-Webhook-Timestamp` more than **300 seconds** from your own clock. 3. Compute `HMAC-SHA256(secret, ".")`, hex-encode it, and **constant-time** compare against the `v1=` value. 4. Only then parse the body, and only then act. Keep the secret in your secret store, never in source. Never log the raw body, the signature, the secret, or the request headers. ```python import hashlib import hmac import json import os import time MAX_SKEW_SECONDS = 300 SECRET = os.environ["CHIA_AFFILIATE_PING_SECRET"].encode("utf-8") class InvalidPing(Exception): pass def verify_ledger_ping(raw_body: bytes, headers) -> dict: """Return the parsed ping, or raise. `raw_body` must be the exact bytes.""" timestamp = headers.get("Chia-Webhook-Timestamp", "") signature = headers.get("Chia-Webhook-Signature", "") if not timestamp.isdigit(): raise InvalidPing("missing or malformed timestamp") if abs(time.time() - int(timestamp)) > MAX_SKEW_SECONDS: raise InvalidPing("timestamp outside the accepted window") if not signature.startswith("v1="): raise InvalidPing("missing v1 signature") expected = hmac.new( SECRET, timestamp.encode("ascii") + b"." + raw_body, hashlib.sha256, ).hexdigest() if not hmac.compare_digest(expected, signature[3:]): raise InvalidPing("signature mismatch") ping = json.loads(raw_body) if ping.get("schema") != "chia-affiliate-ledger-ping/v1": raise InvalidPing("unexpected schema") return ping ``` ```ts import { createHmac, timingSafeEqual } from "node:crypto"; const MAX_SKEW_SECONDS = 300; const SCHEMA = "chia-affiliate-ledger-ping/v1"; export type LedgerPing = { affiliate_id: string; latest_seq: number; livemode: boolean; schema: typeof SCHEMA; }; /** `rawBody` must be the exact received bytes, never a re-serialised object. */ export function verifyLedgerPing( rawBody: Buffer, headers: Headers, secret: string, ): LedgerPing { const timestamp = headers.get("Chia-Webhook-Timestamp") ?? ""; const signature = headers.get("Chia-Webhook-Signature") ?? ""; if (!/^\d+$/.test(timestamp)) throw new Error("missing or malformed timestamp"); if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_SKEW_SECONDS) { throw new Error("timestamp outside the accepted window"); } if (!signature.startsWith("v1=")) throw new Error("missing v1 signature"); const expected = createHmac("sha256", secret) .update(`${timestamp}.`) .update(rawBody) .digest("hex"); const provided = signature.slice(3); if ( provided.length !== expected.length || !timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(provided, "utf8")) ) { throw new Error("signature mismatch"); } const ping = JSON.parse(rawBody.toString("utf8")) as LedgerPing; if (ping.schema !== SCHEMA) throw new Error("unexpected schema"); return ping; } ``` Your handler then does one thing: ```ts const ping = verifyLedgerPing(rawBody, request.headers, secret); if (ping.latest_seq > (await readStoredSequence())) { await enqueueLedgerPull(); // GET /events?since_seq= } return new Response(null, { status: 204 }); ``` If pings stop arriving, nothing is lost and nothing is broken: fall back to `/cursor`, page from your stored `since_seq`, and tell your integration owner. ## Values and encodings - **Money on the ledger endpoints is integer cents** in fields whose names end in `_cents`. There are no decimal strings and no floating-point money. `currency` is an uppercase ISO 4217 code; Chia settles in `USD`. - **Identifiers are byte-exact.** The `apostrophe-prefix-v1` encoding described in the CSV reporting contract is spreadsheet safety and exists only in CSV. It is never applied in JSON, where it would silently corrupt an identifier. A `click_id` of `-partner_7` is exactly `"-partner_7"` here. - **Timestamps are RFC 3339 UTC ending in `Z`**, for example `2026-08-04T12:00:00Z`. - **Booleans are JSON booleans**, not `1`/`0`. The `1`/`0` spelling exists only in CSV. - **Absent string dimensions are `""`, not `null`.** A visitor who supplied no `sub_id3` yields `"sub_id3": ""`. - **The report endpoints are the deliberate exception** and echo the CSV's own value formats verbatim. See below. ## Objects ### `account` Returned by `GET /account`. Not wrapped in a list envelope. | Field | Type | Contract | |---|---|---| | `object` | string | Always `account`. | | `affiliate_id` | string | Your Chia-issued opaque affiliate code. | | `name` | string | Your integration's name as recorded by Chia. | | `status` | string | `active`, `suspended`, or `terminated`. A credential can only authenticate while `active`. | | `livemode` | boolean | Credential scheme of this request. | | `attribution_window_days` | integer | Your configured attribution window. | | `integrated_at` | string or null | When your test-fire confirmation was bound. | | `postback` | object | See below. | `postback` carries three fields: | Field | Type | Contract | |---|---|---| | `live_enabled` | boolean | Whether your live postback rail is currently armed. | | `registration_events_enabled` | boolean | Whether registration events are provisioned for you. | | `partner_confirmed_at` | string or null | When you confirmed receipt of both test-fire events. | `postback.live_enabled` is the first surface on which you can see for yourself that a terminal delivery failure has disarmed your pixel rail. Attribution, reporting, and this API are unaffected by that pause: conversions continue to be recorded and continue to appear here. ### `order` One attributed first purchase. Returned by `/orders`, `/orders/{txid}`, and `/events`. | Field | Type | Contract | |---|---|---| | `object` | string | Always `order`. | | `txid` | string | Chia purchase UUID. The same value delivered in the `{txid}` macro of a `purchase` postback. | | `click_id` | string | The click identifier you supplied on the `/go` link. | | `sub_id`, `sub_id2`, `sub_id3`, `sub_id4`, `sub_id5` | string | Your reporting dimensions, byte-exact. All five are published here; the daily CSV truncates at `sub_id2`. | | `offer` | string | Chia-minted opaque `off_` identifier, unique to one issued link. It encodes no destination, treatment, medication, or marketing semantics. | | `link_code` | string | The issued link this click came through. | | `term_months` | integer | Purchased term length. | | `order_value_cents` | integer | Gross original order value. | | `net_collected_cents` | integer | Cash collected on the original purchase. | | `payout_cents` | integer | Your commission for this purchase, in cents, exactly as sent in the `{payout}` postback macro. Fixed when the purchase was captured; a later rate change never alters it. Provisional until the validation window closes — a refund inside it reverses the commission and is not re-notified. | | `payout_status` | string | Why `payout_cents` is what it is: `payable`, `zero_value` (nothing collected), `refunded` (money had already gone back), `returning_customer` (the buyer was not new to Chia), or `unresolved` (Chia has not determined it yet — raise it with your contact). | | `currency` | string | Uppercase ISO 4217. | | `is_returning_customer` | boolean | Purchase classification flag. | | `is_zero_value` | boolean | Purchase classification flag. | | `applied_coupon_ids` | array of strings | Every captured opaque discount, promotion, or coupon ID, in stored order. `[]` means none. | | `refund_state` | string or null | `none`, `partial`, or `full`, evaluated at `state_as_of`. | | `refunded_amount_cents` | integer or null | Cumulative refunds plus *active* chargebacks at `state_as_of`; a chargeback reversal cancels its chargeback. | | `renewals_to_date` | integer or null | Non-test renewals through `state_as_of`. | | `revenue_to_date_cents` | integer or null | Net collected purchase and renewal cash less refunds and active chargebacks. | | `occurred_at` | string | When the purchase happened. | | `state_as_of` | string or null | The instant the four cumulative fields above were evaluated at. | | `sequence` | integer | Ledger cursor position. | **The four cumulative fields and `state_as_of` are `null` on `/events`.** That stream publishes facts in cursor order; cumulative state is a derived view with a clock. Fetch `/orders` or `/orders/{txid}` when you need it. On `/orders`, every object in the page shares one `state_as_of`, equal to the envelope's `as_of`, so a page of orders is internally consistent and consistent with an `/adjustments` page taken at the same instant. Deliberately absent, permanently: any payment-processor subscription or payment-intent identifier, a discount amount, the internal offer slug, and any per-order renewal timeline. A dated per-order refill sequence is a medication persistence curve for one person you can identify from your own click ID. Renewals are published as their own flat objects instead. ### `renewal` One attributed renewal cycle. Chia sells prepaid multi-month terms billed on 28-day months, so renewal revenue lands far outside any tracker lookback window and is never postbacked. | Field | Type | Contract | |---|---|---| | `object` | string | Always `renewal`. | | `renewal_id` | string | Stable Chia renewal UUID. **This is the reconciliation key.** | | `purchase_txid` | string | The `txid` of the first purchase this cycle belongs to. | | `cycle_number` | integer | An as-of label, not a key. See below. | | `click_id`, `sub_id` … `sub_id5`, `offer`, `link_code` | string | The originating click's dimensions. | | `order_value_cents` | integer | Gross renewal value. | | `net_collected_cents` | integer | Cash collected on this cycle. | | `currency` | string | Uppercase ISO 4217. | | `occurred_at` | string | When the renewal was billed. | | `sequence` | integer | Ledger cursor position. | `cycle_number` is renumbered across every cycle of a purchase whenever a backfilled renewal lands, so a commission row keyed on the cycle number will see it move. This is documented rather than frozen because freezing it would make the number disagree with the CSV. Key on `renewal_id`, which never changes. ### `adjustment` One refund, chargeback, or chargeback reversal, from the append-only adjustment ledger. | Field | Type | Contract | |---|---|---| | `object` | string | Always `adjustment`. | | `adjustment_id` | string | Stable Chia adjustment UUID. The reconciliation key. | | `purchase_txid` | string | The first purchase this adjustment traces to. | | `renewal_id` | string or null | The renewal cycle, when the adjustment applies to one. | | `applies_to` | string | `purchase` or `renewal`. | | `renewal_cycle` | integer or null | Cycle label when `applies_to` is `renewal`. | | `adjustment_type` | string | Exactly `refund`, `chargeback`, or `chargeback_reversal`. | | `net_value_adjustment_cents` | integer | Signed. See below. | | `currency` | string | `USD`. | | `click_id`, `sub_id` … `sub_id5`, `offer`, `link_code` | string | The originating click's dimensions. | | `occurred_at` | string | When the adjustment occurred. | | `sequence` | integer | Ledger cursor position. | `adjustment_type` is a closed vocabulary of exactly those three values. It will not grow a fourth, and no field naming the *reason* for a refund will ever be added. `net_value_adjustment_cents` is signed: **negative when value leaves Chia** (`refund`, `chargeback`) and **positive when a chargeback is restored** (`chargeback_reversal`). The sign is produced by the same code that produces Section D of the daily CSV, so the two surfaces cannot disagree. Summing this field over a period gives the same net movement the CSV's `refunded_value` describes with the opposite sign convention; read both definitions before you reconcile. Chia stores only a one-way digest of the payment processor's own reference and never publishes a raw processor identifier. Inventing one would be a fabrication, so there is no field for it. ### `registration` One pre-payment email capture: a visitor from your link submitted an email in Chia's medical qualification quiz, before checkout. | Field | Type | Contract | |---|---|---| | `object` | string | Always `registration`. | | `registration_id` | string | Stable Chia event UUID. | | `click_id`, `sub_id` … `sub_id5`, `offer`, `link_code` | string | The originating click's dimensions. | | `occurred_at` | string | When the capture happened. | | `sequence` | integer | Ledger cursor position. | Registration objects appear on `/events` **only** when your integration is provisioned for registration events — the same per-integration switch the postback rail honours. If registration events are off for you, no registration object is reachable on any endpoint, and their sequence numbers are simply absent from your stream. There is no value field: a registration is always worth zero. ## The `txid` overload `txid` is overloaded on the postback rail, and this matters when you look an order up: - a `purchase` postback carries the **Chia purchase UUID** in `{txid}`; - every other event type forces **your own click ID** into the same `{txid}` macro, because a pre-payment fact has no order to name. So a `txid` your tracker recorded from a registration postback is your click ID, and `GET /orders/{that value}` legitimately returns `404`. That `404` means "no attributed order exists for that txid". It never means cancelled, refunded, excluded, or suppressed. A syntactically impossible identifier and a real identifier belonging to another affiliate return the identical `404`. To join a registration to the order it later became, match on `click_id`, which appears on every object this API returns. ## Reports ```http GET /api/affiliate/v1/reports/daily/2026-08-04?timezone=America/New_York GET /api/affiliate/v1/reports/monthly/2026-07 ``` - `timezone` accepts exactly `UTC` (the default) or `America/New_York`. Any other value is a `400 invalid_request` naming the allowed set. The two daily variants cover different intervals and are not interchangeable; the `America/New_York` interval is DST-safe and can contain 23, 24, or 25 hours. - A malformed path segment is a `400 invalid_request`: the daily path must be `YYYY-MM-DD`, the monthly path `YYYY-MM`. - **A daily period that has not closed is a `400 period_not_closed`.** A partial day rendered as a complete report is the one way this endpoint could publish a number that later changes with no correction signal. - A monthly rollup may be requested for a month that is still open. Its figures continue to move until the month closes, and its D30/D60/D90/D180 columns stay blank until each horizon matures. Such a response says so: its `period_closed` is `false` and its `snapshot_utc` is the instant it was rendered rather than the window end. - **A month that has not started is a `400 invalid_request`.** A running period has a defensible partial reading; a future one has none, and returning an empty document carrying that period, that window, and a statement-shaped filename is not a reading of anything. - The representation is chosen by your `Accept` header: CSV when it contains `text/csv`, JSON for anything else, including a missing header. ### `Accept: text/csv` returns the emailed artifact Send `Accept: text/csv` and the response body is byte-identical to the CSV attachment Chia would have emailed for that affiliate, period, and timezone, with the same `Content-Disposition` filename, for example `chia-affiliate--daily-2026-08-04-utc.csv`. Its schemas, decoding rules, and sections are defined in the [affiliate CSV reporting contract](https://partners.chia.health/docs/affiliate-reporting-contract/). That byte-identity is the point: during cutover you can fetch the same period you were emailed and diff the two, and prove equivalence before asking Chia to switch the email off. ### `Accept: application/json` returns the same artifact as sections ```json { "object": "report", "report_type": "daily", "schema_version": "chia-affiliate-daily-v1", "affiliate_id": "aff_demo", "period": "2026-08-04", "timezone": "UTC", "window_start_utc": "2026-08-04T00:00:00Z", "window_end_utc": "2026-08-05T00:00:00Z", "period_closed": true, "snapshot_utc": "2026-08-05T00:00:00Z", "filename": "chia-affiliate-aff_demo-daily-2026-08-04-utc.csv", "sections": [ { "name": "section_d_refund_chargeback_adjustments", "records": [ { "timestamp_utc": "2026-08-04T18:00:00Z", "adjustment_id": "10421a12-5517-45d1-8d36-4febce4ccf62", "click_id": "partner_click_7", "purchase_txid": "6c0b843e-69e1-49fd-a69d-6f371dab7c0f", "sub_id": "camp_a", "sub_id2": "creative_7", "applies_to": "purchase", "renewal_cycle": "", "adjustment_type": "refund", "net_value_adjustment": "-200.00", "currency": "USD", "offer": "off_0123456789abcdef01234567", "link_code": "link_demo" } ] } ] } ``` The JSON and CSV forms are rendered from one row set, so they cannot drift. Section names and record keys are exactly the CSV's section names and column headers. Daily reports contain `section_a_daily_by_sub_id`, `section_b_purchase_reconciliation`, `section_c_exclusions`, and `section_d_refund_chargeback_adjustments`; the monthly rollup contains `monthly_cohort_rollup`. A section with no rows is still present with `"records": []`, so you can distinguish "no refunds that day" from "this file predates the refund section". Two properties of this form are contractual: - **Values echo the CSV's own formats verbatim.** Money is a decimal string with two fractional digits, booleans are `"1"` or `"0"`, and an empty cell is `""`. This is not an oversight; the purpose of these endpoints is that you can diff their output against the email. The ledger endpoints — `/orders`, `/renewals`, `/adjustments`, `/events` — are the ones that publish integer cents, because those are new objects rather than a rendering of an existing artifact. - **`apostrophe-prefix-v1` is not applied.** That encoding exists to stop a spreadsheet executing a cell as a formula. In JSON it would corrupt an identifier, so identifier cells are unwrapped to their exact stored value and the CSV's `opaque_text_encoding` preamble key is absent from the JSON document. `period_closed` and `snapshot_utc` are stated in both representations — as a JSON boolean and string here, and as the `period_closed`/`snapshot_utc` preamble rows of the CSV — so the two can never disagree about whether a number is final. | Field | Type | Meaning | |---|---|---| | `period_closed` | boolean | `true` when the labelled period had finished at render time. `false` means these figures are a preview and will keep moving. | | `snapshot_utc` | string | Exclusive RFC 3339 UTC instant the facts in this report were read as of. Equals `window_end_utc` whenever `period_closed` is `true`. | Because a closed period's snapshot is its window end, two renderings of a closed period are byte-identical. Two renderings of a running period differ at least in `snapshot_utc`. Only closed periods are ever emailed, so the byte-identity cutover proof below is unaffected. ### A live re-render can differ from the email you received These endpoints render the report now, from current facts, rather than returning a stored copy of what was sent. So a refund whose occurrence time falls inside a day whose report was already emailed is present here and absent there. **That difference is the correction mechanism the daily email rail structurally lacks.** An emailed attachment is a fixed artifact; once sent it cannot express a later-arriving fact about the period it covers. Re-fetching the same period is how you obtain the corrected view. If you reconcile mechanically, treat the API's rendering of a closed period as the current truth for that period, and expect it to move when a late adjustment lands. ## Errors Every failure is a JSON object of the same shape, with the same headers: ```json { "object": "error", "error": { "code": "invalid_cursor", "message": "Cursor belongs to a different collection.", "retryable": false } } ``` | Status | `code` | `retryable` | Cause | |---|---|---|---| | `400` | `invalid_request` | false | A parameter is malformed or out of range; both `cursor` and `since_seq` were sent; a report path or timezone is invalid; a reporting period has not started. | | `400` | `unknown_parameter` | false | A query parameter this endpoint does not define, including any affiliate selector. | | `400` | `invalid_cursor` | false | The cursor is not a Chia affiliate cursor, is malformed, or belongs to another collection. | | `400` | `period_not_closed` | false | The requested daily reporting period has not finished. | | `401` | `unauthorized` | false | The credential is not valid for this API. Identical for every cause. | | `403` | `tracker_not_live` | false | A Partner Portal key asked for each sale before its tracker is live. `/account` and the reports still answer. Turn the tracker on in the portal. | | `403` | `key_not_allowed` | false | A sales-only key asked for anything but `/orders`. | | `404` | `not_found` | false | No such object exists for your affiliate. Identical for an object belonging to another affiliate. | | `405` | `method_not_allowed` | false | Anything other than `GET`. | | `429` | `rate_limited` | true | Too many requests in the current window. | | `500` | `internal_error` | true | Chia failed to complete the request. No internal detail is ever surfaced. | | `502` | `upstream_unavailable` | true | The read API could not be reached. | Retry only where `retryable` is true. Retrying a `400`, `401` or `403` with the same input cannot succeed. Every endpoint is a pure read, so a retry is always safe. ## Rate limits 600 requests per credential per fixed 60-second window. Exceeding it returns `429 rate_limited` with `retryable: true`. The window is a fixed clock bucket, so waiting until the next wall-clock minute is always sufficient; there is no `Retry-After` header on this API. Rate limiting does not affect cursors — a throttled request consumed no position, so re-issue exactly the same request. The limit is deliberately generous because a first full-history reconciliation is precisely what this feed exists for. ## Reconciling from zero 1. `GET /events?limit=500`, then follow `next_cursor` until `has_more` is false. Discriminate each object on its `object` field. Store the highest `sequence` you have processed. 2. Resume later with `?cursor=` or `?since_seq=`. Both are exact; neither expires. Decide *whether* to resume with `GET /cursor`, an `If-None-Match` conditional request, or a registered ledger ping — see [Staying up to date without polling](#staying-up-to-date-without-polling). 3. For current cumulative refund state on an order, call `/orders` (or `/orders/{txid}`) rather than deriving it — the cumulative fields are `null` on `/events` by design, and Chia's netting rule for active chargebacks versus reversals is not something you should reimplement. 4. Reconcile a closed day against `GET /reports/daily/{date}` with the timezone your commercial agreement uses, and re-fetch it after late adjustments land. ## What this API never returns This list is a boundary, not a backlog. None of it is planned. - **No patient or consumer data of any kind**: no name, email, phone, address, age, identifier, or hashed form of any of them. - **No clinical data**: no condition, questionnaire answer, medication, dose, prescription, provider, pharmacy, lab, or care-session fact, in any form, derived or otherwise. - **No order fulfillment**: no shipment, tracking number, carrier event, delivery state, or supply state. - **No cancellation or subscription lifecycle data.** A missing renewal is not published as a cancellation, and `404` never means cancelled. - **No per-exclusion rows.** Chia publishes exclusion *counts* in Section C of the daily report — `b2b_partner_rail`, `admin_added`, `deferred_sibling` — and never one row per excluded invoice. A row carrying a click ID would tell you "this specific conversion you saw was deliberately not credited", which is a new disclosure rather than a faster transport. - **No payment-processor identifiers**, and no raw refund or dispute reference. - **No commission, balance, payout, or amount-owed field**, and no writes: this API cannot change any Chia state. Do not attempt to re-identify Chia customers, enrich this data against other datasets, or sell, license, or onward-transfer it. The [affiliate program boundaries](https://partners.chia.health/docs/affiliate-program-boundaries/) govern use of everything returned here. ## Support Email `support@chia.health` with the subject `[Affiliate integration]` and the opaque click ID, transaction ID, or adjustment ID in question, plus the exact UTC time, endpoint, and HTTP status. Never include a name, email address, medical information, questionnaire answer, medication, or other patient data, and never include your credential or a full request URL that carries one.