Partner API
AI index
Launch contract: sandbox is synthetic and cannot charge or create a patient. Use only signed-onboarding origins and credentials.

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. 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.

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:

Authorization: Bearer <your credential>
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.

Paging and the cursor#

The four ledger collections return one keyset page in ascending sequence order.

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:

{
  "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#

GET /api/affiliate/v1/cursor
{
  "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:

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:

{
  "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=<your stored sequence>. 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_<affiliate code>_<latest_seq>.
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.

  1. Reject a Chia-Webhook-Timestamp more than 300 seconds from your own

clock.

  1. Compute HMAC-SHA256(secret, "<timestamp>.<raw body>"), hex-encode it, and

constant-time compare against the v1= value.

  1. 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.

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
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:

const ping = verifyLedgerPing(rawBody, request.headers, secret);
if (ping.latest_seq > (await readStoredSequence())) {
  await enqueueLedgerPull(); // GET /events?since_seq=<stored sequence>
}
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#

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-<affiliate_id>-daily-2026-08-04-utc.csv. Its schemas, decoding rules, and sections are defined in the affiliate CSV 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#

{
  "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:

{
  "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.

  1. Resume later with ?cursor=<stored next_cursor> or `?since_seq=<stored

sequence>. 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.

  1. 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.

  1. 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 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.