Reporting payments, credit and costs

Feed Referalli the cash, credit and costs behind each payment so the credit ROI figures are exact: billing providers, the value API, CSV import, mapping profiles, precedence and inference.

Embed
Preview of the card
<a href="https://referalli.com/docs/reporting-value"><img src="https://referalli.com/docs/_api/public/orgs/07f432f4-34b9-46a7-aaad-c0e395d2e4f9/spaces/2bf4f614-3630-4fbf-85f9-5ecafa6b7d97/pages/371b7d44-33ee-4b2a-8291-9b3719368575/social-card.svg?v=3e7ae33eec8b" alt="Reporting payments, credit and costs" width="600" height="315"></a>

Credit ROI answers one question: did the credit you gave away come back as real money? To answer it, Referalli needs to know, for each payment, how much was cash (money you collected) and how much was covered by credit (not money). This page covers every way to supply that, from nothing at all to a fully reported feed. For the merchant's view of the numbers, see Is your referral credit paying off?.

How the data fits together

Referalli keeps one append-only ledger of value events: payments, refunds, credit granted, credit redeemed, credit expired or revoked, costs and adjustments. Rows are never edited; a correction is a new event.

Each fact has a confidence:

Confidence

Where it comes from

reported

You sent it: the value API, CSV import, or the redeem endpoints.

extracted

Read from a billing provider's webhook payload, by the built-in extractor or by your mapping profile.

inferred

Worked out by Referalli because nothing said how much credit a payment used. Inferred credit use is an assumption, shown as such, and never stored as a fact.

You get a first number with no setup: connect a billing provider and Referalli reads cash from each payment and mirrors your referee rewards and settled commission credit as credit grants. Everything below sharpens that number.

Precedence

When several sources describe the same payment, exactly one row counts:

  1. reported beats extracted, which beats inferred.

  2. At equal confidence, the first row recorded wins.

  3. The exception is a re-extraction: a row with a higher extraction_version supersedes a lower one at the same or lower confidence (this is how a new mapping profile replaces the built-in extractor). A re-extraction never overrides a reported fact.

