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 URLAuthContent type
https://dashboard.chartsy.app/api/v1Authorization: Bearer csk_… on every requestapplication/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 }  // identical

The 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

LimitValue
Records per POST500
Items per subscription50
Line items per invoice250
Items per transaction250
Items per adjustment250
Discount applications per object250
Requests per minute, per key600

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#

POST/api/v1/discounts/
GETDELETE/api/v1/discounts/{coupon_id}/

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
}
FieldTypeRequiredNotes
coupon_idstring ≤255yesYour id
durationenumyesonce · repeating · forever
created_datedatetimeyes
amount_offdecimal(15,2) ≥0one ofFixed amount off
percent_offdecimal(5,2) 0–100one ofPercentage off
currencystring(3)conditionalRequired when amount_off is set
duration_in_monthsinteger ≥1conditionalRequired when duration is repeating
codestring ≤100noThe code a customer types
exchange_ratedecimal(15,4) ≥0noDefaults to today's rate
activebooleannoDefault true
metadataobjectnoDefault {}

Send exactly one of amount_off or percent_off. Sending both is a 400.

Customer#

POST/api/v1/customers/
GETDELETE/api/v1/customers/{customer_id}/
{
  "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" }
}
FieldTypeRequiredNotes
customer_idstring ≤255yesYour id
emailemailno
namestring ≤255no
countrystring ≤100noISO 3166 code preferred - drives MRR by country
citystring ≤100no
currencystring ≤10no
balancestring ≤100noFree text, stored as given
delinquentbooleannoDefault false
default_payment_methodstring ≤255no
coupon_idstring ≤255noReference to a discount already posted
created_datedatetimenoSignup date - drives cohort reporting
metadataobjectnoDefault {}

A customer can also be created inline from a subscription - see the customer field on Subscription.

Customer business#

POST/api/v1/customer-businesses/
GETDELETE/api/v1/customer-businesses/{customer_business_id}/

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" }
}
FieldTypeRequiredNotes
customer_business_idstring ≤255yesYour id
customer_idstring ≤255yesMust already exist
created_datedatetimeyes
namestring ≤255no
company_numberstring ≤255no
tax_identifierstring ≤255noVAT / tax number
statusstring ≤100no
contactsobjectnoFree-form; not walked for _in_cents
metadataobjectnoDefault {}

Product#

POST/api/v1/products/
GETDELETE/api/v1/products/{product_id}/

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"
}
FieldTypeRequiredNotes
product_idstring ≤255yesYour id
plan_namestring ≤255yesThe name shown in MRR-by-plan
descriptionstringno
tax_categorystring ≤255no
activebooleannoDefault true
created_datedatetimeno
metadataobjectnoDefault {}

Price#

POST/api/v1/prices/
GETDELETE/api/v1/prices/{price_id}/

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"
}
FieldTypeRequiredNotes
price_idstring ≤255yesYour id
product_idstring ≤255one ofReference to an existing product
productobjectone ofA Product, created inline
amountdecimal(15,2) ≥0yes
currencystring(3)yesISO 4217
intervalenumnoday · week · month · year · one_time. Null = non-recurring
interval_countinteger ≥1noRequires interval. interval: "month", interval_count: 3 = quarterly
usage_typestring ≤20no
price_namestring ≤255no
exchange_ratedecimal(15,4) ≥0noDefaults to today's rate to the account currency
activebooleannoDefault true
created_datedatetimeno
metadataobjectnoDefault {}

Send exactly one of product or product_id.

Subscription#

POST/api/v1/subscriptions/
GETDELETE/api/v1/subscriptions/{subscription_id}/
{
  "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" }
  ]
}
FieldTypeRequiredNotes
subscription_idstring ≤255yesYour id
statusenumyesSee below
created_datedatetimeyes
customer_idstring ≤255one ofReference to an existing customer
customerobjectone ofA Customer, created inline
itemsarray(1–50)yesSubscription items
started_atdatetimeno
current_period_startdatetimeno
current_period_enddatetimeno
canceled_atdatetimeconditionalRequired when status is canceled
paused_atdatetimeno
trial_startdatetimeno
trial_enddatetimeno
cancel_at_period_endbooleannoDefault false. Excluded from MRR
latest_invoice_idstring ≤255no
default_payment_methodstring ≤255no
discountsarray ≤250noDiscount applications
metadataobjectnoDefault {}

status active · trialing · past_due · paused · unpaid · canceled · incomplete · incomplete_expired

Rules

  • status: "canceled" requires canceled_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_end must be on or after current_period_start; same for trial_end / trial_start.
  • Two items on the same price_id need explicit subscription_item_id values to tell them apart, otherwise they would fight over one row.

Subscription item

FieldTypeRequiredNotes
price_idstring ≤255one ofReference to an existing price
priceobjectone ofA Price, created inline
subscription_item_idstring ≤255noNeeded only to disambiguate repeats
quantityinteger ≥0noDefault 1 - seats
amountdecimal(10,2) ≥0noOverrides the price's amount for this item
created_datedatetimeno
metadataobjectnoDefault {}

Invoice#

