Chartsy Ingest API: object reference
A field-level reference for every object you can push to Chartsy. This page assumes you have read the narrative guide and are now writing the code.
If you want the narrative version - how to think about MRR, how to plan a backfill, how to map a processor you are migrating from - read Push your own billing data first.
| Base URL | Auth | Content type |
|---|---|---|
https://dashboard.chartsy.app/api/v1 | Authorization: Bearer csk_… on every request | application/json |
Keep your key server-side#
An ingest key writes to your billing data - anyone holding it can post revenue to your account. Treat it like a database password: it belongs on a server you control, in an environment variable or a secrets manager, never in browser JavaScript, a mobile app, or a committed file. The csk_ prefix exists so secret scanners can spot one that has leaked.
A key is shown once, at creation - Chartsy stores only a hash and cannot show it to you again. Issue one key per integration so a leak costs you one revocation instead of all of them, and revoke from Data Sources → API access. Revocation takes effect on the next request.
Conventions#
These hold for every object on this page. They are the things that, if you skip them, produce data that validates and lands and is quietly wrong.
Ids are yours
Every object is addressed by your id - customer_id, invoice_id, price_id. Chartsy never asks you to store an id it generated. Ids must be unique per resource within one source, and they are what makes a re-post an update rather than a duplicate.
Every POST is an upsert
POST /customers/ with a customer_id you have sent before updates that customer. There is no PUT and no PATCH. This is what makes a failed job safe to re-run: send the same batch twice and you converge on the same rows.
A payload is the full state, not a patch
Nested collections - items, line_items, discounts - are treated as the parent's complete state. Anything stored against the parent and missing from your payload is deleted.
// First post: two items
{ "subscription_id": "sub_1", "items": [ {...seats}, {...addon} ] }
// Second post: one item. The add-on row is DELETED, not left behind.
{ "subscription_id": "sub_1", "items": [ {...seats} ] }Always send the whole object. A subscription posted without its items is rejected (items has min_length: 1); an invoice posted without line_items silently empties it.
References must already exist
A field ending in _id that points at another object - price_id, customer_id, coupon_id - must name a record that is already on this source. Chartsy will not create a stub for you; a missing reference is a 400 naming the record and the endpoint to create it on. Send objects in dependency order.
Two fields let you avoid the round trip by nesting instead of referencing: price on a subscription item, customer on a subscription. Send exactly one of the nested object or the _id reference - sending both, or neither, is a 400.
Money
Amounts are in major units: "49.00" means $49.00. Send them as strings to avoid float rounding.
Any money field also accepts an _in_cents suffix carrying an integer in the currency's smallest unit:
{ "amount": "49.00" } // major units
{ "amount_in_cents": 4900 } // identicalThe exponent follows ISO 4217, not a hardcoded 100 - JPY has none (amount_in_cents: 4900 is ¥4900), KWD has three. Use the suffix when you are mapping from a processor export; it is the one place the unit cannot be forgotten. Keys ending in _in_cents inside metadata or contacts are left alone - those are yours.
Dates
ISO 8601 with an offset: "2026-01-15T09:30:00Z". Naive datetimes are accepted but interpreted in the account's timezone, so send the offset.
Unknown fields are rejected
A typo is a 400 naming the field, not a silently ignored key. {"canceled_date": …} on a subscription tells you canceled_date: "Unrecognised field." rather than accepting a subscription with no cancellation.
Batching#
Every endpoint takes a single object or an array of up to 500.
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"},{"customer_id":"cus_2"}]'The batch is one transaction - if record 7 fails, nothing in the batch is written, and the error is keyed by index:
{ "6": { "currency": ["Required when amount_off is set."] } }Response:
{
"count": 2,
"results": [
{ "customer_id": "cus_1", "id": 4471, "created": true },
{ "customer_id": "cus_2", "id": 4472, "created": false }
]
}201 when every record in the batch was new, 200 when any was an update. id is Chartsy's internal primary key - you never need it.
Limits
| Limit | Value |
|---|---|
| Records per POST | 500 |
| Items per subscription | 50 |
| Line items per invoice | 250 |
| Items per transaction | 250 |
| Items per adjustment | 250 |
| Discount applications per object | 250 |
| Requests per minute, per key | 600 |
Sending order#
Each object references the ones above it. A backfill sends them in this order:
discounts
└─ customers (optional coupon_id)
└─ customer-businesses (customer_id)
products
└─ prices (product_id, or nested product)
└─ subscriptions (customer_id + price_id per item)
└─ invoices (customer_id, subscription_id, price_id per line)
└─ transactions (invoice_id, subscription_id, customer_id)
└─ adjustments (transaction_id, transaction_item_id)You do not need every object. Either send subscriptions directly, or send invoices whose line items carry a subscription block and let Chartsy derive them.
Discount#
A coupon definition. Applying it to something is a separate step - see Discount application.
{
"coupon_id": "SUMMER25",
"code": "SUMMER25",
"duration": "repeating",
"duration_in_months": 3,
"percent_off": "25.00",
"created_date": "2026-01-01T00:00:00Z",
"active": true
}| Field | Type | Required | Notes |
|---|---|---|---|
coupon_id | string ≤255 | yes | Your id |
duration | enum | yes | once · repeating · forever |
created_date | datetime | yes | |
amount_off | decimal(15,2) ≥0 | one of | Fixed amount off |
percent_off | decimal(5,2) 0–100 | one of | Percentage off |
currency | string(3) | conditional | Required when amount_off is set |
duration_in_months | integer ≥1 | conditional | Required when duration is repeating |
code | string ≤100 | no | The code a customer types |
exchange_rate | decimal(15,4) ≥0 | no | Defaults to today's rate |
active | boolean | no | Default true |
metadata | object | no | Default {} |
Send exactly one of amount_off or percent_off. Sending both is a 400.
Customer#
{
"customer_id": "cus_8817",
"email": "ada@example.com",
"name": "Ada Lovelace",
"country": "GB",
"city": "London",
"currency": "GBP",
"created_date": "2026-01-15T09:30:00Z",
"metadata": { "plan_at_signup": "growth" }
}| Field | Type | Required | Notes |
|---|---|---|---|
customer_id | string ≤255 | yes | Your id |
email | no | ||
name | string ≤255 | no | |
country | string ≤100 | no | ISO 3166 code preferred - drives MRR by country |
city | string ≤100 | no | |
currency | string ≤10 | no | |
balance | string ≤100 | no | Free text, stored as given |
delinquent | boolean | no | Default false |
default_payment_method | string ≤255 | no | |
coupon_id | string ≤255 | no | Reference to a discount already posted |
created_date | datetime | no | Signup date - drives cohort reporting |
metadata | object | no | Default {} |
A customer can also be created inline from a subscription - see the customer field on Subscription.
Customer business#
The company behind a customer, for B2B billing.
{
"customer_business_id": "biz_204",
"customer_id": "cus_8817",
"name": "Analytical Engines Ltd",
"company_number": "09876543",
"tax_identifier": "GB123456789",
"status": "active",
"created_date": "2026-01-15T09:30:00Z",
"contacts": { "billing": "ap@example.com" }
}| Field | Type | Required | Notes |
|---|---|---|---|
customer_business_id | string ≤255 | yes | Your id |
customer_id | string ≤255 | yes | Must already exist |
created_date | datetime | yes | |
name | string ≤255 | no | |
company_number | string ≤255 | no | |
tax_identifier | string ≤255 | no | VAT / tax number |
status | string ≤100 | no | |
contacts | object | no | Free-form; not walked for _in_cents |
metadata | object | no | Default {} |
Product#
The plan. One product has many prices.
{
"product_id": "prod_growth",
"plan_name": "Growth",
"description": "Up to 25 seats",
"active": true,
"created_date": "2025-06-01T00:00:00Z"
}| Field | Type | Required | Notes |
|---|---|---|---|
product_id | string ≤255 | yes | Your id |
plan_name | string ≤255 | yes | The name shown in MRR-by-plan |
description | string | no | |
tax_category | string ≤255 | no | |
active | boolean | no | Default true |
created_date | datetime | no | |
metadata | object | no | Default {} |
Price#
An amount and a billing interval. This is where MRR comes from - a price with no interval is treated as one-off and contributes nothing recurring.
{
"price_id": "price_growth_monthly",
"product_id": "prod_growth",
"amount": "49.00",
"currency": "USD",
"interval": "month",
"interval_count": 1,
"price_name": "Growth - monthly",
"active": true
}Or with the product inline, if it does not exist yet:
{
"price_id": "price_growth_monthly",
"product": { "product_id": "prod_growth", "plan_name": "Growth" },
"amount": "49.00",
"currency": "USD",
"interval": "month"
}| Field | Type | Required | Notes |
|---|---|---|---|
price_id | string ≤255 | yes | Your id |
product_id | string ≤255 | one of | Reference to an existing product |
product | object | one of | A Product, created inline |
amount | decimal(15,2) ≥0 | yes | |
currency | string(3) | yes | ISO 4217 |
interval | enum | no | day · week · month · year · one_time. Null = non-recurring |
interval_count | integer ≥1 | no | Requires interval. interval: "month", interval_count: 3 = quarterly |
usage_type | string ≤20 | no | |
price_name | string ≤255 | no | |
exchange_rate | decimal(15,4) ≥0 | no | Defaults to today's rate to the account currency |
active | boolean | no | Default true |
created_date | datetime | no | |
metadata | object | no | Default {} |
Send exactly one of product or product_id.
Subscription#
{
"subscription_id": "sub_5521",
"status": "active",
"created_date": "2026-01-15T09:30:00Z",
"started_at": "2026-01-15T09:30:00Z",
"current_period_start": "2026-03-15T09:30:00Z",
"current_period_end": "2026-04-15T09:30:00Z",
"customer_id": "cus_8817",
"items": [
{ "price_id": "price_growth_monthly", "quantity": 3 }
],
"discounts": [
{ "coupon_id": "SUMMER25" }
]
}| Field | Type | Required | Notes |
|---|---|---|---|
subscription_id | string ≤255 | yes | Your id |
status | enum | yes | See below |
created_date | datetime | yes | |
customer_id | string ≤255 | one of | Reference to an existing customer |
customer | object | one of | A Customer, created inline |
items | array(1–50) | yes | Subscription items |
started_at | datetime | no | |
current_period_start | datetime | no | |
current_period_end | datetime | no | |
canceled_at | datetime | conditional | Required when status is canceled |
paused_at | datetime | no | |
trial_start | datetime | no | |
trial_end | datetime | no | |
cancel_at_period_end | boolean | no | Default false. Excluded from MRR |
latest_invoice_id | string ≤255 | no | |
default_payment_method | string ≤255 | no | |
discounts | array ≤250 | no | Discount applications |
metadata | object | no | Default {} |
status — active · trialing · past_due · paused · unpaid · canceled · incomplete · incomplete_expired
Rules
status: "canceled"requirescanceled_at. Churn is measured on that exact pair, so a cancellation with no timestamp is invisible to every churn number in the product - it is refused rather than accepted and under-reported.current_period_endmust be on or aftercurrent_period_start; same fortrial_end/trial_start.- Two items on the same
price_idneed explicitsubscription_item_idvalues to tell them apart, otherwise they would fight over one row.
Subscription item
| Field | Type | Required | Notes |
|---|---|---|---|
price_id | string ≤255 | one of | Reference to an existing price |
price | object | one of | A Price, created inline |
subscription_item_id | string ≤255 | no | Needed only to disambiguate repeats |
quantity | integer ≥0 | no | Default 1 - seats |
amount | decimal(10,2) ≥0 | no | Overrides the price's amount for this item |
created_date | datetime | no | |
metadata | object | no | Default {} |
Invoice#
{
"invoice_id": "in_9001",
"created_date": "2026-03-15T09:30:00Z",
"currency": "USD",
"status": "paid",
"amount_due": "147.00",
"amount_paid": "147.00",
"amount_remaining": "0.00",
"customer_id": "cus_8817",
"subscription_id": "sub_5521",
"period_start": "2026-03-15T09:30:00Z",
"period_end": "2026-04-15T09:30:00Z",
"line_items": [
{
"invoice_line_item_id": "il_1",
"amount": "147.00",
"currency": "USD",
"quantity": 3,
"price_id": "price_growth_monthly",
"period_start": "2026-03-15T09:30:00Z",
"period_end": "2026-04-15T09:30:00Z"
}
]
}| Field | Type | Required | Notes |
|---|---|---|---|
invoice_id | string ≤255 | yes | Your id |
created_date | datetime | yes | |
currency | string(3) | yes | |
status | string ≤20 | yes | Free text - paid, open, void, … |
amount_due | decimal(…,4) | yes | |
amount_paid | decimal(…,4) | yes | |
amount_remaining | decimal(…,4) | yes | |
customer_id | string ≤255 | conditional | Required if any line derives a subscription |
subscription_id | string ≤255 | no | |
line_items | array ≤250 | no | Invoice line items |
tax | decimal(…,4) | no | |
due_date | datetime | no | |
period_start | datetime | no | |
period_end | datetime | no | |
next_payment_attempt | datetime | no | |
description | string | no | |
exchange_rate | decimal(15,4) ≥0 | no | |
discounts | array ≤250 | no | Discount applications |
metadata | object | no | Default {} |
Invoice line item
MRR is computed from these lines, not from the invoice total. Each needs a price_id (for the interval) and a period (for the length).
| Field | Type | Required | Notes |
|---|---|---|---|
amount | decimal(…,4) | one of | Pre-tax, pre-discount |
subtotal | decimal(…,4) | one of | Paddle's name for the same figure |
amount_including_tax | decimal(…,4) | one of | Post-tax - requires tax |
tax | decimal(…,4) | conditional | Subtracted from amount_including_tax |
currency | string(3) | yes | |
quantity | integer ≥0 | no | Default 1 |
discount | decimal(…,4) | no | Default 0. MRR uses amount - discount |
period_start | datetime | no | Defaulted from the line's subscription block |
period_end | datetime | no | |
price_id | string ≤255 | no | No price = counted in the total, no plan breakdown |
subscription_id | string ≤255 | one of | Reference an existing subscription |
subscription | object | one of | Derive one from this line |
prorated | boolean | no | Default false. Keeps part-period charges out of recurring amount |
invoice_line_item_id | string ≤255 | no | Must be unique within the invoice |
description | string | no | |
exchange_rate | decimal(15,4) ≥0 | no |
Send exactly one amount convention. The field name declares which one you mean, so a mix-up is a 400 rather than a chart that is off by the tax rate.
Derived subscription
Put a subscription block on a line item and Chartsy creates the subscription from it - the shape ChartMogul's import API works in. Use this when your system bills but has no subscription object of its own.
{
"price_id": "price_growth_monthly",
"amount": "147.00",
"currency": "USD",
"subscription": {
"subscription_id": "sub_5521",
"status": "active",
"current_period_start": "2026-03-15T09:30:00Z",
"current_period_end": "2026-04-15T09:30:00Z"
}
}Only subscription_id is required; status, started_at, current_period_start, current_period_end, canceled_at, trial_start, trial_end, cancel_at_period_end and metadata are all optional and otherwise carried over from the invoice. A line deriving a subscription must carry a price_id, and its invoice must carry a customer_id.
How the subscription is matched
Same rule as ChartMogul's subscription_external_id:
- A
subscription_idChartsy has already seen on this source extends the existing subscription - a renewal, not a second subscription. - A new
subscription_idcreates one.
So a year of monthly invoices, each carrying the same subscription_id, produces one subscription with twelve invoices against it - which is what makes the invoice-only integration path work at all.
Chartsy adds replay-safety rules on top, because the first thing a migration does is pour years of history in and nothing guarantees it arrives in date order:
| Field | Replay rule |
|---|---|
created_date, started_at | Only ever move earlier. MRR generates its month series from the earliest date and cohort reports read the same fields, so a later invoice cannot truncate the customer history |
current_period_start / _end | Only the newest invoice seen so far redefines the current period. Replaying an old invoice cannot roll a live subscription backwards |
status, canceled_at, trial_start, trial_end | Written only when the payload carries them, so backfilling paid invoices never resurrects a cancelled subscription |
| The item set | Only the newest invoice may redefine it, for the same reason |
A consequence worth knowing
A line carrying canceled_at with no status is read as a cancellation and sets status to canceled. That is how ChartMogul expresses a cancelled subscription, and Chartsy's churn reporting needs the two to agree.
Because of all this you can re-send the same invoices in any order, as many times as you like, and converge on the same subscription.
Transaction#
The money movement - the payment against an invoice.
{
"transaction_id": "ch_7781",
"type": "payment",
"status": "succeeded",
"amount": "147.00",
"currency": "USD",
"created_date": "2026-03-15T09:31:02Z",
"customer_id": "cus_8817",
"subscription_id": "sub_5521",
"invoice_id": "in_9001",
"fee": "4.56",
"net_amount": "142.44",
"payment_method_type": "card"
}| Field | Type | Required | Notes |
|---|---|---|---|
transaction_id | string ≤255 | yes | Your id |
amount | decimal(10,2) | yes | |
type | enum | yes | See below |
status | enum | yes | See below |
created_date | datetime | yes | |
currency | string(3) | no | |
customer_id | string ≤255 | no | |
customer_business_id | string ≤255 | no | |
subscription_id | string ≤255 | no | |
invoice_id | string ≤255 | no | The invoice this pays |
related_transaction_id | string ≤255 | no | Cannot be itself |
items | array ≤250 | no | Transaction items |
fee | decimal(10,2) | no | Processor fee |
tax | decimal(10,2) | no | |
net_amount | decimal(10,2) | no | |
amount_refunded | decimal(10,2) | no | |
payment_method_type | string ≤100 | no | |
payment_intent_id | string ≤255 | no | |
available_on | datetime | no | Payout availability |
disputed | boolean | no | Default false |
dispute_id | string ≤255 | no | |
failure_code | string ≤255 | conditional | Only when status is failed |
failure_message | string | conditional | Only when status is failed |
description | string | no | |
exchange_rate | decimal(15,4) ≥0 | no | |
discounts | array ≤250 | no | Discount applications |
metadata | object | no | Default {} |
type — payment · refund · credit · debit · adjustment · payout · invoice
status — succeeded · failed · pending · draft · ready · billed · paid · completed · canceled · refunded · past_due
Do not create invoices here
type: "invoice" is how Chartsy stores invoices internally. Post invoices to /invoices/.
Transaction item
| Field | Type | Required | Notes |
|---|---|---|---|
price_id | string ≤255 | yes | |
total | decimal(…,4) | one of | Pre-tax |
subtotal | decimal(…,4) | one of | Paddle's name for the same figure |
total_including_tax | decimal(…,4) | one of | Post-tax - requires tax |
tax | decimal(…,4) | conditional | |
transaction_item_id | string ≤255 | no | Unique within the transaction |
quantity | integer ≥0 | no | Default 1 |
discount | decimal(…,4) | no | Default 0 |
type | string ≤100 | no | |
period_start | datetime | no | |
period_end | datetime | no |
Adjustment#
Refunds, chargebacks and credits against a transaction.
{
"adjustment_id": "adj_310",
"transaction_id": "ch_7781",
"adjustment_type": "refund",
"status": "approved",
"amount": "-49.00",
"currency": "USD",
"adjustment_date": "2026-03-20T11:00:00Z",
"reason": "Downgraded mid-cycle",
"items": [
{ "transaction_item_id": "ti_1", "amount": "-49.00", "total": "-49.00" }
]
}| Field | Type | Required | Notes |
|---|---|---|---|
adjustment_id | string ≤255 | yes | Your id |
transaction_id | string ≤255 | yes | Must already exist |
amount | decimal(12,2) | yes | Signed - see below |
currency | string(3) | yes | |
adjustment_type | enum | yes | refund · chargeback · credit · other |
status | enum | yes | approved · pending · failed |
adjustment_date | datetime | yes | |
created_date | datetime | no | |
reason | string ≤255 | no | |
exchange_rate | decimal(15,4) ≥0 | no | |
items | array ≤250 | no | Adjustment items |
The sign is enforced. Adjustment amounts are added to gross revenue, so refund and chargeback must be negative - a positive one would increase reported revenue - and credit must be positive.
Adjustments are the one object with no metadata field. There is no column for it, and the transaction it points at already carries anything worth storing.
Adjustment item
| Field | Type | Required | Notes |
|---|---|---|---|
transaction_item_id | string ≤255 | yes | The item being adjusted |
adjustment_item_id | string ≤255 | no | |
price_id | string ≤255 | no | |
type | string ≤255 | no | Default "" |
amount | decimal(12,2) | no | Default 0 |
total | decimal(12,2) | no | Default 0 |
Discount application#
Not an endpoint. A discounts array on a subscription, invoice or transaction, tying an already-posted coupon to that object.
"discounts": [
{ "coupon_id": "SUMMER25", "source_discount_id": "di_88a2" }
]| Field | Type | Required | Notes |
|---|---|---|---|
coupon_id | string ≤255 | yes | Must already exist on /discounts/ |
source_discount_id | string ≤255 | no | Your id for the application, not the coupon. Defaults to coupon_id |
metadata | object | no | Default {} |
Give source_discount_id its own value when the same coupon can be applied to one object more than once - it is what makes a re-post update the application rather than add another.
Reading back and deleting#
While you are building, GET is the fastest way to see what Chartsy actually stored. Relationships come back as your ids, not Chartsy's.
curl https://dashboard.chartsy.app/api/v1/subscriptions/sub_5521/ \
-H "Authorization: Bearer $CHARTSY_API_KEY"# Removes the record and its nested children. Records it referenced -
# the customer, the price, the product - are kept.
curl -X DELETE https://dashboard.chartsy.app/api/v1/invoices/in_9001/ \
-H "Authorization: Bearer $CHARTSY_API_KEY"204 on delete, 404 if the id is not on this source.
There is no list endpoint
If you are coming from ChartMogul, the counterpart of List a Customer's Subscriptions is missing, and mostly it is not needed. That endpoint exists to map your subscription_external_id to the subscription_uuid ChartMogul generated for it. Chartsy never generates an id you have to store - every object is addressed by your own id, forever - so there is nothing to look up.
What you cannot currently do is enumerate: there is no way to ask which subscriptions exist for this customer, or what is on this source. GET works only against an id you already hold. In practice:
- To check whether a record landed,
GETit by the id you sent. - To reconcile in bulk, compare against your own system - it is the system of record for these ids, and Chartsy stores exactly what you sent.
- To see the whole picture, use the dashboard, or ask the MCP connector, which can query across a source.
If enumeration would genuinely help your integration, say so - it is a known gap, not a deliberate omission.
Errors#
| Status | Meaning |
|---|---|
400 | Validation failed. Body names the field and what's wrong |
401 | Missing, unknown or revoked key |
403 | Key is valid but the account can't be written to (trial ended, no payment processor set) |
404 | No such record on this source (GET / DELETE) |
429 | Over 600 requests/min, or too many failed key attempts from one IP |
A single-object error names fields directly:
{ "canceled_at": ["Required when status is 'canceled'."] }A batch error is keyed by the record's index in the array:
{ "12": { "price_id": ["No price with price_id='price_x' on this account. Post it to /api/v1/prices/ first."] } }Nothing in a failed batch is written. Fix the record and re-send the whole batch - every endpoint is an upsert, so replaying it is safe.


