The Stripe Reporting API, and a Close You Can Repeat

The Stripe Reporting API runs the same reports as the Dashboard, with two boundary rules that differ: the interval end is exclusive, and the time zone you...

Acodei Content Team · 9/13/2026 · 16 min read

Every reconciliation guide on this site eventually tells you to download a report. Open the Stripe Dashboard, pick a date range, pick a time zone, click Download, open it next to QuickBooks. It works. It is also where most month-to-month discrepancies are born, because a person is choosing three inputs by hand every time and nobody writes down which choices they made in February.

The Stripe Reporting API is the same reports without the hand. You ask for a report type over an interval, Stripe builds the file, and you fetch it. The valuable part is not that it saves you a click. It is that the date range, the time zone and the column set stop being decisions somebody makes under time pressure at month end and become code, and code does not quietly choose differently next month.

There is a catch, and it is the reason this is worth its own post. The API does not define an interval the way the Dashboard does, and it does not default the time zone the way your Dashboard does either. Move your close from one to the other without noticing, and your first automated month will be wrong by a day, or by eight hours, or by a factor of a hundred.

Stop hand-keying Stripe activity into QuickBooks. Acodei syncs charges, refunds, fees, payouts and disputes into QuickBooks Online with the accounts and the dates already correct. Start a free trial.

Two objects, and only one of them makes a file

The API splits the job in two, and the split is the first thing to get straight because the two objects answer different questions.

A report type is a description of a report that exists. It has an ID like balance.summary.1, a name, a version, and two fields that tell you which data Stripe currently holds: data_available_start and data_available_end. You never get numbers out of a report type. You ask it what is possible.

A report run is one execution of a report type with specific parameters. You create it, Stripe starts working, and the object comes back with status set to pending and result set to null. When the work finishes, the status becomes succeeded and a nested result object appears with a file ID and a URL. That URL is your CSV, and you fetch it with your API key like any other Stripe file.

So the loop is: read the report type to learn what data is available, create a report run over the slice you want, wait, then download what the run produced. Every report in the Dashboard's Reports section that offers a CSV download is reachable this way, across the Balance, Payout reconciliation, Fees, Tax, Connect platforms and Commerce report families. Stripe's own reference for the whole flow is at docs.stripe.com/reports/api.

One practical note before any of that: some report types only run against live-mode data and will error if you call them with a test-mode key.

Beyond the interval and the time zone, a run takes three optional parameters worth knowing. columns lets you choose and order the columns in the output, which is how you stop a report changing shape on you when Stripe adds a field. currency filters to one currency, which matters if you settle in several. And report_category filters to matching rows only, which is useful precisely because reporting category is not the same thing as balance transaction type, a distinction we pulled apart in reporting category versus type. Filtering on the wrong one of those two is its own quiet way to produce a short report.

The end of your date range is not where you think it is

This is the finding worth slowing down for, because it is silent, it is off by exactly one day, and both halves of it are documented behaviour working as intended.

In the Dashboard, the dates you select are inclusive on both ends. Stripe's own example is a range of 14 August to 21 August, and it states that the report includes data from the beginning of the day on the 14th at 12:00am through the end of the day on the 21st at 11:59pm, in whichever time zone you selected.

In the API, interval_start is inclusive and interval_end is exclusive. Both are Unix timestamps, and Stripe also requires that interval_start falls strictly before interval_end, not equal to it.

Now put those two sentences next to each other and translate a month. If your team has always downloaded August by typing 1 August and 31 August into the Dashboard, the literal translation into the API is interval_start at midnight on 1 August and interval_end at midnight on 31 August. That run silently drops the whole of 31 August. A month of sales, fees and payouts is missing its last day, and the total is smaller than the Dashboard's by however much happened on it.

The correct translation is interval_end at midnight on 1 September. The exclusive end means the instant you name is the first instant you do not want.

The failure is nasty for two reasons. It is a clean, plausible-looking number rather than an error, so nothing alerts. And it recurs identically every month, so comparing August to September does not reveal it either, because both are short by their last day. The only way it surfaces is somebody reconciling the report against the Stripe balance itself.

The time zone you do not pass is UTC

The second divergence is the same shape as the first, and more expensive.

The Dashboard gives you two choices of time zone: your Stripe account's time zone, or UTC. Stripe is explicit that the choice affects both how the date range filters the report and how the dates and times inside the report are presented.

The API's timezone parameter accepts any time zone name from the IANA database, which is far more flexible. It is also optional, and it defaults to UTC when you do not supply it.

So picture a business in Los Angeles whose bookkeeper has downloaded Dashboard reports in the account time zone for two years. Somebody automates the monthly pull, passes interval_start and interval_end correctly, and does not pass timezone because the parameter is optional and nothing complains. Every report from that day forward runs in UTC, which is seven or eight hours ahead of Pacific time depending on the season. Every transaction that happened in the last seven or eight hours of the last day of the month moves into the next month. Every month. In both directions, so the error nearly cancels out across a full year, which is precisely why nobody notices it until an auditor asks about a single month.

