Reporting and attribution
Three read-only endpoints that tell you, for any window you choose, exactly how much of your subscription revenue is attributable to each recipient you nominated with revenue_share: aggregated for a payout run, per subscription for a member enquiry, and per renewal for a single line. Every figure comes from renewals that actually settled. The raw HTTP helpers the samples use are defined on the overview; the SDK clients do not wrap these three endpoints yet, so the samples here are curl, Node and Python over raw HTTP.
Read this first: a report, not a remittance
Invo settles to you, and only to you. The recipients named in these responses are your users under your agreements. Invo has never identity-verified them, has no contract with them, and has no tax-reporting relationship with them, so Invo does not pay them and never will. You pay your own recipients, out of your own settlement, on your own rails.
This ships in the payload. Every response from every endpoint on this page carries an attribution_notice block, and settled_by_invo: false is restated at every nesting level: on the totals, on each recipient, on each renewal line, on each attribution object, and on each refund event. It is written that way so the statement survives being pasted into an email, a spreadsheet or your own accounting system. What these endpoints give you is the data that makes paying your recipients a lookup rather than a reconstruction. No money has moved to anyone named in a response.
Authentication and scope
All three endpoints are server-to-server, authenticated with X-Game-Secret-Key. They are read-only; none of them can move money. Never call them from a client: a response contains recipient emails and your revenue figures. Results are scoped to your game at every level. A subscription id or a period id from another tenant returns 404, never data.
1. The four correctness properties
1. Figures derive from settled renewals, and are never recomputed from a rate
A stored percentage is not a historical payout. Invo does not keep a rate and multiply it by a past amount at read time. The attributed amount you get back is the snapshot recorded at the moment the renewal settled, the same object that already reached you on the subscription.renewed event.
So if you change a recipient’s rate in March, February’s report does not move. The two detail endpoints show today’s configured rate for convenience in a current_revenue_share block stamped is_payout_basis: false and applies_to: "future_renewals_only". It is absent from the aggregate report, where sitting next to a total it would invite exactly the recompute this design prevents. On a renewal line the historical rate is named percent_at_renewal so the two cannot be confused.
2. The non-settlement flag is on every response
attribution_notice is present on every response, including empty ones. It carries settled_by_invo: false, invo_has_paid_no_recipient: true, document_type: "attribution_report", not_a_remittance_advice: true, payer: "partner", and a plain-English statement.
3. Figures are net of reversals, with the breakdown shown
Each recipient row carries renewal_count, gross_attributed_usd, reversed_attributed_usd and net_attributed_usd. The net is the figure you pay. The gross and the reversal are both emitted alongside it so nothing has to be inferred from a difference.
A renewal that settled and was fully refunded inside the same window reports as a net-zero line, not as a negative. It appears in the window’s gross and its reversal, so it cancels itself. Those lines carry nets_to_zero_in_window: true plus a note, and are counted in totals.renewals_settled_then_fully_reversed.
4. The window is keyed on when money settled, not when a period opened
A renewal for a July cycle that actually settled on 2 August belongs to the August payout run, because August is when you received the revenue you are paying out of. No straddling periods, no partial cycles to apportion.
This is also what makes a closed window reproducible. A renewal that settled in July stays in July’s gross forever, whatever happens to it afterwards, and every reversal is reported in the window its refund actually happened in. Run July in August, pay your recipients, re-run July in December: same numbers.
There are exactly two revenue legs
Invo, and you. A recipient is never a third leg. invo_fee_usd is what the platform kept; partner_revenue_usd is what you were left with; the attributed figure is a slice of your remainder that you told us to attribute, a percentage of the amount after the Invo fee. Invo’s own fee is retained on a refund, so it has no reversed counterpart.
2. Aggregated attribution: the payout run
/api/subscriptions/reporting/attribution
This is the endpoint you run to pay your recipients for a month. It folds every renewal that settled in the window into one row per recipient, and every refund event that happened in the window into that row’s reversal.
Query parameters
| Field | Type | Req. | Notes |
|---|---|---|---|
start | string | yes | Inclusive. ISO 8601 date (2026-07-01, read as 00:00:00Z) or full datetime. A naive datetime is stamped UTC rather than guessed at. |
end | string | yes | Exclusive. Same formats. Must be after start, and no more than 400 days later. |
recipient_player_id | int | no | Restrict to one recipient. The platform-wide figures in totals are accumulated before this filter. |
subscription_id | string | no | Restrict to one subscription. Max 50 chars. |
limit | int | no | Pagination over recipients, not renewals. Default 50, silently clamped to 100. Must be at least 1. |
offset | int | no | Default 0. Between 0 and 100000. |
start=2026-07-01T00:00:00-07:00 means midnight Pacific, not midnight UTC. Totals are never paginated; only the recipient list is. Sorting is by net attributed descending, with recipient id ascending as a stable tiebreaker, so a recipient cannot appear on two pages or on none.Response (200)
{
"status": "success",
"attribution_notice": {
"settled_by_invo": false,
"invo_has_paid_no_recipient": true,
"document_type": "attribution_report",
"not_a_remittance_advice": true,
"payer": "partner",
"statement": "INVO has not paid any recipient named in this report and will not. ..."
},
"report": {
"type": "founder_attribution",
"game_id": "97288428282",
"generated_at": "2026-08-01T10:00:03+00:00",
"window": { "start": "2026-07-01T00:00:00+00:00", "end": "2026-08-01T00:00:00+00:00", "end_exclusive": true },
"filters": { "recipient_player_id": null, "subscription_id": null },
"derivation": { ...prose, in words, saying where each figure came from and that none is recomputed from a rate... },
"known_limits": { ...the caps in section 5, restated... }
},
"totals": {
"renewals_settled": 1284,
"settled_gross_usd": "12827.16",
"invo_fee_usd": "833.77",
"partner_revenue_settled_usd": "11993.39",
"partner_revenue_reversed_usd": "56.14",
"partner_revenue_usd": "11937.25",
"renewals_with_attribution": 1190,
"renewals_without_attribution": 94,
"unattributed_reasons": { "no_revenue_share_configured": 94 },
"gross_attributed_usd": "1112.40",
"reversed_attributed_usd": "5.61",
"net_attributed_usd": "1106.79",
"renewals_settled_then_fully_reversed": 3,
"refund_events_in_window": 7,
"reversals_without_receipts": 0,
"recipient_count": 46,
"settled_by_invo": false
},
"recipients": [
{
"recipient": { "player_id": 40219, "resolved": true, "player_email": "founder@example.com",
"player_name": "Kara V.", "identity_id": "idn_..." },
"renewal_count": 118,
"subscription_count": 118,
"gross_attributed_usd": "110.24",
"reversed_attributed_usd": "0.93",
"net_attributed_usd": "109.31",
"first_paid_at": "2026-07-01T09:02:11+00:00",
"last_paid_at": "2026-07-31T22:40:55+00:00",
"settled_by_invo": false,
"periods": [ ...up to 500 renewal lines, shape as in section 3... ],
"periods_truncated": false,
"reversals": [ ...refund evidence, same 500 cap... ],
"reversal_count": 1,
"reversals_truncated": false
}
],
"pagination": { "total_count": 46, "limit": 50, "offset": 0, "has_more": false }
}totals
| Field | Type | Notes |
|---|---|---|
renewals_settled | int | Renewals that settled in the window, whatever their status is today. |
settled_gross_usd | string | Total billed, from each renewal’s charge-time price snapshot. |
invo_fee_usd | string | What the platform kept. Never reversed by a refund. |
partner_revenue_settled_usd | string | Your revenue as booked in this window. Fixed at settlement. |
partner_revenue_reversed_usd | string | Your revenue clawed back by refunds that happened in this window. |
partner_revenue_usd | string | The difference: your revenue movement for the window, not a running balance. |
renewals_with_attribution / renewals_without_attribution | int | How many settled renewals carried an attribution. |
unattributed_reasons | object | Counts by reason (table below). Reported rather than silently dropped. |
gross_attributed_usd, reversed_attributed_usd, net_attributed_usd | string | Across all recipients. Net is the whole run’s payout figure. |
renewals_settled_then_fully_reversed | int | The net-zero lines. |
refund_events_in_window | int | Individual refund events counted, not renewals refunded. |
reversals_without_receipts | int | Lines that fell back to the coarse reversal path. Non-zero means limitation 1 in section 5 applies to that many lines. |
recipient_count | int | Distinct recipients in the window, before pagination. |
unattributed_reasons keys
| Value | Means |
|---|---|
no_revenue_share_configured | The renewal ran and no share existed for that subscription at the time. Nobody earned anything: a real answer, not a defect. |
spend_order_not_found | The renewal’s settlement record could not be reached. Investigate before paying anything off that line. |
attribution_not_recorded | The record exists but carries no attribution object. |
attribution_malformed | An attribution object exists but could not be read. Contact us if you see this. |
recipients[]
| Field | Type | Notes |
|---|---|---|
recipient.player_id | int | Your player, in your game. Recipients are always same-game players. |
recipient.resolved | bool | false when the player record could not be found. A real answer, not an error: the row is still reported with the identity fields null, so you investigate it rather than silently miss it. |
recipient.player_email, player_name, identity_id | string | null | Identity for your own payout run. |
renewal_count, subscription_count | int | Attributed renewals, and how many distinct subscriptions they came from. |
gross_attributed_usd, reversed_attributed_usd, net_attributed_usd | string | net_attributed_usd is the number to pay. |
first_paid_at, last_paid_at | string | null | Settlement bounds of this recipient’s renewals inside the window. |
periods[], periods_truncated | array, bool | Inline evidence: the renewal lines behind the total, shape as in section 3, capped at 500 per recipient. true when the cap was hit. The totals are never truncated; use the per-subscription endpoint for the rest. |
reversals[], reversal_count, reversals_truncated | array, int, bool | The refund evidence, same 500 cap. |
recipients[].reversals[]
| Field | Type | Notes |
|---|---|---|
period_id, subscription_id, period_seq, status | int, string, int, string | Which renewal was refunded. |
paid_at, settled_in_this_window | string, bool | When it settled, and whether that was inside this window. |
amount_usd, originally_attributed_usd | string | What it was billed and what was originally attributed. |
refunded_to_date_usd, latest_refund_at | string | Cumulative, for reference only. The reversal below is not derived from them. |
reversed_attributed_usd, partner_revenue_reversed_usd | string | This window only, summed from the refund events below. |
refund_events[] | array | One entry per individual refund: refunded_at, refunded_amount_usd, cumulative_refunded_before_usd, cumulative_refunded_after_usd, reversed_attributed_usd, partner_revenue_reversed_usd, partner_revenue_reversal_basis, is_full_refund, settled_by_invo. |
reversal_basis | string | "refund_receipts": the normal, exact, per-event path. "period_row": the coarse fallback; see limitation 1. |
nets_to_zero_in_window, note | bool, string | null | Settled and fully refunded inside this window: pay nothing and claw nothing back. |
Errors
| Status | error_code | Cause |
|---|---|---|
| 400 | WINDOW_INVALID | start or end missing, unparseable, or end not after start. |
| 400 | WINDOW_TOO_LONG | The range exceeds 400 days. |
| 400 | WINDOW_TOO_LARGE | More than 20,000 settled renewals (body carries settled_renewals, max_renewals) or more than 20,000 refunded renewals (reversals, max_rows). See limitation 2. |
| 400 | PAGINATION_INVALID | limit / offset not integers, limit below 1, or offset outside 0 to 100000. |
| 400 | RECIPIENT_INVALID | recipient_player_id is not an integer. |
| 400 | SUBSCRIPTION_ID_INVALID | subscription_id longer than 50 characters. |
| 400 | INVALID_FIELD_VALUE | A parameter value the database rejected. |
| 429 | rate_limit_exceeded | Carries retry_after in the body and a Retry-After header. |
| 500 | INTERNAL_ERROR | The report could not be generated. Retry; contact us if it persists. |
3. One subscription’s attribution history
/api/subscriptions/reporting/subscriptions/{subscription_id}/attribution
Every cycle of one subscription: what was billed, how it split, and what was attributed to whom. The endpoint to reach for when a member or a recipient asks about one membership.
Query parameters
| Field | Type | Req. | Notes |
|---|---|---|---|
status | string | no | Comma-separated cycle statuses: pending, paid, failed, forgiven, refunded. Default: all. Unknown values: 400 STATUS_INVALID. |
limit, offset | int | no | Pagination over cycles. Default 50, max 100; offset 0 to 100000. |
Cycles that were never paid are included by default and carry attribution: null. A failed or forgiven cycle earned nobody anything, and showing it is how you see a gap in a member’s billing history rather than assuming one never existed. Cycles are returned newest first.
Response (200)
{
"status": "success",
"attribution_notice": { ...as in section 2... },
"subscription": {
"subscription_id": "SUB_1786982400_A7K2M9QX",
"status": "active",
"item_id": "guild-42-membership",
"item_name": "Guild 42 membership",
"current_amount_usd": "9.99",
"interval": "month",
"interval_count": 1,
"created_at": "2026-04-01T09:00:00+00:00"
},
"current_revenue_share": {
"recipient_player_id": 40219,
"percent": "10.00",
"applies_to": "future_renewals_only",
"is_payout_basis": false,
"settled_by_invo": false,
"note": "Current configuration. ... Changing this percent does not change any past attribution.",
"updated_at": "2026-06-02T11:20:00+00:00"
},
"lifetime_totals": {
"renewals_settled": 4,
"settled_gross_usd": "39.96",
"invo_fee_usd": "2.60",
"partner_revenue_settled_usd": "37.36",
"partner_revenue_reversed_usd": "2.81",
"partner_revenue_usd": "34.55",
"renewals_with_attribution": 4,
"gross_attributed_usd": "3.72",
"reversed_attributed_usd": "0.28",
"net_attributed_usd": "3.44",
"recipient_player_ids": [40219],
"settled_by_invo": false
},
"periods": [
{
"period_id": 55129,
"subscription_id": "SUB_1786982400_A7K2M9QX",
"period_seq": 4,
"item_id": "guild-42-membership",
"item_name": "Guild 42 membership",
"status": "paid",
"period_start": "2026-07-01T09:00:00+00:00",
"period_end": "2026-08-01T09:00:00+00:00",
"paid_at": "2026-07-01T09:02:11+00:00",
"amount_usd": "9.99",
"invo_fee_usd": "0.65",
"partner_revenue_usd": "6.53",
"partner_revenue_settled_usd": "9.34",
"partner_revenue_reversed_usd": "2.81",
"partner_leg_reversed": true,
"funding": {
"amount_coins": "99.90",
"balance_applied_coins": "40.00",
"card_charged_usd": "5.99",
"rail": "card",
"steam_charged_usd": "0.00"
},
"refund": { "refunded_amount_usd": "3.00", "refunded_at": "2026-08-14T16:05:02+00:00" },
"transaction_id": "TXN_1786982400_K3P9",
"mint_order_id": "ORD_...",
"attribution": {
"recipient_player_id": 40219,
"percent_at_renewal": "10.00",
"base_usd": "9.34",
"attributed_amount_usd": "0.93",
"settled_by_invo": false,
"recipient": { "player_id": 40219, "resolved": true, "player_email": "founder@example.com",
"player_name": "Kara V.", "identity_id": "idn_..." }
},
"attribution_unavailable_reason": null,
"settled_by_invo": false,
"reconciliation": {
"checked": true,
"expected_base_usd": "9.34",
"recorded_base_usd": "9.34",
"base_matches_settled_fee": true
}
}
],
"pagination": { "total_count": 5, "limit": 50, "offset": 0, "has_more": false }
}| Field | Type | Notes |
|---|---|---|
subscription.current_amount_usd | string | Today’s price. What any given cycle was billed is on that cycle’s amount_usd. |
current_revenue_share | object | null | Today’s configured rate, explicitly is_payout_basis: false. null if no share is configured. |
lifetime_totals | object | null | Across every settled cycle of this subscription, not just the page. null only if a subscription somehow exceeds the 20,000-renewal cap. Uses the same settled set as the aggregate report, so the two agree. |
periods[].amount_usd | string | The price snapshot taken when this cycle was charged. |
periods[].partner_revenue_usd | string | Net of any reversal. partner_revenue_settled_usd and partner_revenue_reversed_usd are both alongside it. |
periods[].funding | object | amount_coins and balance_applied_coins are coins, never converted; card_charged_usd and steam_charged_usd are USD; rail is card or steam. Do not compare coins against USD. |
periods[].transaction_id, mint_order_id | string | Invo’s own ledger handles, for correlating against the subscription.renewed event you already received. mint_order_id is null when the wallet covered the period. |
periods[].attribution.percent_at_renewal | string | null | The rate in force at that renewal, named so it cannot be confused with today’s configured percent. |
periods[].attribution.base_usd | string | The amount the percentage was applied to: the billed amount after the Invo fee. |
periods[].attribution_unavailable_reason | string | null | One of the four values in section 2, when attribution is null. |
periods[].reconciliation | object | A check, not a substitute: it re-derives the base from the canonical revenue rows and reports whether it matches the recorded one (base_matches_settled_fee). attributed_amount_usd stays the recorded snapshot even when this is false. Tell us if you ever see false. |
Errors
| Status | error_code | Cause |
|---|---|---|
| 400 | PAGINATION_INVALID | Bad limit or offset. |
| 400 | STATUS_INVALID | An unknown value in status. The body names the offending values. |
| 400 | INVALID_FIELD_VALUE | An unusable subscription id or query parameter. |
| 404 | SUBSCRIPTION_NOT_FOUND | No such subscription on your game. |
| 429 | rate_limit_exceeded | Per-game and per-subscription limits both apply. |
| 500 | INTERNAL_ERROR | The history could not be read. |
4. One payout line in full
/api/subscriptions/reporting/periods/{period_id}
One renewal in full: what was billed, how it was funded, what Invo kept, what you received, what was attributed and to whom, whether the recorded attribution base agrees with the canonical revenue rows, every individual refund against it, and which charge attempt paid it. No query parameters; period_id comes from a periods[].period_id on either endpoint above.
Response (200)
{
"status": "success",
"attribution_notice": { ...as in section 2... },
"subscription": { ...as in section 3... },
"period": { ...the full renewal line from section 3... },
"fee_legs": {
"invo_fee_usd": "0.65",
"invo_fee_percent": "6.50",
"partner_revenue_usd": "6.53",
"partner_revenue_settled_usd": "9.34",
"partner_revenue_reversed_usd": "2.81",
"partner_revenue_percent": "93.50",
"partner_leg_reversed": true,
"source": "...in words: these rows are canonical and never recomputed from a rate...",
"note": "...in words: two legs, Invo and the partner; no third leg for a recipient..."
},
"refunds": {
"refund_count": 1,
"refunded_to_date_usd": "3.00",
"latest_refund_at": "2026-08-14T16:05:02+00:00",
"basis": "refund_receipts",
"events": [
{
"refunded_at": "2026-08-14T16:05:02+00:00",
"refunded_amount_usd": "3.00",
"cumulative_refunded_before_usd": "0.00",
"cumulative_refunded_after_usd": "3.00",
"is_full_refund": false,
"refund_transaction_id": "TXN_1786985511_B4W1",
"reversed_attributed_usd": "0.28",
"partner_revenue_reversed_usd": "2.81",
"partner_revenue_reversal_basis": "clawback",
"settled_by_invo": false
}
]
},
"charge": {
"attempts_for_period": 1,
"paid_attempt_no": 1,
"paid_attempt_at": "2026-07-01T09:02:09+00:00",
"outcome": "paid"
},
"current_revenue_share": { ...as in section 3... }
}| Field | Type | Notes |
|---|---|---|
fee_legs | object | The canonical two-leg split for this renewal. There is no third leg for a recipient. Invo’s fee has no reversed counterpart because it is retained on a refund. |
refunds.refund_count | int | Individual refund events against this renewal. |
refunds.refunded_to_date_usd, latest_refund_at | string | null | Cumulative total and the latest refund timestamp. Never prorate these into a window; that is the double-count the per-event list exists to prevent. |
refunds.basis | string | "refund_receipts" or "period_row". See limitation 1. |
refunds.events[] | array | One entry per refund, each with its own amount, cumulative before and after, and timestamp. This tells you which windowed report each reversal landed in. |
events[].partner_revenue_reversal_basis | string | clawback (a contra entry booked against the refund), sale_fee_row_reversed (the original revenue entry was reversed outright), or none. |
charge | object | attempts_for_period, paid_attempt_no, paid_attempt_at, outcome. Useful when a cycle took several attempts to clear. |
Errors
| Status | error_code | Cause |
|---|---|---|
| 400 | INVALID_FIELD_VALUE | An unusable period_id. |
| 404 | PERIOD_NOT_FOUND | No such renewal on your game. Period ids are shared across tenants, so this is an authorisation answer as much as a lookup one. |
| 429 | rate_limit_exceeded | Carries retry_after and a Retry-After header. |
| 500 | INTERNAL_ERROR | The renewal could not be read. |
5. Limits and caps
Limitation 1: a refund whose reversal cannot be split per event
Normally every reversal is derived from the individual refund event it belongs to, so a renewal refunded in instalments across two windows reverses its August share in August and its September share in September. The pieces are never double-counted and sum exactly to the whole reversal. Those lines carry reversal_basis: "refund_receipts".
When the per-refund record is unavailable for a renewal, the report falls back to the cumulative total on the renewal itself, and the whole reversal is reported in the window of the latest refund. Those lines carry reversal_basis: "period_row", and totals.reversals_without_receipts counts them so you can check the number is zero before you pay. Any partner-revenue clawback on such a renewal is not visible to the report either.
Limitation 2: a range that is too large is refused, not truncated
A window containing more than 20,000 settled renewals (or more than 20,000 refunded ones) returns 400 WINDOW_TOO_LARGE with the actual count in the body. Narrow the range and run the report in parts. A partial number that looks complete is how a recipient gets underpaid and nobody finds out.
One recipient per subscription
A subscription attributes to exactly one recipient, set at create, and that recipient must be a real player in your game. A renewal line has at most one attribution object. If a membership in your product could ever split across two recipients, tell us before you build.
Every cap in one place
| Cap | Value | On breach |
|---|---|---|
| Reporting window length | 400 days | 400 WINDOW_TOO_LONG |
| Settled renewals per report | 20,000 | 400 WINDOW_TOO_LARGE |
| Refunded renewals per report | 20,000 | 400 WINDOW_TOO_LARGE |
| Inline evidence lines per recipient | 500 | Flagged periods_truncated / reversals_truncated. Totals are never truncated. |
limit | 100 (default 50) | Silently clamped; not an error. |
offset | 100,000 | 400 PAGINATION_INVALID |
Rate limits
The aggregate report is a range scan and is limited to 60 requests per minute per game. The two detail endpoints allow 600 per minute per game, with an additional 120 per minute per individual subscription. A 429 carries retry_after in the body and a Retry-After header. Honour it.
6. A monthly payout run, end to end
Run this on the 1st for the month that just closed. Because the window is keyed on settlement, it covers exactly the money that arrived.
# start is INCLUSIVE, end is EXCLUSIVE, and an explicit offset is honoured:
# this is midnight-to-midnight Pacific, not UTC.
curl -sS -G "$BASE/api/subscriptions/reporting/attribution" \
-H "X-Game-Secret-Key: $GAME_SECRET" \
--data-urlencode "start=2026-07-01T00:00:00-07:00" \
--data-urlencode "end=2026-08-01T00:00:00-07:00" \
--data-urlencode "limit=100"
# one line, in full, behind a figure you are about to pay:
curl -sS "$BASE/api/subscriptions/reporting/periods/55129" \
-H "X-Game-Secret-Key: $GAME_SECRET"
# one member's whole history, paid cycles only:
curl -sS -G "$BASE/api/subscriptions/reporting/subscriptions/SUB_1786982400_A7K2M9QX/attribution" \
-H "X-Game-Secret-Key: $GAME_SECRET" \
--data-urlencode "status=paid,refunded"async function monthlyPayoutRun(startIso, endIso) {
const recipients = [];
let offset = 0, notice = null, totals = null;
for (;;) {
const qs = new URLSearchParams({ start: startIso, end: endIso, limit: "100", offset: String(offset) });
const { status, json } = await invo("GET", "/api/subscriptions/reporting/attribution?" + qs);
if (status !== 200) {
if (json.error_code === "WINDOW_TOO_LARGE") {
throw new Error("Window has " + json.settled_renewals + " settled renewals (max " +
json.max_renewals + "). Split the month and re-run.");
}
throw new Error(json.error_code + ": " + json.message);
}
notice = json.attribution_notice;
totals = json.totals;
recipients.push(...json.recipients);
if (!json.pagination.has_more) break;
offset += json.pagination.limit;
}
// THIS IS A REPORT. Invo has paid nobody in it. Assert it rather than assume it.
if (notice.settled_by_invo !== false || notice.payer !== "partner") {
throw new Error("Unexpected attribution notice; stop and read it.");
}
if (totals.reversals_without_receipts > 0) {
console.warn(totals.reversals_without_receipts + " line(s) used the coarse reversal path this window.");
}
// net_attributed_usd is the figure to pay: already net of every reversal in the window.
return recipients
.filter((r) => Number(r.net_attributed_usd) > 0)
.map((r) => ({
playerId: r.recipient.player_id,
email: r.recipient.player_email, // null if resolved === false
resolved: r.recipient.resolved, // investigate false before paying
amountUsd: r.net_attributed_usd, // exact decimal string; do not parse to a float to move money
renewals: r.renewal_count,
}));
}
// You pay these out on your own rails, under your own agreements. Invo settles to you and only to you.from decimal import Decimal
def monthly_payout_run(start_iso, end_iso):
recipients, offset, notice, totals = [], 0, None, None
while True:
status, body = invo("GET", "/api/subscriptions/reporting/attribution",
params={"start": start_iso, "end": end_iso, "limit": 100, "offset": offset})
if status != 200:
if body.get("error_code") == "WINDOW_TOO_LARGE":
raise RuntimeError(f"Window has {body['settled_renewals']} settled renewals "
f"(max {body['max_renewals']}). Split the month and re-run.")
raise RuntimeError(f"{body.get('error_code')}: {body.get('message')}")
notice, totals = body["attribution_notice"], body["totals"]
recipients.extend(body["recipients"])
if not body["pagination"]["has_more"]:
break
offset += body["pagination"]["limit"]
# THIS IS A REPORT. Invo has paid nobody in it. Assert it rather than assume it.
if notice["settled_by_invo"] is not False or notice["payer"] != "partner":
raise RuntimeError("Unexpected attribution notice; stop and read it.")
if totals["reversals_without_receipts"] > 0:
print(f"{totals['reversals_without_receipts']} line(s) used the coarse reversal path this window.")
# net_attributed_usd is the figure to pay: already net of every reversal in the window.
return [
{"player_id": r["recipient"]["player_id"],
"email": r["recipient"]["player_email"], # None if resolved is False
"resolved": r["recipient"]["resolved"], # investigate False before paying
"amount_usd": Decimal(r["net_attributed_usd"]), # exact; never a float
"renewals": r["renewal_count"]}
for r in recipients if Decimal(r["net_attributed_usd"]) > 0
]
# You pay these out on your own rails, under your own agreements. Invo settles to you and only to you.Related
- Webhooks: the per-renewal attribution rides on
subscription.renewed, so you can accrue in real time instead of only at report time. - Card road: where the attribution recipient is nominated (
revenue_share). - Refunds: what produces the reversals on this page.
- Renewals: how a renewal settles in the first place.