Losing rows are kept, marked superseded, and left out of every total. A payment you report is matched to the billing payment it describes by provider plus invoiceRef, else by externalId (rows Referalli extracts from billing events carry their billing event internally; the API and CSV import don't take a billingEventId). Send provider and invoiceRef (the invoice or order id the provider uses) and your report replaces the extracted row for that payment instead of counting twice.

Stripe

Automatic. Connect Stripe as described in Billing providers; no further setup is needed. For each invoice.paid, Referalli reads:

  • Cash: amount_paid.

  • Credit applied: Stripe stores a customer's credit balance as a negative number. Credit consumed by an invoice is ending_balance - starting_balance, counted only when starting_balance is below zero, and capped at the balance the customer held. A customer with a $50.00 balance (starting_balance: -5000) and a $30.00 invoice that Stripe settles from the balance ends at -2000: credit applied is 3000, cash is 0.

  • Discount: the sum of total_discount_amounts[].amount.

A payment covered entirely by credit therefore counts as zero cash, which is the point.

Field reference by provider

This is what the built-in extractor reads from each provider's payload. All amounts end up in integer minor units; major-unit providers are scaled by the currency's exponent (JPY 0, USD 2, KWD 3) from the text, so no float error reaches the ledger.

Provider

Cash

Credit applied

Discount

Stripe

amount_paid

ending_balance - starting_balance when starting_balance < 0, capped at -starting_balance

sum of total_discount_amounts[].amount

Chargebee

amount_paid, else total minus credit

credits_applied + promotional_credits

sum of discounts[].amount, else the lines' discount_amount

Shopify

total_price minus gift-card payments

successful gift_card sale or capture entries in transactions[]

sum of discount_allocations[].amount on line items and shipping, else total_discounts

BigCommerce

total_inc_tax minus credit

store_credit_amount + gift_certificate_amount

discount_total, else discount_amount + coupon_discount

Recurly

paid, else total minus credit

sum of credit_payments[].amount, else credit_applied

discount

Paddle

details.totals.grand_total (already after credit and discount)

details.totals.credit

details.totals.discount

WooCommerce

total

not reported (0)

discount_total

Lemon Squeezy

total

not reported (0)

discount_total

Polar

total_amount

not reported (0)

discount_amount

Braintree

amount

not reported (0)

discount-amount

Notes:

  • "Not reported" does not mean "no credit". Referalli can't tell, so for those providers it falls back to inference, or to your own reports through the value API. Add a mapping profile (in the dashboard: Settings → Billing providers, connection menu, Map credit fields) if the provider's payload carries the credit somewhere else.

  • Shopify gift cards need the order's transactions. If an order paid with a gift_card gateway arrives without them, Referalli claims nothing and lets inference apply rather than tag the payment as exact cash. Shopify store credit and loyalty points are not in the built-in fields; map them or report them.

  • Providers or payloads with none of the fields above fall back to the event's gross amount as cash.

  • generic connections have no built-in extractor. Send the amounts in a mapping profile or through the value API.

Shopify

Connect Shopify as in Billing providers. Cash and gift-card credit come from the order payload as above; discount codes still attribute buyers to partner coupons. To report what Shopify can't (store credit your app issued, cost of goods), add either a mapping profile that points at a field in the order, such as a note_attributes or metafield value you already include, or report from your backend with the value API, using the order's id as invoiceRef and provider: "shopify".

Custom platform

If you run your own billing, report every payment and credit event from your server with the value API. Send in batches and make every event idempotent with a stable externalId, so a retry never counts twice.

curl -X POST https://referalli.com/v1/value/events \
  -H "Authorization: Bearer rk_live_..." \
  -H "content-type: application/json" \
  -d '{
    "events": [
      {
        "kind": "credit_granted",
        "role": "referee",
        "customerRef": "cus_123",
        "faceMinor": 1000,
        "currency": "usd",
        "externalId": "grant-cus_123-welcome",
        "occurredAt": "2026-10-20T09:00:00Z"
      },
      {
        "kind": "payment",
        "customerRef": "cus_123",
        "cashMinor": 2400,
        "creditAppliedMinor": 1000,
        "currency": "usd",
        "productRef": "pro-monthly",
        "provider": "acme-billing",
        "invoiceRef": "inv_8841",
        "externalId": "pay-inv_8841",
        "occurredAt": "2026-10-21T09:00:00Z"
      },
      {
        "kind": "cost",
        "customerRef": "cus_123",
        "costMinor": 600,
        "currency": "usd",
        "productRef": "pro-monthly",
        "externalId": "cost-inv_8841",
        "occurredAt": "2026-10-21T09:00:00Z"
      }
    ]
  }'
{
  "results": [
    { "index": 0, "id": "val_01JB...", "created": true, "superseded": [] },
    { "index": 1, "id": "val_01JB...", "created": true, "superseded": [] },
    { "index": 2, "id": "val_01JB...", "created": true, "superseded": [] }
  ],
  "errors": []
}

Send the same batch again and every result comes back "created": false: nothing is recorded twice. Each event is validated and recorded on its own, so a bad event appears in errors as { "index": 3, "message": "..." } while the rest are recorded. A request holds 1 to 500 events. Reported facts are always source: "api" and confidence: "reported"; you can't choose either.

The value API

POST /v1/value/events needs an API key with the analytics:write scope (or a full key). The body is { "events": [ ... ] }, and unknown fields are rejected.

Field

Type

Notes

kind

string

Required: payment, refund, credit_granted, credit_redeemed, credit_expired, credit_revoked, cost or adjustment.

externalId

string

Required. Your unique id for this fact; the idempotency key.

occurredAt

string

Required. ISO 8601 date or datetime.

currency

string

Required. Three letters; stored lowercase.

customerRef, partnerId, partnerExternalRef

string