Pass the time zone explicitly, every time, even when you want UTC. An explicit timezone set to UTC and an omitted one produce the same file, but only one of them tells the next person that the choice was made on purpose.

One constraint to know: you can only request a time zone for a report type that actually has a timezone parameter, so check the report type's parameters rather than assuming.

Ask Stripe which days exist rather than guessing

Stripe prepares report data on a semi-daily basis, which means the most recent day or two may not be there yet. Rather than hard-coding a delay and hoping, you can ask.

Retrieve the report type you intend to run and read data_available_start and data_available_end. Those two timestamps are the full range of valid times for that report. Stripe then enforces them: if your interval_start or interval_end falls outside that window, the create call fails with an invalid request error and HTTP 400 rather than returning a short file. That is the one place in this whole flow where a boundary mistake is loud instead of quiet, and it is worth leaning on.

A small warning about the documentation itself. The example report type response Stripe publishes happens to show a data_available_end that is earlier than its data_available_start. Read the live object for your own account rather than copying values or assumptions out of the sample.

The notification you have to ask for by name

If you want the pull to be genuinely scheduled rather than run on a guess, Stripe has a webhook for it, with a condition attached that is easy to miss.

Stripe publishes reporting.report_type.updated events carrying the updated report type twice per day, at 00:00 and 12:00 UTC. You will typically see twenty to thirty of them a day, two for each report type your account is eligible for.

Here is the condition. A webhook endpoint configured to receive all events does not receive these. You have to subscribe to reporting.report_type.updated explicitly by name. An integration that appears to be listening for everything and never fires is almost always hitting exactly this. Two further exclusions are worth knowing: Stripe does not send these events to Connect webhook endpoints, and you will not receive them in sandboxes, so a pattern you cannot trigger in a sandbox is not necessarily broken.

Stripe also notes that report data can update more often than the events are sent, so data_available_end on a live retrieve may be later than the value in your most recent event payload. If you need the freshest boundary, read the object rather than the event.

From there the flow is short. On receiving the event for the report type you care about, create a report run. When it finishes, Stripe sends reporting.report_run.succeeded with the completed object, including result.url, and you fetch the file with your API key. If it fails instead, you get reporting.report_run.failed, which Stripe describes as rare and recommends handling the way you would handle a 500.

Two operational details round it out. Most runs complete within a few minutes, but a large data set or a wide interval can take longer, so treat the run as asynchronous rather than polling tightly. And Stripe monitors concurrent runs per account: if you fire too many at once you can get a rate limit error with HTTP 429, and the fix is to let pending runs drain before requesting more.

The hundredfold error hiding in the file

One more trap, and it catches people writing the comparison script rather than the pull.

The CSV reports format monetary amounts in major currency units as a decimal number. Ten dollars is 10.00. The Stripe API, everywhere else, expresses amounts in the currency's minor unit as an integer. Ten dollars is 1000.

So a script that reads a total out of a report CSV and compares it to an amount it fetched from the API, without converting one of them, is out by a factor of a hundred. That is an obvious bug when the numbers are small and a very convincing one when they are not, because a discrepancy of exactly one hundred times looks like a units problem in your chart of accounts rather than in your script.

What automating does not decide for you

Scheduling the pull does not answer the two questions that actually determine whether the numbers tie out.

The first is which report you should be running. The Balance report and the Payout reconciliation report cover the same dates and produce different totals on purpose, and choosing between them is a real decision with a real answer. We worked that through, along with the twelve-hour availability expectation and the two cases where payout reconciliation takes longer, in the balance report versus payout reconciliation. That post is the one to read before you automate anything, because automating the wrong report just produces the wrong number reliably.

The second is how fees are pivoted. The Fees report comes in four shapes, two keyed to the date a fee hit your balance and two keyed to the date of the event that incurred it, and they do not agree for any month in which those dates straddle a boundary. The Stripe Fees report and the month a fee belongs to covers that, and none of it changes when the download becomes an API call.

It is also worth saying plainly that you may not need the API at all. The Dashboard has its own scheduled reports: pick a report, click Schedule, choose daily, weekly or monthly along with the columns and currency, and Stripe emails you when the file is ready. If what you want is the same CSV arriving on a cadence, that is the cheaper answer and it involves no code.

The API earns its keep in two specific cases. One is when the file needs to land somewhere a machine reads it, rather than in an inbox. The other is the partial-day cutoff: Dashboard reports always cover a complete day, so if your close needs a boundary that is not midnight, the API is the only way to get it.

Where Acodei sits in this

Worth being precise, because the answer changes how you should use these reports.

Acodei builds your QuickBooks records from Stripe balance transactions. For the daily balance activity summary, it takes the day's balance transactions and groups them by date and by type, tracking currency per group, so charges, refunds, fees, advances, financing, contributions and reserves each become their own line. Each group posts as a line item using the product mapped for that type, with uncommon types routed through Balance Transaction Mapping. The resulting receipt is dated to the day, deposited to the resolved holding account, and carries a note identifying it as an Acodei daily summary. The individual line items are persisted per summary as well, so the Data Feed can show what went into it.