POST/api/v1/invoices/
GETDELETE/api/v1/invoices/{invoice_id}/
{
  "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"
    }
  ]
}
FieldTypeRequiredNotes
invoice_idstring ≤255yesYour id
created_datedatetimeyes
currencystring(3)yes
statusstring ≤20yesFree text - paid, open, void, …
amount_duedecimal(…,4)yes
amount_paiddecimal(…,4)yes
amount_remainingdecimal(…,4)yes
customer_idstring ≤255conditionalRequired if any line derives a subscription
subscription_idstring ≤255no
line_itemsarray ≤250noInvoice line items
taxdecimal(…,4)no
due_datedatetimeno
period_startdatetimeno
period_enddatetimeno
next_payment_attemptdatetimeno
descriptionstringno
exchange_ratedecimal(15,4) ≥0no
discountsarray ≤250noDiscount applications
metadataobjectnoDefault {}

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

FieldTypeRequiredNotes
amountdecimal(…,4)one ofPre-tax, pre-discount
subtotaldecimal(…,4)one ofPaddle's name for the same figure
amount_including_taxdecimal(…,4)one ofPost-tax - requires tax
taxdecimal(…,4)conditionalSubtracted from amount_including_tax
currencystring(3)yes
quantityinteger ≥0noDefault 1
discountdecimal(…,4)noDefault 0. MRR uses amount - discount
period_startdatetimenoDefaulted from the line's subscription block
period_enddatetimeno
price_idstring ≤255noNo price = counted in the total, no plan breakdown
subscription_idstring ≤255one ofReference an existing subscription
subscriptionobjectone ofDerive one from this line
proratedbooleannoDefault false. Keeps part-period charges out of recurring amount
invoice_line_item_idstring ≤255noMust be unique within the invoice
descriptionstringno
exchange_ratedecimal(15,4) ≥0no

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_id Chartsy has already seen on this source extends the existing subscription - a renewal, not a second subscription.
  • A new subscription_id creates 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:

FieldReplay rule
created_date, started_atOnly 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 / _endOnly 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_endWritten only when the payload carries them, so backfilling paid invoices never resurrects a cancelled subscription
The item setOnly 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#

POST/api/v1/transactions/
GETDELETE/api/v1/transactions/{transaction_id}/

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"
}
FieldTypeRequiredNotes
transaction_idstring ≤255yesYour id
amountdecimal(10,2)yes
typeenumyesSee below
statusenumyesSee below
created_datedatetimeyes
currencystring(3)no
customer_idstring ≤255no
customer_business_idstring ≤255no
subscription_idstring ≤255no
invoice_idstring ≤255noThe invoice this pays
related_transaction_idstring ≤255noCannot be itself
itemsarray ≤250noTransaction items
feedecimal(10,2)noProcessor fee
taxdecimal(10,2)no
net_amountdecimal(10,2)no
amount_refundeddecimal(10,2)no
payment_method_typestring ≤100no
payment_intent_idstring ≤255no
available_ondatetimenoPayout availability
disputedbooleannoDefault false
dispute_idstring ≤255no
failure_codestring ≤255conditionalOnly when status is failed
failure_messagestringconditionalOnly when status is failed
descriptionstringno
exchange_ratedecimal(15,4) ≥0no
discountsarray ≤250noDiscount applications
metadataobjectnoDefault {}

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

FieldTypeRequiredNotes
price_idstring ≤255yes
totaldecimal(…,4)one ofPre-tax
subtotaldecimal(…,4)one ofPaddle's name for the same figure
total_including_taxdecimal(…,4)one ofPost-tax - requires tax
taxdecimal(…,4)conditional
transaction_item_idstring ≤255noUnique within the transaction
quantityinteger ≥0noDefault 1
discountdecimal(…,4)noDefault 0
typestring ≤100no
period_startdatetimeno
period_enddatetimeno

Adjustment#

POST/api/v1/adjustments/
GETDELETE/api/v1/adjustments/{adjustment_id}/

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" }
  ]
}
FieldTypeRequiredNotes
adjustment_idstring ≤255yesYour id
transaction_idstring ≤255yesMust already exist
amountdecimal(12,2)yesSigned - see below
currencystring(3)yes
adjustment_typeenumyesrefund · chargeback · credit · other
statusenumyesapproved · pending · failed
adjustment_datedatetimeyes
created_datedatetimeno
reasonstring ≤255no
exchange_ratedecimal(15,4) ≥0no
itemsarray ≤250noAdjustment 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

FieldTypeRequiredNotes
transaction_item_idstring ≤255yesThe item being adjusted
adjustment_item_idstring ≤255no
price_idstring ≤255no
typestring ≤255noDefault ""
amountdecimal(12,2)noDefault 0
totaldecimal(12,2)noDefault 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" }
]
FieldTypeRequiredNotes
coupon_idstring ≤255yesMust already exist on /discounts/
source_discount_idstring ≤255noYour id for the application, not the coupon. Defaults to coupon_id
metadataobjectnoDefault {}

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, GET it 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#

StatusMeaning
400Validation failed. Body names the field and what's wrong
401Missing, unknown or revoked key
403Key is valid but the account can't be written to (trial ended, no payment processor set)
404No such record on this source (GET / DELETE)
429Over 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.

Related articles

Chartsy Team

Written by

Chartsy Team

The Chartsy Team writes guides, product updates, and resources to help SaaS and eCommerce founders make sense of their metrics, without SQL or spreadsheets.

Chartsy
Ministry of Economy and Innovation
Startup Albania

The Chartsy program is realized with the financial support of the Albanian Government through the Ministry of Economy and Innovation, under the Grant 2026 scheme, and is implemented by the Innovation4Albania Agency.