At least one is required. partnerExternalRef is your own id for a partner (the partner's externalRef). A payment, refund, cost or adjustment for a partner with no customerRef is recorded under the partner's externalRef.

cashMinor

integer

Real money. For a refund, any sign is accepted and stored as a negative.

creditAppliedMinor

integer, 0 or more

Part of the payment covered by credit. For credit_redeemed, the credit used.

discountMinor, taxMinor, feesMinor

integer, 0 or more

Discount, tax and processor fees contained in the payment.

costMinor

integer, 0 or more

Your cost: the cost of the payment, or a cost event's amount.

faceMinor

integer, 0 or more

Face value of the credit. Required and positive for credit_granted.

role

string

referee or referrer. Required for credit_granted, and only valid there.

creditGrantId

string

The Referalli credit grant a redemption, expiry or revocation closes.

referralId, programId, productRef

string

Optional links. productRef picks a margin from your product catalog.

provider, invoiceRef

string

Together they match this payment to the billing payment it describes, so the two aren't counted twice.

metadata

object

Free-form.

How each kind is used:

  • payment records cash, credit applied, discount and optionally a cost.

  • refund returns real money. Credit refunded to the customer's balance is creditAppliedMinor.

  • credit_granted registers free value you gave, as an external credit grant. Referee credit costs you when it is used; referrer credit costs you when you settle it (see cost model).

  • credit_redeemed records credit used outside a payment. With creditGrantId it closes that grant; without an amount it uses the grant's whole remaining balance.

  • credit_expired and credit_revoked close the rest of a grant, so it costs nothing further.

  • cost is a cost of goods or of serving a customer, linked to a payment by invoiceRef or productRef.

  • adjustment corrects an earlier fact with a signed cashMinor.

Import from a CSV

If you can't call an API, upload a CSV. POST /v1/value/import takes up to 10,000 rows and 5 MB, and the same scope as the value API.

curl -X POST https://referalli.com/v1/value/import \
  -H "Authorization: Bearer rk_live_..." \
  -H "content-type: application/json" \
  -d '{
    "csv": "order_id,email,paid,store_credit,date\nA-1001,ada@example.com,24.00,10.00,2026-10-21\nA-1002,bo@example.com,49.00,0,2026-10-22\n",
    "kind": "payment",
    "defaultCurrency": "usd",
    "dryRun": true,
    "mapping": {
      "order_id": "externalId",
      "email": "customerRef",
      "paid": "cashMajor",
      "store_credit": "creditAppliedMajor",
      "date": "occurredAt"
    }
  }'

mapping maps each CSV column header to a field. Targets are the text fields of the table above (kind, customerRef, partnerId, partnerExternalRef, creditGrantId, referralId, programId, productRef, currency, externalId, invoiceRef, provider, occurredAt, role) and each money field as <name>Minor (an integer) or <name>Major (a decimal like 12.34, converted by the currency's exponent; more decimals than the currency allows is an error). The money names are cash, creditApplied, discount, tax, fees, cost and face.

  • kind applies to every row unless a column is mapped to kind. One is required.

  • defaultCurrency fills rows with no currency column. Major-unit columns need a currency.

  • Use a Minor or a Major column for a field, not both.

  • With no externalId column, Referalli derives one from the row's content (csv_ followed by a hash), so uploading the same file twice records nothing new. Rows that are identical in every mapped column collapse into one, so include a unique id column when real orders can repeat.

  • With "dryRun": true nothing is recorded. The response lists the first 20 normalised rows, every row error and the total row count, so you can check the mapping first.

{ "rows": [ { "kind": "payment", "customerRef": "ada@example.com", "cashMinor": 2400, "creditAppliedMinor": 1000, "currency": "usd", "externalId": "A-1001", "source": "api", "confidence": "reported", "occurredAt": "2026-10-21T00:00:00.000Z" } ], "errors": [], "total": 2 }

A real run returns { "recorded": 2, "duplicates": 0, "errors": [] }. Row numbers in errors are spreadsheet rows, with the header as row 1. A bad row never blocks the others. A file over 5 MB or 10,000 rows gets 413.

A file of more than 500 rows is recorded in the background: the response is 202 with { "jobId": "vjb_..." }. Poll GET /v1/value/import/{jobId} until status is done (or failed); it returns recorded, duplicates and errors in the same shape, plus processed and total rows so far. A dry run always answers at once.

Mapping profiles

When your billing provider sends credit or cost in a field the built-in extractor doesn't read, tell Referalli where it is. A mapping profile belongs to one billing connection and says which path in the provider's payload holds each amount.

curl -X PUT https://referalli.com/v1/billing/connections/bcon_01JB.../value-mapping \
  -H "Authorization: Bearer rk_live_..." \
  -H "content-type: application/json" \
  -d '{
    "cash": "data.object.amount_paid",
    "creditApplied": "data.object.metadata.store_credit_used",
    "discount": "data.object.discounts[].amount",
    "cost": "data.object.metadata.cogs",
    "productRef": "data.object.lines.data[].price.product",
    "unit": "minor"
  }'
{
  "id": "bcon_01JB...",
  "valueMapping": {
    "cash": "data.object.amount_paid",
    "creditApplied": "data.object.metadata.store_credit_used",
    "discount": "data.object.discounts[].amount",
    "cost": "data.object.metadata.cogs",
    "productRef": "data.object.lines.data[].price.product",
    "unit": "minor",
    "version": 2
  }
}