That is worth knowing because it tells you what a report run is for. The report is your independent check on the books, produced from a different shape of the same underlying data, which is exactly what makes it useful as a control. It is not the thing that writes your entries.

Acodei does its own version of that check, and the detail matters here. Every summary is validated against Stripe: the validation re-derives the day's expected totals from Stripe balance history and compares them to what was recorded, and it does that respecting the account's time zone and whether the account syncs in real time or as a daily summary.

Read that next to the time zone section above and the practical conclusion falls out. If the account time zone that validation respects is not UTC, and you run your verification report in UTC because you left timezone unset, you are comparing two different days, and the difference is not an error in either system. Line the report's time zone up with that account time zone before you conclude anything from a variance.

One more thing that affects where fees turn up. Acodei splits Stripe fees into transactional fees, meaning the fee on a charge, payment or refund balance transaction, and non-transactional fees, meaning standalone fee balance transactions such as Billing, Radar and Tax product charges. Each category can be routed independently, either onto the daily summary's line items or into a separate expense record. If a fee total in your report does not match a fee total in QuickBooks, that setting is the first place to look, because the two categories may not be landing in the same place. Our guide to reconciling Stripe fees in QuickBooks Online works the variance through end to end.

A short close checklist

  1. Retrieve the report type and read data_available_start and data_available_end before you run anything.
  2. Set interval_start to midnight on the first day you want, and interval_end to midnight on the first day you do not want. For a calendar month, that is the first of the following month.
  3. Pass timezone explicitly, every run, and set it to your Stripe account's time zone unless you have a documented reason to use UTC.
  4. Subscribe your webhook endpoint to reporting.report_type.updated by name. An all-events endpoint will never receive it.
  5. Treat the run as asynchronous. Wait for reporting.report_run.succeeded, then fetch result.url with your API key, and handle reporting.report_run.failed the way you handle a 500.
  6. Convert units before comparing anything. The CSV is in dollars and cents, the API is in cents.
  7. When a total disagrees with QuickBooks, check the report's time zone and the fee category routing before assuming a sync problem.

Frequently asked questions

What is the Stripe Reporting API?

It is the API behind the financial reports in the Stripe Dashboard. You create a report run for a given report type over a given interval, Stripe generates the file, and you download it from the URL in the run's result. It covers the same CSV downloads the Dashboard offers across the Balance, Payout reconciliation, Fees, Tax, Connect platforms and Commerce reports.

Why does my API report total not match the Dashboard for the same month?

The two most likely causes are both boundary definitions. In the Dashboard the selected dates are inclusive on both ends, while in the API interval_end is exclusive, so naming the last day of the month drops that day entirely. And the API's timezone parameter defaults to UTC when omitted, while the Dashboard uses whichever of your account time zone or UTC you picked, so an account outside UTC shifts activity across every month boundary.

Do I need to write code to schedule Stripe reports?

No. The Dashboard can schedule a report to run daily, weekly or monthly and email you when it is ready, with the columns and currency you choose. The API is worth it when the file has to be delivered somewhere automated, or when you need a cutoff that is not the end of a complete day, which Dashboard reports cannot produce.

Why is my webhook not receiving report notifications?

Almost certainly because the endpoint is subscribed to all events. Stripe does not deliver reporting.report_type.updated to endpoints listening for all events, so it has to be selected by name. Stripe also does not send these events to Connect webhook endpoints, and they are not delivered in sandboxes.

How long does a Stripe report run take?

Most complete within a few minutes, but it depends on the size of your data set and the width of the interval you requested, so larger pulls take longer. Stripe also throttles accounts with too many concurrent runs and returns HTTP 429, so let pending runs finish before creating more.

Can I use the Reporting API to drive my QuickBooks entries?

It is better used as a check than as a source. Acodei builds QuickBooks records from Stripe balance transactions, grouped by date and type and dated to the day, which means a report run gives you an independent view of the same underlying activity. That independence is what makes it useful for verifying a period rather than for producing the entries in the first place.

The point

The Reporting API does not make your close more accurate by itself. It makes it repeatable, which over a year is the same thing, because almost every reconciliation discrepancy that survives to the end of a close started as a human picking a slightly different date range or time zone than last month.

Two things to get right on the way in. The end of your interval is exclusive, so name the day after the one you want. And the time zone you leave out is UTC, not yours. Get those two and the rest of it is plumbing.

Let the entries take care of themselves. Acodei posts Stripe charges, fees, refunds, payouts and disputes into QuickBooks Online on the right dates and in the right accounts, so your reports become a check rather than a chore. Start a free trial or see how the reconciliation works.

Share

Automate your Stripe to QuickBooks sync

Save hours every month. Acodei automatically syncs your Stripe transactions, invoices, and payouts to QuickBooks Online.

How Acodei handles this in your stack

Stripe QuickBooks Integration

See how Acodei syncs Stripe payments, fees, refunds, invoices, and payouts into QuickBooks Online automatically.

Or go straight to a capability

Get more operational finance guides like this one

We will only send high-value product and finance content.