Push your own billing data with a Custom Source
If some of your revenue does not run through Stripe or Paddle - you invoice customers directly, you are on a processor Chartsy does not support yet, or your billing lives in your own database - send it over the API and get the same dashboards, charts and AI answers as everyone else.
| Base URL | Auth | Batch size | Rate limit |
|---|---|---|---|
https://dashboard.chartsy.app/api/v1 | Bearer csk_… | 500 records | 600 requests / minute |
Where your data should go#
This is the first decision, and it matters more than anything else on this page.
Into a data source you already have - most people. The data belongs to the same business as your Stripe or Paddle revenue: invoices you raise by hand, a customer who pays by bank transfer. Everything lands in one dashboard and one MRR figure, and a customer who exists in both is counted once.
As a separate Custom Source. It is genuinely a different stream - a second product, or a processor like Polar.sh you want charted under its own name. It gets its own card and its own name in the picker, and totals still combine under All Accounts.
When in doubt, choose the first. A separate source splits your numbers across two cards, and a person who appears in both counts as two customers in customer counts and ARPU.
On plan limits
A Custom Source is a new data source and counts against your plan's limit. Pushing into a source you already have does not, so it is available whatever your plan allows.
Step one: get your key#
Into an existing source: Data Sources → the source's card → API access → Issue a key. The panel shows your endpoint and your key, and lists any keys the source already has. You can revoke a key from the same place.
As a new source: Data Sources → Add Data Source → Custom Source, or during onboarding under Other → Connect it yourself. Name it - "Polar", "In-house billing", whatever you call it - and Chartsy shows the endpoint and key straight away. The name is what the source is called everywhere:
All Accounts (Aggregated)
#1 Acme - Stripe
#2 Acme - PolarCopy the key when it is shown
Chartsy stores only a hash of it, so it cannot be displayed again. If you lose it, issue a new one and revoke the old.
Your first call, once you have a key:
curl -X POST https://dashboard.chartsy.app/api/v1/customers/ \
-H "Authorization: Bearer $CHARTSY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"customer_id": "cus_1", "email": "ada@example.com", "country": "GB"}'A key is bound to one source, and no request carries a source id, so a key can only ever read and write its own source's data.
Step two: how MRR is measured#
Worth reading twice, because everything else is forgiving and this is not. Chartsy computes MRR from invoice line items, not from subscription records. Each line that carries a price and a service period contributes its amount, normalised to a month, to every month that period covers.
| What you send | Resulting MRR |
|---|---|
| Monthly plan at $49, twelve invoices across the year | $49.00 in every month |
| Annual plan at $588, one invoice with a 1 Jan → 1 Jan service period | $49.00 in every month - the line is spread across the twelve months it covers |
| A subscription posted with no invoices behind it | $0.00, all year |
A month that no invoice period covers reads as zero for that subscription - including the current month, if this month's invoice has not been sent yet.
The rule: one invoice per subscription per billing period
For every period you want on the chart. A monthly plan running since January 2024 needs about 20 invoices; an annual plan needs one per year. This is exactly how Chartsy measures a Stripe account - Stripe keeps issuing invoices, and so must you.
What makes a line count
All five have to hold:
| Requirement | Why |
|---|---|
Invoice status is paid, open, completed or past_due | Anything else - draft, void, uncollectible - is excluded |
Invoice amount_paid is above 0 | An issued-but-unpaid invoice counts for nothing until it is paid |
The line carries a price_id | The price carries the billing interval |
The line carries period_start and period_end | These are the months the amount is spread across |
| The line is tied to a subscription | By subscription_id, or a nested subscription block |
Cancelling is separate: a subscription with status: "canceled" and a canceled_at stops counting from that timestamp, whatever invoices exist. Churn is measured from it, which is why Chartsy rejects a cancellation that arrives without one.
If your system has no concept of a subscription
Put a subscription block on an invoice line and Chartsy creates the subscription from the first invoice and extends it on each later one - the same shape ChartMogul's Import API works in.
POST /api/v1/invoices/
{
"invoice_id": "inv_001",
"created_date": "2026-02-01T00:00:00Z",
"currency": "USD", "status": "paid",
"amount_due": "49.00", "amount_paid": "49.00", "amount_remaining": "0.00",
"customer_id": "cus_0001",
"line_items": [{
"amount": "49.00", "currency": "USD", "price_id": "pr_pro",
"subscription": {
"subscription_id": "sub_0001",
"current_period_start": "2026-02-01T00:00:00Z",
"current_period_end": "2026-03-01T00:00:00Z"
}
}]
}Send the same subscription_id next month and the same subscription is extended. Setting canceled_at in that block records churn with no separate call.
Step three: load your history#
Decide how far back you want history - two or three years is usually plenty, and it is where your MRR chart will start. Then send in this order, because each resource references the ones before it:
discounts -> customers -> customer-businesses -> products -> prices
-> subscriptions -> INVOICES -> transactions -> adjustments- Catalog and customers. Every product, price, discount and customer you have ever used, including retired plans and churned customers - an old invoice still references them. Send one price per plan per interval: monthly and annual are two prices.
- Subscriptions, current and historical. Cancelled ones need
canceled_at. - Invoices - the bulk of the work. One per subscription per billing period across the whole history, each recurring line carrying
price_id,period_startandperiod_end. - Transactions and adjustments, for revenue, fees, failed payments and refunds.
Order matters between those steps. Within the history it does not - send 2024 before 2023 if that is what your export gives you. Start dates only ever move earlier, billing periods only ever move later, and only the newest invoice defines what a subscription currently contains, so replaying an old invoice cannot roll a live subscription backwards.
Batch it: post an array of up to 500 records per request, 600 requests per minute - a few hundred thousand records an hour. A batch is atomic, so if one record is bad nothing in that request is written; fix it and re-send the whole batch.
Connecting Stripe or Paddle as well?
Do that first and let it finish. Your processor history arrives on its own, and you only need to backfill what the processor does not know about.
Step four: keep it current#
There are two shapes, and which one fits depends on where the data comes from.
If you own the billing code, push when it happens
The simplest and most current option. When your system issues an invoice, takes a payment or cancels a subscription, post it then. No schedule, no window, no record of what you have already sent.
| When this happens | Post this |
|---|---|
| Invoice issued | POST /invoices/ |
| Payment taken or failed | POST /transactions/ |
| Subscription changed or cancelled | POST /subscriptions/ with its current state |
| Refund or chargeback | POST /adjustments/ |
Every write is an upsert keyed on your own id, so a retry after a timeout is free and a duplicate send does nothing.
If you are reading someone else's processor, run a job on a schedule
When there is no billing event to hook into - you are syncing from Polar, Gumroad or similar - poll on a schedule and re-send a trailing window: everything created or changed since a couple of days before your last successful run. The upserts absorb the overlap, so you do not need a watermark or a cursor.
Either way: a renewal is a new invoice
Miss those and your MRR decays to zero as the last periods you sent run out.
Running more than one worker
Safe, with no coordination on your side. Two requests carrying the same record take turns, and the rare case where neither can proceed comes back as a 409 saying nothing was written - sleep briefly and send the identical batch again. You will get more throughput from workers that split the data by customer than from several sending overlapping ranges.
Checking it is working
Your source's card on the Data Sources page shows Last Received, and flags a source that has not sent anything in a while. If your integration dies quietly at 3am, that is where you will see it.
Shortcut: hand it to your AI agent#
The agent brief is written to be implemented without any other document. Paste it into Claude Code, Cursor, Codex or whatever you use, fill in three placeholders, and let it write both jobs against your billing system.
<MY BILLING SYSTEM>- where the data comes from: Polar.sh, Lemon Squeezy, Chargebee, your own database<START DATE>- how far back to backfill<MY STACK>- language and runtime the jobs should be written in
# Build a Chartsy integration — agent brief
Paste this whole file to your coding agent (Claude Code, Cursor, Codex, or similar) and
tell it which billing system to read from. It is written to be implementable without any
other document.
---
## Your task
Write two jobs that push billing data from `<MY BILLING SYSTEM>` into Chartsy's ingest API:
1. **A backfill script**, run once, that loads all history from `<START DATE>` to today.
2. **A sync job**, run on a schedule, that keeps Chartsy current.
Language and runtime: `<MY STACK>`. Read credentials from the environment; never hardcode.
## Connection
- Base URL: `https://dashboard.chartsy.app/api/v1`
- Auth header on every request: `Authorization: Bearer $CHARTSY_API_KEY` (keys start `csk_`)
- `Content-Type: application/json`
- Limits: **500 records per request**, **600 requests per minute** per key. A `429` means
back off and retry.
## Non-negotiable rules
1. **Money goes in one of two fields, and the field name states the unit.**
`"amount": "49.00"` is major units. `"amount_in_cents": 4900` is the same money in the
currency's smallest unit. Send whichever my source already uses — if it exports cents,
use the `_in_cents` form and do not divide anything yourself. Every money field has both
spellings (`amount_due_in_cents`, `tax_in_cents`, `fee_in_cents`, `discount_in_cents`,
`amount_off_in_cents`, `net_amount_in_cents`, …). Never send both for the same amount.
An `_in_cents` field takes a **whole number only** — `"49.00"` there is rejected, not
silently read as 49 cents. Note that "cents" follows the currency: JPY has no minor
unit, so `{"currency": "JPY", "amount_in_cents": 4900}` is ¥4,900.
2. **All timestamps are ISO 8601 with a timezone.** `"2026-02-01T00:00:00Z"`.
3. **All currencies are 3-letter ISO 4217 codes**, uppercase.
4. **Every id is the id from my system**, not one Chartsy assigns. Posting the same id again
updates that record. There is no mapping table to keep and no Chartsy id to store.
5. **Unknown fields are rejected with a 400.** Do not invent field names. If my source has
data that does not fit a listed field, put it in `metadata` (any JSON object, queryable
afterwards) — on every resource **except adjustments**, which take none.
6. **A payload is the record's complete state, not a patch.** Nested lists (`items`,
`line_items`, `discounts`) replace what was stored. To remove a subscription add-on,
re-post the subscription with only the remaining items.
7. **References must already exist.** A missing one is a `400` naming it — never a silently
created stub. Hence the sending order below.
8. **Send a line's pre-tax amount, and the discount beside it.** Chartsy counts
`amount - discount` per line. Use whichever field matches the data you have — the name
states the convention, and exactly one may be sent:
- `amount` (invoice line) / `total` (transaction item) — already pre-tax, Stripe's shape
- `subtotal` — the same thing under Paddle's name
- `amount_including_tax` / `total_including_tax` **plus** `tax` — gross, and Chartsy
subtracts the tax
Never pre-subtract the discount: put it in `discount` and Chartsy takes it off once.
Getting this wrong is the most common reason a customer's MRR disagrees with their
billing system by a small amount.
9. **A batch is atomic.** One bad record means nothing in that request is written. Fix and
re-send the whole batch; re-sending is always safe because every write is an upsert.
## Sending order
```
discounts → customers → customer-businesses → products → prices
→ subscriptions → invoices → transactions → adjustments
```
Two shortcuts so the common case is one call: a subscription may carry a nested `customer`
object instead of `customer_id`, and a price may carry a nested `product` instead of
`product_id`. Send one or the other, never both.
## THE MOST IMPORTANT RULE: MRR comes from invoices
Chartsy computes MRR from **invoice line items**, not from subscription records. Each line
that carries a `price_id` and a service period contributes its amount, normalised to a
month, to every month that period covers.
Therefore:
- A subscription with no invoices behind it produces **zero MRR**.
- A month that no invoice period covers reads **zero** for that subscription.
- You must send **one invoice per subscription per billing period**, for every period that
should appear on the chart — across the whole backfill, and again on every renewal.
A monthly plan running since January 2024 needs ~20 invoices. An annual plan needs one
per year: a single line with a 12-month period covers all twelve months.
A line only counts toward MRR if **all** of these hold:
| Requirement | Why |
|---|---|
| Invoice `status` is one of `paid`, `open`, `completed`, `past_due` | Other values (`draft`, `void`, `uncollectible`, anything unrecognised) are excluded |
| Invoice `amount_paid` is greater than `0` | An issued-but-unpaid invoice contributes nothing until it is paid |
| The line has a `price_id` | The price carries the billing interval |
| The line has `period_start` **and** `period_end` | These are the months the amount is spread across |
| The line is linked to a subscription, via `subscription_id` or a nested `subscription` | Unlinked lines count as revenue but not as recurring |
Cancelling is separate: a subscription with `status: "canceled"` and a `canceled_at` stops
counting from that timestamp, whatever invoices exist.
## Endpoints and fields
`metadata` (optional JSON object) exists on every resource below except adjustments. Fields not listed are
rejected.
### `POST /products/`
Required: `product_id`, `plan_name`.
Optional: `description`, `tax_category`, `active` (default true), `created_date`.
`plan_name` is what appears in every plan breakdown.
### `POST /prices/`
Required: `price_id`, `amount`, `currency`, and exactly one of `product_id` or nested `product`.
Optional: `interval` (`day`|`week`|`month`|`year`|`one_time`), `interval_count`
(`3` + `month` = quarterly), `price_name`, `usage_type`, `exchange_rate`, `active`, `created_date`.
One price per plan **per billing interval** — monthly and annual are two prices.
Only recurring intervals count toward MRR; `one_time` is stored and excluded.
### `POST /discounts/`
Required: `coupon_id`, `duration` (`once`|`repeating`|`forever`), `created_date`, and exactly
one of `amount_off` or `percent_off`. `amount_off` also requires `currency`.
`duration_in_months` is required when `duration` is `repeating`.
Optional: `code` (what customers type, e.g. `SAVE10`), `active`, `exchange_rate`.
### `POST /customers/`
Required: `customer_id`.
Optional: `email` (groups a person's subscriptions in lifetime-value charts), `name`,
`country` (drives geography charts), `city`, `currency`, `balance`, `delinquent`,
`default_payment_method`, `coupon_id`, `created_date`.
### `POST /customer-businesses/`
Required: `customer_business_id`, `customer_id`, `created_date`.
Optional: `name`, `company_number`, `tax_identifier`, `status`, `contacts`.
### `POST /subscriptions/`
Required: `subscription_id`; `status`; `created_date`; exactly one of `customer_id` or nested
`customer`; `items` (1–50).
`status` is one of `active`, `trialing`, `past_due`, `paused`, `unpaid`, `canceled`,
`incomplete`, `incomplete_expired`.
**`canceled_at` is required when `status` is `canceled`** — churn is measured from it, so
Chartsy refuses the record rather than under-report churn.
Optional: `started_at` (defaults to `created_date`), `current_period_start`,
`current_period_end`, `paused_at`, `trial_start`, `trial_end`, `cancel_at_period_end`,
`latest_invoice_id`, `default_payment_method`, `discounts`.
Each item: exactly one of `price_id` or nested `price`, plus optional `quantity` (default 1),
`amount`, `subscription_item_id`, `created_date`. Two items on the same `price_id` need
explicit `subscription_item_id` values to tell them apart.
### `POST /invoices/`
Required: `invoice_id`, `created_date`, `currency`, `status`, `amount_due`, `amount_paid`,
`amount_remaining`.
Optional: `customer_id`, `subscription_id`, `line_items` (up to 250), `tax`, `due_date`,
`period_start`, `period_end`, `next_payment_attempt`, `description`, `exchange_rate`,
`discounts`.
Each line item: `amount` and `currency` required. Optional `price_id`, `quantity`,
`discount`, `period_start`, `period_end`, `description`, `invoice_line_item_id`,
`prorated`, `exchange_rate`, and either `subscription_id` (reference an existing one) or
`subscription` (derive one — see below), never both.
Mark part-period charges `"prorated": true` so the amount is not taken as the
subscription's recurring amount, which would understate MRR for as long as it lives.
A line with no `price_id` is fine — its money still counts in the invoice total, it just
gets no per-plan attribution, which is correct for a one-off service line.
### Invoice-first (if my system has no concept of a subscription)
Put a `subscription` block on the line item instead of a `subscription_id`, and Chartsy
creates the subscription from the first invoice and extends it on each later one:
```json
{
"invoice_id": "inv_001",
"created_date": "2026-02-01T00:00:00Z",
"currency": "USD", "status": "paid",
"amount_due": "49.00", "amount_paid": "49.00", "amount_remaining": "0.00",
"customer_id": "cus_0001",
"line_items": [{
"amount": "49.00", "currency": "USD", "price_id": "pr_pro",
"subscription": {
"subscription_id": "sub_0001",
"current_period_start": "2026-02-01T00:00:00Z",
"current_period_end": "2026-03-01T00:00:00Z"
}
}]
}
```
The block requires `subscription_id`; the line must carry a `price_id`, and the invoice a
`customer_id`. It also accepts `status`, `started_at`, `canceled_at`, `trial_start`,
`trial_end`, `cancel_at_period_end` — setting `canceled_at` records churn with no separate
call. Send the same `subscription_id` next period and the same subscription is extended.
### `POST /transactions/`
Required: `transaction_id`, `amount`, `type`, `status`, `created_date`.
`type`: `payment`, `refund`, `credit`, `debit`, `adjustment`, `payout`, `invoice`.
`status`: `succeeded`, `failed`, `pending`, `draft`, `ready`, `billed`, `paid`, `completed`,
`canceled`, `refunded`, `past_due`.
Optional: `currency`, `customer_id`, `customer_business_id`, `subscription_id`, `invoice_id`,
`related_transaction_id` (the payment a refund reverses), `items`, `fee`, `tax`,
`net_amount` (what actually landed), `amount_refunded`, `description`,
`payment_method_type`, `payment_intent_id`, `available_on`, `disputed`, `dispute_id`,
`exchange_rate`, `discounts`, and `failure_code`/`failure_message` — **only valid when
`status` is `failed`**.
Each item: `price_id` and `total` required; optional `transaction_item_id`, `quantity`,
`discount`, `type`, `period_start`, `period_end`.
An invoice is what was billed; a transaction is the money arriving. Send both — they are
separate records, exactly as Chartsy stores them for Stripe.
### `POST /adjustments/` — refunds, chargebacks, credits
Required: `adjustment_id`, `transaction_id` (must already exist), `amount`, `currency`,
`adjustment_type` (`refund`|`chargeback`|`credit`|`other`), `status`
(`approved`|`pending`|`failed`), `adjustment_date`.
**`amount` must be negative for refunds and chargebacks**, positive for credits — revenue
queries add it to gross revenue, so a positive refund would increase reported revenue.
Chartsy rejects that with a `400`.
Optional: `reason`, `created_date`, `items` (each needs `transaction_item_id`). **No `metadata` on this resource** — sending one is a `400`.
### Reading back and deleting
```
GET /api/v1/subscriptions/{my_id}/ # returns what Chartsy stored, in my ids
DELETE /api/v1/invoices/{my_id}/ # removes it and its nested children
```
Deleting leaves referenced records (customer, product, price) alone.
## Sending one or many
A single object, or an array of up to 500:
```bash
curl -X POST https://dashboard.chartsy.app/api/v1/subscriptions/ \
-H "Authorization: Bearer $CHARTSY_API_KEY" \
-H "Content-Type: application/json" \
-d '[{...}, {...}]'
```
Response: `{"count": 2, "results": [{"subscription_id": "sub_1", "id": 4021, "created": true}, ...]}`.
`201` when everything was new, `200` when anything was an update.
## Errors
| Status | Meaning | What to do |
|---|---|---|
| `400` | Validation failed; the body names each bad field, including inside nested objects | Fix the payload. Do not retry unchanged |
| `401` | Missing, unknown or revoked key | Stop; the key is wrong |
| `403` | Key is valid but the account cannot be written to (usually an ended trial) | Stop; tell the user |
| `404` | No such record on this key's account | Check the id |
| `409` | Another request was writing the same records; nothing was written | Sleep briefly and send the identical batch again |
| `429` | Rate limited | Back off and retry |
| `5xx` | Server error | Retry with exponential backoff; the batch was atomic, so nothing partial was written |
Log the full response body on any `400`. It names the exact field, e.g.
`{"canceled_at": ["Required when status is 'canceled'."]}`.
## What to build
**Backfill script** — one run, in the sending order above:
catalog and customers (including retired plans and churned customers, since old invoices
reference them) → subscriptions, current and historical → **invoices for every billing
period in the history** → transactions and adjustments.
Batch 500 at a time. Parallel workers are safe — but give each one its own slice of the
data (split by customer, for instance) rather than overlapping ranges, and handle `409` by
resending. History order does not matter: start dates only move earlier, billing
periods only move later, and only the newest invoice defines a subscription's current
contents, so replaying an old invoice cannot roll a live subscription backwards.
**Sync job** — on a schedule, sending whatever changed:
a new invoice for every billing period that started (miss these and MRR decays to zero as
the last periods run out), transactions as payments succeed or fail, changed subscriptions
including cancellations with `canceled_at`, and adjustments for refunds.
Because every write is an upsert keyed on my ids, re-send a trailing window — everything
created or changed since a couple of days before the last successful run — and let the
upserts absorb the overlap. Do not build a watermark table or track what was already sent.
## Before you tell me it's done
- [ ] Re-run the backfill script. It must be safe: same totals, nothing duplicated.
- [ ] `GET` one subscription, one invoice and one transaction back, and check the values
match my source system.
- [ ] Confirm every recurring invoice line has `price_id`, `period_start` and `period_end`.
- [ ] Confirm every cancelled subscription has a `canceled_at`.
- [ ] Spot-check one amount against my billing system, and confirm which field you used:
`amount` for major units, `amount_in_cents` for minor. A 100x error is the most
common failure in this integration.
- [ ] Report the total counts pushed per resource, and any record the API rejected.
- [ ] Tell me to compare the MRR on the Chartsy dashboard with my own number. If Chartsy is
low, the usual cause is invoices missing a price or a service period on their
recurring lines.
Prefer the raw file? Open it directly - it is the same document, served as plain Markdown.
It includes an acceptance checklist, so your agent should finish by re-running its own backfill, reading records back out of Chartsy, and reporting what it pushed and what was rejected.
When the numbers look wrong#
| Symptom | Usual cause |
|---|---|
| MRR is zero, though subscriptions are there | No invoices, or invoice lines missing price_id or their service period |
| MRR drops off a cliff at the current month | This period's invoices have not been sent yet |
| MRR is lower than your own figure | Some invoices are in a status that does not count, or have amount_paid of 0 |
| Everything is 100x too big | Cents sent in a major-units field - use the _in_cents twin instead of dividing by hand |
| Churn shows nothing | Cancelled subscriptions sent without canceled_at |
| Revenue went up after a refund | Refund amount must be negative |
| A customer appears twice | The same person was pushed to a separate Custom Source and synced from Stripe. Push into the Stripe source instead, using their Stripe customer id, and the records merge |
| A prorated charge inflated a plan's MRR | Mark part-period lines "prorated": true |
Nothing is silently zero
For anything you do not send, Chartsy answers "that data is not available for this account" rather than reporting a zero that looks like a real answer.
Questions, or a processor you would like natively supported: support@chartsy.app.