Paths. A path is dot-separated keys into the raw webhook body, such as data.object.amount_paid. A key ending in [] walks an array and sums every number it reaches: data.discounts[].amount adds up all discount amounts. Allowed characters are letters, digits, _, $ and - in each key. productRef reads the first non-empty string at its path.

Units. unit: "minor" means the numbers are already minor units. unit: "major" means decimals such as "12.34" or 12.34, scaled by the payment currency's exponent and rounded half-even on the exact digits.

Defaults. An unmapped or unreachable path reads as 0 for credit and discount. If cash is not mapped, the event's gross amount is used. Credit, discount and cost are stored as magnitudes. A path that holds something unreadable (not a number) is read as 0 and reported as a warning.

Versions. The built-in extractor is version 1, so your first mapping saves as version 2, and every later save adds one. The version is stored on each fact it extracts. Because a higher version supersedes a lower one, a new mapping's values replace the older version's for the same payment (see precedence).

Only events after the first mapping. A raw provider payload is stored only for connections that have a mapping. So a mapping applies to events Referalli receives after the connection first had a mapping, and a later version re-maps those on Rebuild numbers (or POST /v1/profitability/rebuild). Events received before the connection's first mapping keep the value the built-in extractor gave them, because there's no payload to re-read.

Check a mapping before you save it. The preview writes nothing, and takes either the saved mapping or a draft:

curl -X POST https://referalli.com/v1/billing/connections/bcon_01JB.../value-mapping/preview \
  -H "Authorization: Bearer rk_live_..." \
  -H "content-type: application/json" \
  -d '{
    "currency": "usd",
    "payload": { "data": { "object": { "amount_paid": 2400, "metadata": { "store_credit_used": 1000 } } } },
    "mapping": { "cash": "data.object.amount_paid", "creditApplied": "data.object.metadata.store_credit_used", "unit": "minor" }
  }'

The response is { mappingVersion, currency, value, wouldRecord }: the amounts the mapping reads, the raw numbers it used, any warnings, and the value events it would record. For a generic connection that batches events ({ "events": [...] }), it returns one result per element. Saving and previewing a mapping needs the owner or admin role, and saves are audited.

Inference

When a payment doesn't say how much credit it used, Referalli still has to decide whether that payment was cash. By default it infers, and labels the result:

  • Next payments, first in first out. For a credit grant with no reported use, each later payment that reports no credit applied is treated as paid, in part, with the credit: Referalli takes the smaller of the grant's remaining balance and that payment's cash, oldest payment first, until the grant is used up. The cash that credit covered is not counted as real money.

  • Same currency only. A grant is drawn down only by payments in its own currency.

  • Credit from reported payments is drawn first. When a payment does report credit applied, it is taken from the customer's open grants oldest grant first (grants_first, the default), or spread by remaining balance if you set creditDraw to pro_rata. Anything beyond the open grants' balances is "other credit" and is recorded nowhere.

  • Reports win. Once a grant has a reported use (a linked redemption, or a payment that reported credit applied), inference stops for that grant.

The profitability settings choose the rule: fifo_next_payments (default), on_grant (the whole face counts as used when granted) or report_only (never infer). The data-quality report shows what share of the figures is inferred. Reporting credit applied removes the guess.

Margin and cost settings

Cash becomes profit through a margin. There is no default margin: with none set, revenue is shown and profit stays unknown. Merchants set these under Settings → Credit ROI and Product margins in the dashboard; the API below does the same.

curl -X PUT https://referalli.com/v1/profitability/settings \
  -H "Authorization: Bearer rk_live_..." \
  -H "content-type: application/json" \
  -d '{ "defaultMarginBps": 6000, "windowDays": 180, "targetRoiBps": 20000 }'

Add "programId": "prg_..." to override a program (a program override can't change the reporting currency). Requires owner or admin; every change is audited. GET /v1/profitability/settings?programId=... returns the resolved settings.

Setting

Meaning

reportingCurrency

Currency every converted figure is shown in. Default usd. Org-level only.

defaultMarginBps

Margin in basis points (6000 is 60%). null means unknown.

creditCostModel

face (cost equals face value, the default), margin (face times one minus the margin) or fixed_ratio (face times creditCostRatioBps).

inference

fifo_next_payments (default), on_grant or report_only.

creditDraw

grants_first (default) or pro_rata.

windowDays

How long after a grant to count payments: 30, 90, 180 (default), 365, or 0 for lifetime.

churnWindowDays

Days without a payment after credit runs out that count as churn. Default 30.

countReferrerSpend

Count a linked referrer's own payments toward their credit. Default off.

costToServeMinorPerMonth

Optional cost to serve one active customer per month.

taxFeesReduceProfit

Subtract tax and fees before margin. Default off.

valuePatternWeight

0 to 1: how hard the credit patterns weigh on a partner's quality score. Default 0 (off).

targetRoiBps

Target ROI as a multiple in basis points (20000 is 2.0×). A program can set null to opt out of the org's target.

The margin for a payment comes from the first of these that exists:

  1. A costMinor on the payment itself.

  2. cost events linked to the payment.

  3. The product catalog entry for its productRef.

  4. The program's defaultMarginBps.

  5. The org's defaultMarginBps.

  6. None: profit is unknown.

The product catalog is PUT /v1/profitability/products/{productRef} with exactly one of marginBps or unitCostMinor (the cost of one payment), plus GET and DELETE on the same path and GET /v1/profitability/products.

After changing settings, mappings or history, Rebuild numbers in the dashboard, or POST /v1/profitability/rebuild replays your billing history into the value ledger and recomputes everything derived (allocations and rollups). It returns a job id; poll GET /v1/profitability/rebuild/{jobId} for status, processed and total.

Mobile app

Never put an API key in a mobile app: it can be extracted and used by anyone. Report from your own server, which already knows what the customer paid. The app tells your server about the purchase (or your server hears from the app store); your server calls Referalli.

import { ReferallyClient } from '@referally/sdk-js';

const referalli = new ReferallyClient({
  apiKey: process.env.REFERALLI_API_KEY!, // rk_live_..., server-side only
  baseUrl: 'https://referalli.com',
});

// In the handler your app calls after a purchase is verified:
await referalli.reportPayment({
  customerRef: user.id,
  cashMinor: 999,
  creditAppliedMinor: 0,
  currency: 'usd',
  externalId: `iap-${transactionId}`, // the store's transaction id: a retry won't double count
  occurredAt: new Date().toISOString(),
});

The server client has one helper per kind: reportPayment, reportCreditGranted (needs role and faceMinor), reportCreditUsed (needs creditAppliedMinor), reportCost (needs costMinor), and reportValueEvents for a batch. See the SDKs page for the client options. For attributing the purchase to a referral in the first place, see In-app purchases.

Zapier

The Report payment or credit action in the Zapier app sends one value event. Its amount is entered in the currency's main unit (49.00) and converted for you, and its Event id is the externalId, so a replayed Zap records nothing twice.

Read the results

All of these accept from, to, programId, partnerId, windowDays and currency, and return CSV with format=csv. They need the analytics scope.

Call

Returns

GET /v1/roi/summary

Credit cost, real money after, profit, net, ROI, share paid back, median payback days, coverage, and the outstanding credit liability. currencies lists the currencies with credit. Add model=incremental&programId=... for the lift against the program's holdout, includeProjection=true for expected payback of open grants, and read the program's target status under target when programId is set.

GET /v1/roi/breakdown?by=

The same figures per program, partner, cohort, source or role.

GET /v1/roi/grants

Every credit grant with its status, use and payback. Filter by status (open, used, expired, revoked), role and paidBack.

GET /v1/roi/trace/{customerRef}

One customer's timeline of grants, uses, payments and costs, with a running net.

GET /v1/roi/partners/{id}/trace

The same for a partner and their referrals, including commissions.

GET /v1/roi/patterns

All four named patterns in one call. GET /v1/roi/patterns/{pattern} returns one: credit_only, churn_at_exhaustion, bonus_collector or negative_partner.

POST /v1/roi/patterns/{pattern}/review

Turn one match into a fraud case. Owner or admin.

GET /v1/roi/data-quality

How complete the data is: payments reporting credit, products with a margin, partners linked to a customer id, and the gaps with a fix for each.

In the summary, profitAfterMinor, netMinor and roi are null when profit is unknown. null means unknown, never zero. roi is profit after credit divided by credit cost (2.1 means each unit of credit returned 2.1).

Webhooks and tools

The webhook topics billing_event.recorded, credit.redeemed, roi.payback_reached and roi.pattern_detected let you react to this data; referally trigger <topic> sends a sample of each. The MCP server has read-only roi_summary, roi_trace and roi_patterns tools, so an assistant can explain a figure from the same data.

Updated