<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ilshaad</title>
    <description>The latest articles on DEV Community by ilshaad (@ilshadyx).</description>
    <link>https://dev.to/ilshadyx</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3690402%2Fabb27eda-4dd7-4c0c-a408-2c21ee0b99b0.png</url>
      <title>DEV Community: ilshaad</title>
      <link>https://dev.to/ilshadyx</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ilshadyx"/>
    <language>en</language>
    <item>
      <title>Cron Scripts vs Managed Sync: The Hidden Cost of Rolling Your Own</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Tue, 22 Sep 2026 09:54:14 +0000</pubDate>
      <link>https://dev.to/ilshadyx/cron-scripts-vs-managed-sync-the-hidden-cost-of-rolling-your-own-d5f</link>
      <guid>https://dev.to/ilshadyx/cron-scripts-vs-managed-sync-the-hidden-cost-of-rolling-your-own-d5f</guid>
      <description>&lt;p&gt;&lt;em&gt;A cron job and 50 lines of Node looks free. Here is what building and running your own billing data pipeline really costs, and when managed sync is cheaper.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;By Ilshaad Kheerdali · 22 September 2026&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Every developer who has needed billing data in their own database has had the same thought, usually within about ninety seconds of looking at a pricing page: &lt;em&gt;I could write this myself in an afternoon.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You are right. You could. The afternoon version genuinely works, and that is the problem, because the afternoon is not the cost. The cost is the eighteen months afterwards, and it lands in places that never appear in the build estimate: somewhere to run the thing, someone to notice when it stops, and a slow drip of maintenance that never quite ends.&lt;/p&gt;

&lt;p&gt;This post puts real numbers on that. Not to argue that you cannot build it, but so you can compare the two options honestly before you commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Script That Works on Day One
&lt;/h2&gt;

&lt;p&gt;Here is the version everyone writes. It is not a strawman, it is genuinely what a competent developer produces in an afternoon, and it does work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sync-stripe.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;customers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`INSERT INTO stripe_customers (id, email, name, created)
     VALUES ($1, $2, $3, to_timestamp($4))
     ON CONFLICT (id) DO UPDATE SET email = $2, name = $3`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One crontab line and it is a pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 2 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /usr/bin/node /srv/sync/sync-stripe.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it, and rows appear in Postgres. The demo is convincing. If your Stripe account has 80 customers and you only ever need customers, you can genuinely stop here, and you should.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Script Quietly Does Not Handle
&lt;/h2&gt;

&lt;p&gt;The script above is correct for exactly one shape of data: a single small table, fetched whole, with no failures. Every assumption in that sentence breaks at some point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pagination.&lt;/strong&gt; &lt;code&gt;limit: 100&lt;/code&gt; is the maximum page size, not the maximum table size. Past 100 customers you are silently syncing a fraction of your data, and nothing errors. You need &lt;code&gt;has_more&lt;/code&gt; and cursor loops on every list call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limits.&lt;/strong&gt; Stripe's &lt;a href="https://docs.stripe.com/rate-limits" rel="noopener noreferrer"&gt;published limits&lt;/a&gt; are 100 requests per second in live mode, but the constraint that actually bites is the read allocation, which scales with your transaction volume and has a floor of 10,000 reads a month. Xero is stricter, with a per-tenant daily call cap. Hit either and your script does not slow down gracefully, it throws. You need backoff, retry with jitter, and a way to resume a half-finished run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incremental fetching.&lt;/strong&gt; Re-reading every invoice every night stops being viable somewhere in the low tens of thousands of rows. So you add a watermark, and then you discover the genuinely hard part: &lt;code&gt;updated_at&lt;/code&gt; semantics differ per provider, some objects do not expose one at all, and a naive "since last run" bookmark loses data permanently the first time a run fails. Fixed lookback windows are more robust than bookmarks, which is &lt;a href="https://codelesssync.com/blog/how-often-to-sync-billing-data-to-postgres" rel="noopener noreferrer"&gt;why we default to them&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deletions and amendments.&lt;/strong&gt; An upsert never removes anything. Delete a customer in Stripe, void an invoice in Xero, amend last quarter's entry in QuickBooks, and your table keeps the stale row forever. Accounting systems do this constantly, and it is the failure mode most likely to put a wrong number in front of your finance team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema drift.&lt;/strong&gt; Providers add fields. Your &lt;code&gt;INSERT&lt;/code&gt; has a fixed column list, so new fields are dropped silently, and the day you want one you are writing a migration and a backfill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type handling.&lt;/strong&gt; Stripe returns amounts as integers in the smallest currency unit and timestamps as Unix seconds. Get the conversion wrong once and every revenue figure downstream is out by a factor of 100. Nested objects need a considered &lt;code&gt;jsonb&lt;/code&gt; policy rather than a &lt;code&gt;JSON.stringify&lt;/code&gt; reflex.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets and connections.&lt;/strong&gt; &lt;code&gt;process.env&lt;/code&gt; on a box you SSH into is fine until it is not. Long-lived API keys need rotating, OAuth providers like QuickBooks and Xero need refresh-token handling with the refresh persisted somewhere durable, and a script that reconnects to Postgres on every run will exhaust a pooled connection limit faster than you expect.&lt;/p&gt;

&lt;p&gt;None of these is hard. That is exactly the trap. Each one is a two-hour job, and there are nine of them, and you find them one at a time over six months, each announced by a number that looks slightly wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Somewhere to Run It: The Infrastructure Nobody Budgets
&lt;/h2&gt;

&lt;p&gt;This is the cost that gets left out of every build-versus-buy estimate, because &lt;code&gt;crontab -e&lt;/code&gt; feels free. It is not free, it is just unpriced. Your script has to live somewhere, and every option has a real bill and a real failure mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your laptop.&lt;/strong&gt; Free, and it works until you close the lid. Not a serious option, but worth naming because a surprising number of pipelines start here and quietly stay here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A small VPS.&lt;/strong&gt; The honest baseline. A &lt;a href="https://www.digitalocean.com/pricing/droplets" rel="noopener noreferrer"&gt;DigitalOcean basic droplet&lt;/a&gt; starts at $4 a month for 512 MiB and 1 vCPU, which is ample for a nightly sync. The bill is not really $4 though, it is $4 plus a machine you now own: OS patches, Node version upgrades, disk filling with logs, and a box that is invisible to your team until it is the reason the numbers are stale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Actions on a &lt;code&gt;schedule&lt;/code&gt; trigger.&lt;/strong&gt; Genuinely appealing, and the most common free choice. Read the documentation carefully first. GitHub states that the shortest interval is once every 5 minutes, that "the &lt;code&gt;schedule&lt;/code&gt; event can be delayed during periods of high loads", that "if the load is sufficiently high enough, some queued jobs may be dropped", and that in a public repository scheduled workflows are &lt;a href="https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows" rel="noopener noreferrer"&gt;automatically disabled after 60 days&lt;/a&gt; of no repository activity. A scheduler that may silently drop your run, and may switch itself off, is a poor foundation for the table your MRR dashboard reads from. Private repositories on the Free plan include 2,000 minutes a month, after which Linux runners bill at $0.006 a minute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Serverless functions on a scheduler.&lt;/strong&gt; Cheap per invocation and pleasantly hands-off, until a full backfill runs past the execution timeout. Then you are writing checkpointing and chunking, which is a genuine engineering project rather than a cron line.&lt;/p&gt;

&lt;p&gt;Whichever you pick, you still need the part that is not the schedule: somewhere to see whether last night's run succeeded, and something that tells you when it did not. A cron job that fails is completely silent by default. The realistic failure is not a dramatic outage, it is a script that has been dying at 2am for three weeks while everyone kept reading the dashboard it feeds. We covered &lt;a href="https://codelesssync.com/blog/why-stripe-postgresql-sync-keeps-breaking" rel="noopener noreferrer"&gt;why these pipelines break in production&lt;/a&gt; in more detail.&lt;/p&gt;

&lt;p&gt;Run history and alerting is the piece people defer indefinitely, and it is the piece that decides whether you can trust the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Building a Data Pipeline Actually Costs
&lt;/h2&gt;

&lt;p&gt;Put hours against it. These are for a developer who knows the stack, building one provider properly, with no padding for the parts they have not met yet.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Hours&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First working script, one table&lt;/td&gt;
&lt;td&gt;4 to 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pagination and initial backfill&lt;/td&gt;
&lt;td&gt;3 to 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate-limit backoff, retries, resumable runs&lt;/td&gt;
&lt;td&gt;4 to 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upserts, deletions, amendment handling&lt;/td&gt;
&lt;td&gt;4 to 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incremental windows&lt;/td&gt;
&lt;td&gt;4 to 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets, OAuth refresh, multi-table config&lt;/td&gt;
&lt;td&gt;3 to 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy target and scheduling&lt;/td&gt;
&lt;td&gt;3 to 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run history and failure alerting&lt;/td&gt;
&lt;td&gt;4 to 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29 to 60&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://www.itjobswatch.co.uk/contracts/uk/backend%20developer.do" rel="noopener noreferrer"&gt;IT Jobs Watch&lt;/a&gt; puts the UK median backend developer contract day rate at £525 for the six months to 20 September 2026, which is roughly £70 an hour. That makes the build &lt;strong&gt;£2,000 to £4,200&lt;/strong&gt;, or about $2,700 to $5,700.&lt;/p&gt;

&lt;p&gt;If you are a founder writing it yourself, substitute your own number, but do not substitute zero. Those 29 to 60 hours are a week to a week and a half of full-time work not spent on the product, and that is the expensive part, not the cash.&lt;/p&gt;

&lt;p&gt;Then add the running cost: $4 to $20 a month for compute, plus whatever your monitoring costs once you stop pretending you will check the logs by hand.&lt;/p&gt;

&lt;p&gt;And that is one provider. Adding QuickBooks or Xero afterwards is not a copy-paste, because OAuth, tenant handling and pagination semantics are all different.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Maintenance Line That Never Ends
&lt;/h2&gt;

&lt;p&gt;The build is finite. The maintenance is not, and this is the number that decides the argument.&lt;/p&gt;

&lt;p&gt;Every month there is something. An API version deprecation with a migration window. A new field finance wants, which means a migration plus a backfill. A refresh token that expired while someone was on holiday. A rate limit you started hitting because the business grew. A Node or dependency upgrade on that droplet. A run that failed at 2am and needs replaying by hand.&lt;/p&gt;

&lt;p&gt;Call it 2 to 4 hours a month once things have settled down, which is conservative. At £70 an hour that is &lt;strong&gt;£140 to £280 a month, every month, indefinitely&lt;/strong&gt;. It is invisible in any budget because it is never a line item, it is just a Tuesday morning that went somewhere.&lt;/p&gt;

&lt;p&gt;Over two years, the build plus that maintenance comes to somewhere between £5,400 and £10,900, ignoring compute.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Rolling Your Own Is the Right Call
&lt;/h2&gt;

&lt;p&gt;It genuinely is sometimes, and the cases are specific:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need one small table and nothing else, ever.&lt;/strong&gt; Eighty customers, one nightly pull. The script at the top of this post is the correct answer, and buying anything would be silly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your transformation is the actual product.&lt;/strong&gt; If the point is bespoke enrichment, or joining three sources into a proprietary shape mid-flight, you were going to write code regardless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You already run a pipeline platform.&lt;/strong&gt; If Airflow or Dagster is up with alerting and an on-call rota, one more DAG is a genuinely small marginal cost. The infrastructure section above is already paid for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance forbids third-party credential access.&lt;/strong&gt; A hard constraint is a hard constraint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice what those have in common: either the work is trivially small, or the expensive infrastructure already exists. Outside those, you are paying to rebuild something undifferentiated.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Managed Sync Costs Instead
&lt;/h2&gt;

&lt;p&gt;Codeless Sync exists to be the other side of that comparison, so here is the pricing plainly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://codelesssync.com/pricing" rel="noopener noreferrer"&gt;Starter is $19 a month&lt;/a&gt; and Pro is $29, both with daily, weekly and monthly schedules, and 10 and 30 configurations respectively. Business is $99 and adds twelve-hourly scheduling. There is a free tier with two manual syncs a day while you evaluate it.&lt;/p&gt;

&lt;p&gt;The comparison over two years:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Build it&lt;/th&gt;
&lt;th&gt;Codeless Sync Pro&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Up-front&lt;/td&gt;
&lt;td&gt;£2,000 to £4,200&lt;/td&gt;
&lt;td&gt;£0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compute&lt;/td&gt;
&lt;td&gt;$4 to $20 a month&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance&lt;/td&gt;
&lt;td&gt;£140 to £280 a month&lt;/td&gt;
&lt;td&gt;£0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alerting and run history&lt;/td&gt;
&lt;td&gt;You build it&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adding a second provider&lt;/td&gt;
&lt;td&gt;Most of another build&lt;/td&gt;
&lt;td&gt;A configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Two-year total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;£5,400 to £10,900&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;about £550&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pagination, backoff, upserts, deletion handling, lookback windows, OAuth refresh, run history and failure alerting all sit on our side of the line. Destination tables are created for you, and the schedule is a dropdown rather than infrastructure. If you want the cron syntax behind those presets, we &lt;a href="https://codelesssync.com/blog/cron-expressions-for-data-syncs" rel="noopener noreferrer"&gt;wrote that up separately&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://codelesssync.com/docs/getting-started/quick-start" rel="noopener noreferrer"&gt;quick start&lt;/a&gt; takes about five minutes, which is a useful benchmark against the afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is it cheaper to build your own data pipeline or use a managed sync tool?
&lt;/h3&gt;

&lt;p&gt;For a single small table you pull occasionally, building it is cheaper, and a 20-line script does the job. Past that, managed sync wins quickly. A properly built pipeline for one provider runs 29 to 60 developer hours up front and 2 to 4 hours a month to maintain, which at UK median contract rates is £2,000 to £4,200 to build and £140 to £280 a month to keep. Managed sync starts at $19 a month with no build.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I just use a GitHub Actions cron job for free?
&lt;/h3&gt;

&lt;p&gt;You can, with caveats worth knowing first. GitHub documents that scheduled workflows may be delayed under high load, that queued jobs may be dropped entirely when load is high enough, and that scheduled workflows in public repositories are disabled automatically after 60 days without repository activity. Private repositories on the Free plan get 2,000 minutes a month. It is fine for something you can afford to miss, and a poor choice for a table your reporting depends on.&lt;/p&gt;

&lt;h3&gt;
  
  
  What usually breaks first in a homemade sync script?
&lt;/h3&gt;

&lt;p&gt;Pagination, and it breaks silently. A &lt;code&gt;list&lt;/code&gt; call with &lt;code&gt;limit: 100&lt;/code&gt; returns the first page whether you have 80 records or 80,000, with no error raised. The second is deletion handling: upserts never remove rows, so voided invoices and deleted customers linger indefinitely. Both surface as numbers that are slightly wrong rather than as a failure, which is why they survive so long.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long does it really take to build a Stripe to Postgres sync?
&lt;/h3&gt;

&lt;p&gt;The first working version takes an afternoon. A version you would trust your revenue reporting to takes 29 to 60 hours, because pagination, rate-limit backoff, incremental windows, deletions, schema drift, secrets handling, deployment and alerting are each small jobs that you meet one at a time over several months.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this change if I need more than one provider?
&lt;/h3&gt;

&lt;p&gt;It gets worse, not better. A second provider is not a copy-paste: QuickBooks and Xero use OAuth with refresh tokens rather than a static key, Xero adds per-tenant handling and a stricter daily call cap, and pagination semantics differ across all four. Expect most of another build per provider. With managed sync it is a new configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest Comparison
&lt;/h2&gt;

&lt;p&gt;The choice is not "write 50 lines" versus "pay $19 a month". It is "own a small piece of infrastructure indefinitely" versus "pay $19 a month".&lt;/p&gt;

&lt;p&gt;If that infrastructure is load-bearing for your product, own it. If it is a table your dashboard reads so you can look at MRR without opening four tabs, the maintenance line is the whole argument, and it is the one nobody prices until they are already paying it.&lt;/p&gt;

&lt;p&gt;Worth doing the arithmetic before the afternoon, rather than eighteen months into it.&lt;/p&gt;

</description>
      <category>api</category>
      <category>database</category>
      <category>postgres</category>
      <category>integration</category>
    </item>
    <item>
      <title>Is It Safe to Connect Stripe or QuickBooks to a Third-Party Sync Tool?</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Tue, 15 Sep 2026 10:11:32 +0000</pubDate>
      <link>https://dev.to/ilshadyx/is-it-safe-to-connect-stripe-or-quickbooks-to-a-third-party-sync-tool-2844</link>
      <guid>https://dev.to/ilshadyx/is-it-safe-to-connect-stripe-or-quickbooks-to-a-third-party-sync-tool-2844</guid>
      <description>&lt;p&gt;&lt;em&gt;How to judge whether a billing sync tool deserves your Stripe or QuickBooks credentials, what to ask before connecting, and how to verify it after.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;By Ilshaad Kheerdali · 15 September 2026&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;You have found a tool that syncs your billing data into your own database, the setup takes five minutes, and the last screen asks for a Stripe key. That is the moment most people stop and think about it properly for the first time.&lt;/p&gt;

&lt;p&gt;It is the right instinct. You are about to give a company you found twenty minutes ago standing access to every customer, invoice and payment in your business. "Is it safe" is a reasonable question, and the honest answer is that it depends entirely on things you can check before you click connect.&lt;/p&gt;

&lt;p&gt;This post is that check. It covers what you are actually handing over, the four ways a tool can hold your credentials ranked from worst to best, the questions worth asking any vendor, and, most usefully, how to verify the answers yourself instead of taking anyone's word for it. We run one of these tools, so we answer every question about ourselves along the way, including the two answers that are not flattering.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Are Actually Handing Over
&lt;/h2&gt;

&lt;p&gt;The first thing worth being precise about: you are not handing over your data. You are handing over a &lt;strong&gt;credential&lt;/strong&gt;, and the credential is what determines how much damage is possible.&lt;/p&gt;

&lt;p&gt;That distinction matters because the risks people worry about and the risks that actually occur are different. Almost nobody is hurt by a sync vendor deliberately misusing their data. The realistic failure modes are duller:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The vendor gets breached, and your credential is in the blast radius.&lt;/li&gt;
&lt;li&gt;The credential has more permission than the job needs, so a breach that should have leaked read access leaks write access instead.&lt;/li&gt;
&lt;li&gt;You forget the connection exists, leave the company, change tools, and the credential stays live for three years.&lt;/li&gt;
&lt;li&gt;The vendor stores the credential badly, in plain text or in application logs, so a low-severity incident becomes a high-severity one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those is a question about the credential, not about the vendor's intentions. Which is good news, because credentials are something you can inspect, scope, monitor and revoke on your own, without trusting anybody.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four Credential Models, Worst to Best
&lt;/h2&gt;

&lt;p&gt;Sync tools ask for access in one of four ways. The difference between the worst and the best is enormous, and it is visible before you sign up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Full secret key.&lt;/strong&gt; A tool asks for your Stripe &lt;code&gt;sk_live_&lt;/code&gt; key or the equivalent. This is the whole account: read everything, create charges, issue refunds, update subscriptions. Stripe's own documentation is blunt about it, saying that because you cannot limit their permissions, it does not recommend secret keys for new use cases and recommends migrating existing integrations to restricted keys. Its &lt;a href="https://docs.stripe.com/keys/restricted-api-keys" rel="noopener noreferrer"&gt;restricted key guide&lt;/a&gt; puts it more directly still, describing a secret key handed to a third party as dangerous because it gives that third party full control, against a restricted key that hands over only the access they need. If a tool insists on a full secret key in 2026, that tells you something about how much thought went into the rest of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Restricted key.&lt;/strong&gt; Same mechanism, but the key carries only the permissions you granted it. A read-only restricted key that leaks is an information disclosure problem. A secret key that leaks is a money problem. Stripe calls these RAKs and they are the recommended default. Paddle works similarly with scoped API keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. OAuth with a broad scope.&lt;/strong&gt; No key changes hands at all. You authorise through the provider, the provider issues the tool a token, and you can see and revoke that grant from your own account without contacting the vendor. The catch is that some providers only offer coarse scopes, so "broad" can still mean write access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. OAuth with a read-only scope.&lt;/strong&gt; The best available. No key to leak, revocable by you at any time, and the token is cryptographically incapable of changing anything.&lt;/p&gt;

&lt;p&gt;The practical ranking is simple: prefer OAuth where the provider offers it, insist on a restricted key where it does not, and treat a full secret key request as a red flag rather than a minor inconvenience.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Codeless Sync Asks For, Provider by Provider
&lt;/h2&gt;

&lt;p&gt;Here is our own answer to the question, which is only useful if it includes the parts we would rather leave out.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Scope requested&lt;/th&gt;
&lt;th&gt;Honest assessment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stripe&lt;/td&gt;
&lt;td&gt;Restricted key&lt;/td&gt;
&lt;td&gt;Read-only, created through Stripe's "providing this key to another website" flow&lt;/td&gt;
&lt;td&gt;Best available for Stripe, which does not offer OAuth for this use case&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Xero&lt;/td&gt;
&lt;td&gt;OAuth 2.0&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;accounting.transactions.read&lt;/code&gt;, &lt;code&gt;accounting.contacts.read&lt;/code&gt;, &lt;code&gt;accounting.settings.read&lt;/code&gt;, &lt;code&gt;accounting.journals.read&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Genuinely read-only, the strongest of the four&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QuickBooks&lt;/td&gt;
&lt;td&gt;OAuth 2.0&lt;/td&gt;
&lt;td&gt;&lt;code&gt;com.intuit.quickbooks.accounting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Read and write. Intuit publishes no read-only accounting scope.&lt;/strong&gt; See below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paddle&lt;/td&gt;
&lt;td&gt;API key&lt;/td&gt;
&lt;td&gt;Read-only key&lt;/td&gt;
&lt;td&gt;Comparable to the Stripe position&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The QuickBooks answer needs saying plainly.&lt;/strong&gt; Intuit's accounting scope is a single permission covering both reading and writing. There is no read-only variant to request, so every QuickBooks integration on the market, ours included, holds a token that could in principle write to your books. Any vendor claiming read-only QuickBooks access is either confused or misleading you.&lt;/p&gt;

&lt;p&gt;What we can tell you is that we never use it. Codeless Sync issues no write call to any provider. The only non-read request in our entire worker is the OAuth token refresh that keeps the connection alive. The section below tells you how to confirm that for yourself rather than believe it because we wrote it down.&lt;/p&gt;

&lt;p&gt;For Stripe we go a step further than most: our setup instructions point you at Stripe's purpose-built flow for exactly this situation, the one where you tell Stripe you are providing a key to a named third party and it scopes the key appropriately, and our instructions explicitly tell you &lt;strong&gt;not&lt;/strong&gt; to tick "customize permissions". Fewer decisions, fewer ways to accidentally grant write access.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens to the Credential After You Paste It
&lt;/h2&gt;

&lt;p&gt;Storage is where most of the real risk sits, and it is the part vendors are usually vaguest about. Ours:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted at rest with AES-256-GCM&lt;/strong&gt;, an authenticated cipher, with a random initialisation vector per credential. Authenticated means tampering with the stored ciphertext is detected rather than silently decrypted into something else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS 1.3 in transit&lt;/strong&gt;, both to us and from us to your database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decrypted only in memory at sync time&lt;/strong&gt;, to make the API calls the sync needs.&lt;/li&gt;
&lt;li&gt;We are a UK company and handle personal data under UK GDPR. Our &lt;a href="https://codelesssync.com/privacy" rel="noopener noreferrer"&gt;privacy policy&lt;/a&gt; sets out what we hold and for how long.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Encrypted at rest" is close to meaningless as a marketing phrase, so the useful follow-up when you are evaluating any vendor is which cipher and where the key lives. A vendor that cannot answer that in one sentence has probably not thought about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven Questions to Ask Any Sync Vendor
&lt;/h2&gt;

&lt;p&gt;These work on us and on our competitors. If a vendor cannot answer them from their public documentation, that is itself an answer.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What is the least-privileged credential you accept?&lt;/strong&gt; If the answer is a full secret key, stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which exact OAuth scopes do you request, by name?&lt;/strong&gt; Vague answers like "read access to your accounting data" are not scope names. Real scopes are strings you can look up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do you ever write to my source system?&lt;/strong&gt; Ask it directly, and ask how you can verify it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Which cipher encrypts credentials at rest, and where does the encryption key live?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What happens to my credentials when I close my account?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where does my data actually go?&lt;/strong&gt; In a sync tool it should land in a database you own, which means the vendor holds a credential but is not accumulating a copy of your business. That is a meaningfully smaller target than a vendor that warehouses your data on their own infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How do I revoke access without contacting you?&lt;/strong&gt; If revocation requires a support ticket, the vendor has more control over your data than you do.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Question six is the one people miss, and it is the structural argument for this category of tool. A sync tool that writes into your own PostgreSQL is holding a key to your house rather than moving your furniture into their warehouse. When you disconnect it, your data is still in your database, because it was always in your database.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Verify the Answers Yourself
&lt;/h2&gt;

&lt;p&gt;This is the part that makes the rest of the post worth something. You do not need to trust a vendor's claims, including ours, because all three major providers let you audit third-party access from your own account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stripe: read the request log for the key.&lt;/strong&gt; Every restricted key has its own request log. In the Dashboard, open &lt;strong&gt;Developers → API keys&lt;/strong&gt;, click the overflow menu on the key you issued, and choose &lt;strong&gt;View request logs&lt;/strong&gt;. You will see every call made with that key, with method and endpoint. Stripe documents the mapping plainly: &lt;code&gt;GET&lt;/code&gt; is a read, while &lt;code&gt;POST&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; are writes. So if a vendor claims read-only behaviour, this is where you confirm it, and you should see &lt;code&gt;GET&lt;/code&gt; requests and nothing else. Check it a week after connecting, then whenever you feel like it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stripe: pin the key to the vendor's infrastructure.&lt;/strong&gt; Less well known, and genuinely useful. Stripe supports &lt;a href="https://docs.stripe.com/keys" rel="noopener noreferrer"&gt;access policies&lt;/a&gt; that restrict a key by IP range, ASN or country, and block anything else while notifying you. Attach one to the key you gave your sync vendor and a stolen credential becomes far less useful to whoever stole it. Stripe recommends policies on all live-mode keys, and almost nobody does it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stripe: rotate on your own schedule.&lt;/strong&gt; Rotating a key keeps both the old and new key working for up to seven days, so you can rotate a vendor's credential without downtime and without asking permission. If a vendor's integration breaks when you rotate a key, you have learned something useful about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Xero and QuickBooks: check the connected apps screen.&lt;/strong&gt; Both show every application connected to your organisation, what it can access and when it was authorised. Review it periodically, the same way you would review who has keys to the office. If you run several client organisations, each one is authorised separately, which is a feature rather than an annoyance: revoking one client leaves the rest untouched. We wrote about that structure in more detail in &lt;a href="https://codelesssync.com/blog/sync-multiple-xero-organisations-to-one-database" rel="noopener noreferrer"&gt;syncing multiple Xero organisations into one database&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everywhere: check what happens when you disconnect.&lt;/strong&gt; Connect a tool, sync once, disconnect it, and confirm your data is still sitting in your own database. If disconnecting takes your data with it, you were renting access to your own records.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Do Not Have
&lt;/h2&gt;

&lt;p&gt;An honest trust page has to include the gaps, because the gaps are what a careful reader is looking for anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We do not have SOC 2 or ISO 27001.&lt;/strong&gt; We are a small UK company, and those certifications cost tens of thousands of pounds and months of auditor time. We would rather spend that on the product at this stage. If your procurement process requires a SOC 2 report, we will fail it, and you should use a vendor that has one.&lt;/p&gt;

&lt;p&gt;What we would gently push back on is treating a certification as the same thing as security. SOC 2 attests that a company follows the controls it says it follows. It is a strong signal about organisational maturity and a weak signal about whether a specific integration asks for more permission than it needs. The seven questions above tell you more about your actual exposure than a badge does, and they work on certified vendors too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We are a small team.&lt;/strong&gt; That cuts both ways honestly: a smaller attack surface and less to steal, but no dedicated security staff and no 24-hour incident response. Weigh that as you see fit. It is the same trade you make with most tools at this price point, and it is a reason to give us a read-only, revocable, monitored credential rather than an unrestricted one, which is what we ask for anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Revoke Access
&lt;/h2&gt;

&lt;p&gt;Worth knowing before you connect rather than after.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe.&lt;/strong&gt; Expire or rotate the restricted key in &lt;strong&gt;Developers → API keys&lt;/strong&gt;. Access stops immediately on expiry. Because we use a key rather than Stripe Connect, revocation is entirely in your hands and does not involve us.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Xero.&lt;/strong&gt; Disconnect Codeless Sync from your organisation's connected apps settings. Each organisation is revoked separately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QuickBooks.&lt;/strong&gt; Disconnect from the connected apps list in your Intuit account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From our side.&lt;/strong&gt; Delete the connection in your Codeless Sync dashboard and the stored credential goes with it. Deleting your account removes them all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In every case, the tables already synced into your database stay yours, because they were never anywhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Leaves You
&lt;/h2&gt;

&lt;p&gt;The question is not really "is it safe to connect a third-party sync tool". It is "what is the smallest amount of access this job needs, and can I see and revoke it". Asked that way it stops being a matter of trust and becomes a matter of configuration, which is a much better position to negotiate from.&lt;/p&gt;

&lt;p&gt;For a billing sync specifically, the job needs read access to billing objects and nothing else. That is available on Stripe, Xero and Paddle today, and unavailable on QuickBooks for reasons that belong to Intuit rather than to any vendor. Give a tool exactly that, put it somewhere you can watch it, and check the log occasionally.&lt;/p&gt;

&lt;p&gt;If you want to see what the connection looks like before committing anything, our &lt;a href="https://codelesssync.com/docs/guides/stripe-setup" rel="noopener noreferrer"&gt;Stripe setup guide&lt;/a&gt; walks through creating the restricted key, and you can do the whole thing on a Stripe sandbox key first. A sandbox key exercises the entire flow with no live data attached, which is the cheapest possible way to audit a vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is it safe to give a third-party tool my Stripe API key?
&lt;/h3&gt;

&lt;p&gt;It depends on which key. A restricted, read-only key is a reasonable thing to share with a vendor you have checked, because the worst case is disclosure of data the vendor was reading anyway, and you can expire it yourself at any moment. A full secret key is not, because it can move money. Stripe itself recommends restricted keys over secret keys for new integrations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a sync tool change or delete my Stripe or accounting data?
&lt;/h3&gt;

&lt;p&gt;Only if the credential you gave it allows writes. A read-only restricted Stripe key cannot, and neither can Xero's read scopes. QuickBooks is the exception, because Intuit's accounting scope covers read and write with no read-only alternative, so the protection there is the vendor's behaviour rather than the token. Check the request log or your connected apps screen rather than relying on assurances.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I know a sync tool is not writing to my account?
&lt;/h3&gt;

&lt;p&gt;Read the request log. Stripe records every call made with each restricted key, viewable from the API keys page in your Dashboard, and read-only behaviour shows up as &lt;code&gt;GET&lt;/code&gt; requests with nothing else alongside them. This is the single most useful habit in this whole post, and it takes about a minute.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Codeless Sync store a copy of my billing data?
&lt;/h3&gt;

&lt;p&gt;No. Data moves from the provider into the PostgreSQL database you own and control, on Supabase, Neon, AWS RDS, Railway, DigitalOcean or anywhere else. We hold the credential needed to run the sync and the configuration describing it, not a warehouse of your records. Disconnecting leaves every synced table exactly where it is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is OAuth safer than an API key?
&lt;/h3&gt;

&lt;p&gt;Usually, for two reasons that have nothing to do with cryptography: no secret is copied and pasted through a browser and an email client on its way to the vendor, and you can revoke the grant from your own account without involving them. The exception is a coarse OAuth scope that grants more than a well-scoped API key would, which is precisely the QuickBooks situation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do you have SOC 2 certification?
&lt;/h3&gt;

&lt;p&gt;No. We are a small UK company and have not been through a SOC 2 or ISO 27001 audit. We handle personal data under UK GDPR, encrypt credentials at rest with AES-256-GCM and use TLS 1.3 in transit. If your procurement requires a SOC 2 report, we are not the right supplier yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens to my credentials if I stop using the service?
&lt;/h3&gt;

&lt;p&gt;Deleting a connection removes the stored credential, and deleting your account removes all of them. You do not have to rely on that alone, which is the point of the section above: expire the Stripe key or disconnect the app from Xero or QuickBooks and access ends immediately, regardless of what any vendor does on their side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;The safest posture with any sync tool is not trusting the right vendor. It is granting the least access the job needs, in a form you can watch and withdraw on your own.&lt;/p&gt;

&lt;p&gt;Ask the seven questions. Insist on a restricted or read-only credential. Read the request log a week later. Do that and the answer to "is it safe" stops depending on marketing copy, including ours.&lt;/p&gt;

&lt;p&gt;If our answers hold up, &lt;a href="https://codelesssync.com/sign-up" rel="noopener noreferrer"&gt;start with a Stripe sandbox key&lt;/a&gt; and watch exactly what the integration does before you give it anything live.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/validate-secure-stripe-api-keys" rel="noopener noreferrer"&gt;How to Validate and Secure Your Stripe API Keys&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/sync-supabase-securely-with-oauth" rel="noopener noreferrer"&gt;Sync Supabase via OAuth: No Connection String Needed&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/sync-multiple-xero-organisations-to-one-database" rel="noopener noreferrer"&gt;How to Sync Multiple Xero Organisations into One Database&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/why-stripe-postgresql-sync-keeps-breaking" rel="noopener noreferrer"&gt;Why Your Stripe to PostgreSQL Sync Keeps Breaking&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/xero-api-integration-guide" rel="noopener noreferrer"&gt;Xero API Integration Guide for Developers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/docs/guides/stripe-setup" rel="noopener noreferrer"&gt;Stripe setup guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>api</category>
      <category>stripe</category>
    </item>
    <item>
      <title>How to Sync Multiple Xero Organisations into One Database</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Mon, 07 Sep 2026 11:35:46 +0000</pubDate>
      <link>https://dev.to/ilshadyx/how-to-sync-multiple-xero-organisations-into-one-database-30pc</link>
      <guid>https://dev.to/ilshadyx/how-to-sync-multiple-xero-organisations-into-one-database-30pc</guid>
      <description>&lt;p&gt;If you look after more than one Xero organisation, you already know the drill. Log into the first org, run the report, export it. Log out. Log into the second org, run the same report, export it again. Paste six exports into a spreadsheet and hope nobody asks you to refresh it next week.&lt;/p&gt;

&lt;p&gt;This is not a workflow problem you are failing to solve. Xero is built around one organisation at a time, and consolidated reporting across organisations is a long-standing request that Xero has &lt;a href="https://productideas.xero.com/forums/967133-reports-tax/suggestions/44960413-reporting-ability-to-consolidate-multiple-xero-o" rel="noopener noreferrer"&gt;not committed to building&lt;/a&gt;. Every accountant, bookkeeper and agency running several client files hits the same wall.&lt;/p&gt;

&lt;p&gt;There is a way out that does not involve a spreadsheet: put every organisation's data into one PostgreSQL database, tagged by organisation, and query the lot with SQL. This post covers why Xero makes it awkward, the one schema decision that makes it work, and what to watch for when you consolidate across clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Xero Makes This Awkward
&lt;/h2&gt;

&lt;p&gt;Xero treats each organisation as a separate world, and its API is built the same way.&lt;/p&gt;

&lt;p&gt;When you authorise an app against Xero, you are not authorising an account, you are authorising a set of organisations, each identified by a &lt;strong&gt;tenant ID&lt;/strong&gt;. Every single API call then has to name which organisation it is for, by passing that tenant ID in a header. Get it wrong and you do not get an empty result, you get an error.&lt;/p&gt;

&lt;p&gt;That design is sensible. An accountant with forty client files should not be able to leak one client's ledger into another's report by forgetting a filter. But it means anything that reads Xero data has to be built for multiple organisations from the start, and most things are not. The mechanics of the OAuth flow and the tenant header are covered in our &lt;a href="https://codelesssync.com/blog/xero-api-integration-guide" rel="noopener noreferrer"&gt;Xero API integration guide&lt;/a&gt; if you want the developer-level detail.&lt;/p&gt;

&lt;p&gt;The result is a market full of tools that connect to one organisation nicely and fall apart at five.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One Schema Decision That Makes This Work
&lt;/h2&gt;

&lt;p&gt;Here is where most home-grown attempts go wrong. Faced with several organisations, the instinct is to give each one its own table, or worse, its own database. &lt;code&gt;acme_invoices&lt;/code&gt;, &lt;code&gt;globex_invoices&lt;/code&gt;, &lt;code&gt;initech_invoices&lt;/code&gt;, on and on.&lt;/p&gt;

&lt;p&gt;It works for two clients. At ten it is a maintenance problem, because every new client means new tables, every query means a &lt;code&gt;UNION&lt;/code&gt; across a list you have to keep updating, and every schema change has to be applied everywhere.&lt;/p&gt;

&lt;p&gt;The alternative is one table per data type for all organisations, with the tenant ID carried as a column and folded into the primary key. Codeless Sync builds its Xero tables this way: the primary key is the composite &lt;code&gt;(id, tenant_id)&lt;/code&gt;, and &lt;code&gt;tenant_id&lt;/code&gt; is indexed. Two organisations can hold a contact or an invoice with the same Xero ID without colliding, and every table is one query away from being filtered or grouped by organisation.&lt;/p&gt;

&lt;p&gt;That single choice is what turns "six exports and a spreadsheet" into &lt;code&gt;GROUP BY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second piece is the &lt;code&gt;xero_organisations&lt;/code&gt; table. Xero exposes organisation metadata as a data type of its own, so you can sync it alongside the rest and get a row per organisation carrying the trading name, legal name, base currency, country code, financial year end month and timezone, keyed by the same &lt;code&gt;tenant_id&lt;/code&gt;. Without it your consolidated queries are full of GUIDs. With it they read like a client list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting It Up
&lt;/h2&gt;

&lt;p&gt;The shape of the job, assuming you are not writing the sync yourself:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Connect each organisation.&lt;/strong&gt; Authorise Xero once per organisation you want to sync. Where you have access to several, you pick which one this connection is for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose the data types.&lt;/strong&gt; Contacts, invoices, payments, accounts, bank transactions, credit notes, items, purchase orders, journals and organisations are all available. Most consolidated reporting needs fewer than you think: invoices, contacts and organisations covers a surprising amount.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create the destination tables once.&lt;/strong&gt; Not once per client, once. Every organisation writes into the same tables, separated by &lt;code&gt;tenant_id&lt;/code&gt;. Tables can be auto-created, or you can run the &lt;a href="https://codelesssync.com/docs/sql-templates/xero-invoices" rel="noopener noreferrer"&gt;SQL templates&lt;/a&gt; yourself if you would rather see the DDL first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put it on a schedule.&lt;/strong&gt; Daily is the sensible default for accounting data. Our post on &lt;a href="https://codelesssync.com/blog/how-often-to-sync-billing-data-to-postgres" rel="noopener noreferrer"&gt;how often to sync billing data&lt;/a&gt; covers picking a cadence properly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you have never done the single-organisation version, &lt;a href="https://codelesssync.com/blog/how-to-sync-xero-data-to-postgresql" rel="noopener noreferrer"&gt;how to sync Xero to PostgreSQL&lt;/a&gt; walks the whole flow start to finish. Multi-organisation is that, repeated per client, into the same tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Queries That Make It Worth Doing
&lt;/h2&gt;

&lt;p&gt;This is the part that pays for the setup. All three of these are impossible in Xero's own reporting because they cross organisations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outstanding receivables, every client, one screen.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;organisation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;open_invoices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_due&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;outstanding&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;xero_invoices&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;xero_organisations&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACCREC'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'AUTHORISED'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency_code&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;outstanding&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Who is overdue, across the whole book.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;organisation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invoice_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contact_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;due_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;current_date&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;due_date&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;days_overdue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_due&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency_code&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;xero_invoices&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;xero_organisations&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACCREC'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'AUTHORISED'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;due_date&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="k"&gt;current_date&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;days_overdue&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Which client files have gone stale.&lt;/strong&gt; Worth running before anyone trusts a number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;organisation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;synced_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;last_synced&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_date_utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;latest_change_in_xero&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;xero_invoices&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;xero_organisations&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;last_synced&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these needs a reporting product, a BI licence or a spreadsheet. They are ordinary SQL against tables you own, which also means they can feed a dashboard, a scheduled email, or your own client portal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Watch For
&lt;/h2&gt;

&lt;p&gt;Consolidating across organisations introduces problems that single-org syncing never shows you. These are the ones that bite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not sum across currencies.&lt;/strong&gt; Invoices carry their own &lt;code&gt;currency_code&lt;/code&gt;, and organisations carry a &lt;code&gt;base_currency&lt;/code&gt;. A group with a UK and an Australian entity will happily let you add pounds to Australian dollars and produce a number that means nothing. Group by currency, as the queries above do, or convert deliberately with a rate table you control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Financial year ends differ.&lt;/strong&gt; &lt;code&gt;financial_year_end_month&lt;/code&gt; exists on the organisation row for exactly this reason. Two clients on different year ends cannot share a naive "this year" filter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Demo companies pollute totals.&lt;/strong&gt; Xero's demo organisation is real data as far as an API is concerned. The organisation row carries &lt;code&gt;is_demo_company&lt;/code&gt;, so exclude it explicitly rather than noticing later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Access is per organisation, and clients can revoke it.&lt;/strong&gt; Each organisation is authorised separately, which is the correct security posture, and it also means a client offboarding removes one connection and leaves the rest untouched. Build the staleness query above and you will notice a revoked connection in a day rather than a quarter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limits apply per organisation as well as per app.&lt;/strong&gt; Xero meters each organisation separately, with a daily cap that depends on the connecting app's Xero subscription tier, so ten organisations do not all contend for one shared bucket. It is one of the few things about Xero's API that gets easier at scale rather than harder. The specifics are in our &lt;a href="https://codelesssync.com/blog/xero-api-integration-guide" rel="noopener noreferrer"&gt;Xero API guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The number that binds is rows, not clients.&lt;/strong&gt; This is the one people get wrong when they price it up. Plans are metered on rows processed per month, and a full sync reprocesses the whole table every run. Ten organisations synced daily on full mode is a lot of repeated work. Use a fixed lookback window for scheduled runs instead, keep the window wider than the gap between runs, and reserve full syncs for the first load and the occasional reconciliation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Costs
&lt;/h2&gt;

&lt;p&gt;Worth doing the arithmetic before you commit, because the shape is not obvious.&lt;/p&gt;

&lt;p&gt;Each organisation and data type pairing is one sync configuration. Ten client organisations syncing invoices, contacts and organisation details is thirty configurations, all writing into one database.&lt;/p&gt;

&lt;p&gt;On &lt;a href="https://codelesssync.com/pricing" rel="noopener noreferrer"&gt;Codeless Sync pricing&lt;/a&gt;, that lands on Pro at $29 a month, which allows 30 configurations across 3 database projects with a 100,000 row monthly allowance and 90 days of sync history. Starter at $19 covers 10 configurations, which suits three or four clients on a couple of data types. Business at $99 goes to 100 configurations and 10 projects, which is agency scale. The free tier allows 2 configurations and covers Xero contacts and invoices only, so it is a way to try one client, not to run a book of them, and the organisation joins above need a paid plan.&lt;/p&gt;

&lt;p&gt;Scheduled syncing is a paid feature at every tier: daily, weekly or monthly on Starter and Pro, with a 12-hourly option on Business. For accounting data, daily is almost always the right answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  When You Want a Reporting Tool Instead
&lt;/h2&gt;

&lt;p&gt;It would be dishonest to pretend this replaces everything, so here is the line.&lt;/p&gt;

&lt;p&gt;Products like Joiin, Fathom and Spotlight Reporting exist to produce consolidated financial statements, and they do things this approach does not: intercompany eliminations, group structures with ownership percentages, formatted board packs, budget versus actual. If what you need is a monthly group P&amp;amp;L that an investor will read, buy one of those.&lt;/p&gt;

&lt;p&gt;What syncing gives you instead is the raw ledger in a database you own, queryable in SQL, joinable against everything else you hold, and available to any tool that speaks Postgres. If your reporting is bespoke, if you are building a client dashboard, or if the interesting questions cross Xero and your own systems, a reporting product cannot get you there and a database can.&lt;/p&gt;

&lt;p&gt;The two are not really competitors. One produces statements, the other produces a data layer. Plenty of firms end up with both.&lt;/p&gt;

&lt;p&gt;If you are still deciding whether to build the sync yourself, &lt;a href="https://codelesssync.com/blog/xero-api-vs-database-sync" rel="noopener noreferrer"&gt;Xero API vs database sync&lt;/a&gt; works through the build-versus-buy maths, and &lt;a href="https://codelesssync.com/blog/how-to-export-xero-data-to-database" rel="noopener noreferrer"&gt;how to export Xero data to a database&lt;/a&gt; covers the one-off export options.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can Xero consolidate multiple organisations natively?
&lt;/h3&gt;

&lt;p&gt;No. Xero is designed around one organisation at a time, and cross-organisation consolidated reporting is a long-standing feature request Xero has not committed to. Consolidation happens outside Xero, either in a reporting product or in a database you control.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I tell which organisation a row came from?
&lt;/h3&gt;

&lt;p&gt;By its tenant ID. Xero identifies each organisation with a tenant ID, and a well-designed destination table stores it as a column and includes it in the primary key. Sync the organisations data type as well and you can join on &lt;code&gt;tenant_id&lt;/code&gt; to get trading names, currencies and countries instead of bare identifiers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a separate table for each Xero organization?
&lt;/h3&gt;

&lt;p&gt;No, and you should avoid it. Separate tables per organization means new tables for every client, &lt;code&gt;UNION&lt;/code&gt; queries that need editing whenever the client list changes, and schema updates applied many times over. A composite primary key of record ID plus tenant ID lets every organisation share one table safely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will invoice IDs from different organisations clash?
&lt;/h3&gt;

&lt;p&gt;Not if the table is keyed on both the record ID and the tenant ID. That composite key is what allows two organisations to hold records with the same identifier in the same table without overwriting each other.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many Xero organisations can I sync?
&lt;/h3&gt;

&lt;p&gt;In practice the limit is how many sync configurations your plan allows, since each organisation and data type pairing counts as one. Thirty configurations covers ten organisations across three data types. Xero also caps how many organisations can connect to any given app, and that cap sits with the app rather than with you, so if you are planning to connect a large book of clients it is worth asking us first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I do the same thing with QuickBooks or Stripe?
&lt;/h3&gt;

&lt;p&gt;Yes for QuickBooks. Its tables use the same composite key pattern, keyed on the record ID plus the QuickBooks company ID, so several companies share one table safely. Stripe works differently. A Stripe account is a single entity, so the tables are keyed on the record ID alone with no account column, and two Stripe accounts writing to one table would leave you unable to tell the rows apart. Give each Stripe account its own database project instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Multi-organisation Xero reporting looks like a Xero problem, and it is really a storage problem. Xero will keep handing you one organisation at a time, correctly, because that is what it is for. The fix is to stop asking it for a consolidated view and to keep one yourself, in a database, with a tenant ID on every row.&lt;/p&gt;

&lt;p&gt;Once the data is there, the questions that used to take an afternoon of exports take a &lt;code&gt;GROUP BY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://codelesssync.com/sign-up" rel="noopener noreferrer"&gt;Try it with one client organisation&lt;/a&gt; and see the first table land in your own database in about five minutes.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/how-to-sync-xero-data-to-postgresql" rel="noopener noreferrer"&gt;How to Sync Xero to PostgreSQL Automatically in 5 Minutes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/xero-api-integration-guide" rel="noopener noreferrer"&gt;Xero API Integration Guide for Developers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/xero-api-vs-database-sync" rel="noopener noreferrer"&gt;Xero API vs Database Sync: Which is Better?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/how-to-export-xero-data-to-database" rel="noopener noreferrer"&gt;How to Export Xero Data to a Database&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/docs/sql-templates/xero-organisations" rel="noopener noreferrer"&gt;Xero organisations SQL template&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>api</category>
      <category>xero</category>
    </item>
    <item>
      <title>Stripe Data Pipeline Doesn't Sync to PostgreSQL: What to Use Instead</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Wed, 02 Sep 2026 10:55:53 +0000</pubDate>
      <link>https://dev.to/ilshadyx/stripe-data-pipeline-doesnt-sync-to-postgresql-what-to-use-instead-40h3</link>
      <guid>https://dev.to/ilshadyx/stripe-data-pipeline-doesnt-sync-to-postgresql-what-to-use-instead-40h3</guid>
      <description>&lt;p&gt;You went looking for the official answer. Stripe sells a product called Data Pipeline whose entire job is getting your Stripe data out of Stripe and into somewhere you can query it, which sounds exactly like what you need. Then you open the destination list.&lt;/p&gt;

&lt;p&gt;Snowflake. Amazon Redshift. Databricks. BigQuery. Google Cloud Storage, Azure Blob Storage, Amazon S3.&lt;/p&gt;

&lt;p&gt;No PostgreSQL. No Supabase, no Neon, no RDS. If your stack is a Postgres database, the Data Pipeline you can actually buy today does not meet you there. The two Stripe products that will are both locked behind a preview.&lt;/p&gt;

&lt;p&gt;This post covers what Data Pipeline actually is, what Stripe is quietly building for Postgres users and why you probably cannot have it yet, what the cloud storage route really costs if you decide to bridge the gap yourself, and the options that exist today.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stripe Data Pipeline Actually Is
&lt;/h2&gt;

&lt;p&gt;Data Pipeline is a paid Stripe product that pushes a copy of your Stripe data to a destination you own, with no code on your side. Per &lt;a href="https://docs.stripe.com/data/access-data-in-warehouse" rel="noopener noreferrer"&gt;Stripe's documentation&lt;/a&gt;, it supports two shapes of destination:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data warehouses&lt;/strong&gt; (Snowflake, Amazon Redshift, Databricks, BigQuery). Stripe sends a data share to your warehouse, with one instructive exception covered in the next section. Either way, your core Stripe data is available within 12 hours of setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloud storage&lt;/strong&gt; (Google Cloud Storage, Azure Blob Storage, Amazon S3). Stripe drops &lt;a href="https://parquet.apache.org/" rel="noopener noreferrer"&gt;Parquet&lt;/a&gt; files into a bucket you own.&lt;/p&gt;

&lt;p&gt;After the initial load, both routes refresh on the same cadence: a &lt;strong&gt;new full load of your data every 3 hours&lt;/strong&gt;, with Stripe noting that some tables update on their own schedules as new data becomes available. Your data is split into a &lt;code&gt;STRIPE&lt;/code&gt; schema for live mode and a &lt;code&gt;STRIPE_TESTMODE&lt;/code&gt; schema for sandboxes, every table carries a &lt;code&gt;merchant_id&lt;/code&gt; column so multiple Stripe accounts can share one destination, and a &lt;code&gt;data_load_times&lt;/code&gt; table tells you when each table was last refreshed.&lt;/p&gt;

&lt;p&gt;Two things worth knowing that rarely make it into comparison posts. A Data Pipeline subscription &lt;strong&gt;includes Stripe Sigma&lt;/strong&gt;, so you are buying the in-dashboard SQL tool along with the export. And Stripe does not offer Data Pipeline to customers in &lt;strong&gt;India&lt;/strong&gt;, because of data localization requirements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why PostgreSQL Is Not on the List
&lt;/h2&gt;

&lt;p&gt;It is tempting to read the destination list as an oversight. The setup mechanics are more interesting than that, and they explain the shape of what Stripe is building next.&lt;/p&gt;

&lt;p&gt;Three of the four warehouse destinations arrive as a &lt;strong&gt;native data share&lt;/strong&gt;, a feature of the warehouse rather than anything Stripe operates. In Snowflake you accept a share from a Stripe account and mount it as a database. In Redshift you associate an incoming datashare, which only works on an RA3 cluster with encryption enabled. In Databricks it comes through Delta Sharing and you mount the Stripe share to a catalog. No bytes move through infrastructure Stripe runs. It publishes, you accept, and your warehouse does the rest.&lt;/p&gt;

&lt;p&gt;BigQuery is the exception. There is no share: you create a service account, grant it &lt;code&gt;BigQuery Job User&lt;/code&gt; and &lt;code&gt;BigQuery Data Editor&lt;/code&gt;, stand up an intermediate Cloud Storage bucket for Stripe to write to, and Stripe runs load jobs into your datasets, deleting the intermediate files afterwards.&lt;/p&gt;

&lt;p&gt;That exception is the tell. Stripe will operate a real write pipeline into a destination you own when it decides the destination is worth the engineering. Postgres was not on the list because it is the harder version of that problem: not one managed vendor with one stable way in, but millions of instances across every host and self-run box on the internet, each with its own version, extensions, connection limits, pooler, IP allow-list and maintenance window, and no share mechanism to fall back on.&lt;/p&gt;

&lt;p&gt;Hard is not the same as never, though, which brings us to the part most comparison posts have not caught up with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stripe Is Building Postgres Support, in Two Places
&lt;/h2&gt;

&lt;p&gt;Neither is generally available. Neither is something you can go and buy this afternoon. Both are documented, and both are aimed squarely at the reader of this post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-time sync to Postgres.&lt;/strong&gt; Stripe's &lt;a href="https://docs.stripe.com/data/data-pipeline/real-time-sync-to-postgres" rel="noopener noreferrer"&gt;documentation for it&lt;/a&gt; is unambiguous: it continuously streams "your Stripe data to a Postgres database", in real time rather than in a 3-hour batch, with a schema that is "a direct mapping to the public Stripe API". Coverage is everything in the public API that has a list endpoint, so charges, customers, subscriptions, payment intents and invoices are in, while single-object resources like Balance are not, and you choose which tables to sync from the Dashboard. The &lt;a href="https://docs.stripe.com/data/data-pipeline/real-time-sync-to-postgres/configure" rel="noopener noreferrer"&gt;setup guide&lt;/a&gt; is public and specific: PostgreSQL 13 or later from any provider, with Stripe naming Amazon RDS and Aurora, Supabase, Neon and self-hosted deployments; a &lt;code&gt;stripe&lt;/code&gt; schema reserved for the pipeline; a dedicated database user that owns that schema and holds &lt;code&gt;SELECT&lt;/code&gt;, &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; on it; an SSL connection; and sixteen Stripe IP addresses added to your firewall allowlist. Stripe then backfills the tables you picked and keeps them current. The catch is the status: it is still a &lt;strong&gt;preview&lt;/strong&gt; you request access to by email, with no published general availability date, and the commitment is to "a near-real-time basis" with no end-to-end latency SLA.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stripe Database.&lt;/strong&gt; Announced at Sessions 2026 as a managed, hosted, read-only Postgres database holding your Stripe data, created in one click from the Dashboard or a single CLI command. Its documentation page returns a 404 unless your account is enrolled in the early access programme, so the substantive public document is the &lt;a href="https://stripe.com/legal/stripe-database" rel="noopener noreferrer"&gt;preview terms&lt;/a&gt;, which are worth reading before you build anything on it. It is a Preview Service "not subject to Stripe's standard compliance certifications, access controls requirements, or related auditing commitments". Stripe "may, at its discretion, temporarily throttle, rate-limit, or suspend" your access based on query volume. It "reserves the right to apply or change Fees". And on termination, "Stripe will delete all associated data".&lt;/p&gt;

&lt;p&gt;So the honest status is that Stripe will probably solve part of this, on its own timeline. Two things stay true when it lands.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is Stripe only.&lt;/strong&gt; The real-time Postgres sync syncs Stripe data and Stripe Database holds Stripe data. If your reporting also needs QuickBooks, Xero or Paddle in the same database, that is a second tool or a warehouse regardless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With Stripe Database, the data lives in Stripe's infrastructure, not yours.&lt;/strong&gt; That is genuinely convenient, and it is also the opposite of why most people want the copy in Postgres in the first place: to join billing data against their own application tables, own the schema, and keep it somewhere they control.&lt;/p&gt;

&lt;p&gt;Until either ships, the practical answer is unchanged. If you want Stripe data in your Postgres today, something other than Data Pipeline has to put it there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Data Pipeline Costs, and What the Meter Is
&lt;/h2&gt;

&lt;p&gt;The pricing matters here because it shapes the decision even for people who could adopt a warehouse.&lt;/p&gt;

&lt;p&gt;Data Pipeline is priced in &lt;strong&gt;tiers based on your monthly charge count&lt;/strong&gt;, with a per-charge rate on anything above your tier. Read off Stripe's &lt;a href="https://stripe.com/data-pipeline/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; in September 2026, the entry tier covers up to 1,000 charges a month at $65 a month, or $50 a month on an annual plan, with 7 cents and 6 cents respectively per additional charge. Above that it is annual only: $75 a month up to 2,500 charges, $280 up to 10,000, $550 up to 25,000, and custom pricing beyond. The UK page shows the same ladder at £48 or £37, then £56, £209 and £410. New accounts get a 30-day free trial.&lt;/p&gt;

&lt;p&gt;Two things sit in the small print. &lt;strong&gt;Monthly billing exists only on the entry tier&lt;/strong&gt;, so passing 1,000 charges a month means taking on an annual commitment. And the meter counts "successful charges both on Stripe and through third-party payments processors in connection with any Stripe service", so payments you did not run through Stripe at all can still push you up a tier.&lt;/p&gt;

&lt;p&gt;Note what the meter is. You are not billed for tables, rows, syncs or queries. You are billed for &lt;strong&gt;payment volume&lt;/strong&gt;, which is to say your business getting busier, for a job whose difficulty does not change when it does. That is the same structural complaint as usage-priced ETL, arrived at from a different direction: &lt;a href="https://codelesssync.com/blog/fivetran-pricing-explained" rel="noopener noreferrer"&gt;Fivetran's monthly active rows model&lt;/a&gt; meters row churn instead, with the same result that the bill moves for reasons unrelated to what you asked for.&lt;/p&gt;

&lt;p&gt;And this is before the destination. Snowflake, Redshift, Databricks and BigQuery all bill you separately for storage and compute. Adopting a warehouse to receive this data is not a one-line decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cloud Storage Escape Hatch, and What It Really Costs
&lt;/h2&gt;

&lt;p&gt;Here is the part that a flat "Data Pipeline can't do Postgres" leaves out, and it is the honest centre of this post.&lt;/p&gt;

&lt;p&gt;There &lt;strong&gt;is&lt;/strong&gt; a technical path. Point Data Pipeline at S3, GCS or Azure Blob, and Stripe delivers Parquet files into a bucket you control. Nothing stops you writing a job that reads those files and loads them into Postgres. Stripe even helps a little: it writes a &lt;code&gt;SUCCESS&lt;/code&gt; file per run date and mode once all files for a set of tables are transferred and validated, and it updates a &lt;code&gt;/data_load_times.json&lt;/code&gt; file with the time and location of the most recent successful load for each table.&lt;/p&gt;

&lt;p&gt;Now count what you own the moment you take that path.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A loader that reads Parquet.&lt;/strong&gt; Postgres does not ingest Parquet natively. You are writing a job, in a language of your choice, on a runtime you have to host, or standing up something like DuckDB or a Spark step in between.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type mapping.&lt;/strong&gt; Parquet's types are not Postgres types. Timestamps, nested structures, &lt;code&gt;numeric&lt;/code&gt; precision and the JSON-shaped fields in Stripe's schema each need a decision, and the wrong one is usually discovered months later inside a report. Our post on &lt;a href="https://codelesssync.com/blog/postgresql-schema-for-stripe-data" rel="noopener noreferrer"&gt;designing a Postgres schema for Stripe data&lt;/a&gt; covers the choices that bite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplication, every three hours, forever.&lt;/strong&gt; This is the big one. Each run is a &lt;strong&gt;full load&lt;/strong&gt;, not a changelog. You are handed the whole dataset again eight times a day, and it is entirely on you to turn that into idempotent upserts against your existing rows rather than duplicates. Get the conflict target wrong once and you are cleaning up a table by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orchestration and failure handling.&lt;/strong&gt; Polling for the &lt;code&gt;SUCCESS&lt;/code&gt; file, not starting on a half-written batch, retrying, and alerting when a load silently stops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema drift.&lt;/strong&gt; Stripe adds fields. Your loader has to notice, and your table has to change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two bills.&lt;/strong&gt; You are paying Stripe for Data Pipeline, and paying for the bucket, the egress and whatever compute runs the loader.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is hard in the sense of being intellectually difficult. It is hard in the sense of never being finished. You wanted Stripe data in a table; you have acquired a data pipeline, and pipelines are things people end up on call for. If the appeal of Data Pipeline was "without writing code", the cloud storage route quietly hands the code back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Today's Version Is Not Real Time
&lt;/h2&gt;

&lt;p&gt;One more expectation worth resetting before you build anything around this.&lt;/p&gt;

&lt;p&gt;A 3-hour cadence is not a 3-hour freshness guarantee. Stripe's &lt;a href="https://docs.stripe.com/data/data-pipeline/data-freshness" rel="noopener noreferrer"&gt;data freshness documentation&lt;/a&gt; works the example: with a 3-hour cadence, a record created at 00:01 UTC is picked up in the 03:00 UTC batch, and with a typical delivery time of about three hours after the batch closes, it lands by 06:00 UTC. That is a &lt;strong&gt;P50 freshness of six hours from event creation&lt;/strong&gt;. Half the time it is slower than that, delivery varies with your data volume, and derived datasets run on their own schedules.&lt;/p&gt;

&lt;p&gt;That is completely fine for finance, reporting and analytics, which is what the product is for. It is not fine if you were planning to drive product behaviour off the copy. Real-time streaming is exactly what the Postgres sync in preview promises, which is Stripe's own way of conceding that the shipping product does not do it. Until that is generally available, if you need a customer's subscription state correct within seconds, that is a webhook job rather than a sync job. We covered where the line sits in &lt;a href="https://codelesssync.com/blog/how-often-to-sync-billing-data-to-postgres" rel="noopener noreferrer"&gt;how often you should sync billing data to Postgres&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your Real Options for Getting Stripe Data into Postgres Today
&lt;/h2&gt;

&lt;p&gt;Assuming Postgres is not negotiable, these are the paths, with the trade-off named honestly.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What it costs you&lt;/th&gt;
&lt;th&gt;Best when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wait for Stripe's real-time Postgres sync&lt;/td&gt;
&lt;td&gt;No money, but no date either, and access is Stripe's decision&lt;/td&gt;
&lt;td&gt;Stripe is your only source and the timing genuinely does not matter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adopt a warehouse&lt;/td&gt;
&lt;td&gt;Warehouse bill plus a Data Pipeline tier, and a second system to run&lt;/td&gt;
&lt;td&gt;You already run Snowflake, BigQuery, Redshift or Databricks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud storage plus your own loader&lt;/td&gt;
&lt;td&gt;Data Pipeline tier, storage, compute, and permanent ownership of the job&lt;/td&gt;
&lt;td&gt;You have a data engineer and Parquet is already in your stack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe API plus your own script&lt;/td&gt;
&lt;td&gt;Build time, pagination, rate limits, retries, schema upkeep&lt;/td&gt;
&lt;td&gt;Your needs are narrow enough that nothing off the shelf fits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed sync straight into Postgres&lt;/td&gt;
&lt;td&gt;A flat subscription, and a narrower feature set than a warehouse platform&lt;/td&gt;
&lt;td&gt;Postgres is the destination and you want the job to be finished&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you want the full field rather than these four, &lt;a href="https://codelesssync.com/blog/5-ways-to-get-stripe-data-into-postgresql" rel="noopener noreferrer"&gt;5 ways to get Stripe data into PostgreSQL&lt;/a&gt; walks the methods, and &lt;a href="https://codelesssync.com/blog/best-tools-to-sync-stripe-data-to-a-database" rel="noopener noreferrer"&gt;the best tools to sync Stripe data to a database&lt;/a&gt; reviews seven products with their weak points included.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Data Pipeline Is Still the Right Call
&lt;/h2&gt;

&lt;p&gt;It would be a bad post that only argued one way, so here is the case for buying it.&lt;/p&gt;

&lt;p&gt;If a warehouse is already in your stack, Data Pipeline is close to unbeatable for Stripe specifically. It is first-party, and Stripe's pitch is that you get "structured and ready-to-use datasets not available through third-party APIs or ETLs", which nothing built on the public API can match. On the three share-based destinations there is no pipeline to run at all. Multi-account businesses can fold every Stripe account into one destination and filter on &lt;code&gt;merchant_id&lt;/code&gt;, and Organizations gives administrators a single view of every pipeline. Sigma is bundled rather than sold separately, so the reporting layer arrives with it, which we compared in &lt;a href="https://codelesssync.com/blog/best-stripe-sigma-alternative-for-postgresql" rel="noopener noreferrer"&gt;the best Stripe Sigma alternative for Postgres users&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Two limits to know before committing: you can connect only one warehouse account to a Stripe account at a time, and the pre-built financial report templates are Snowflake only, not available on Redshift, Databricks or BigQuery.&lt;/p&gt;

&lt;p&gt;The question is not whether it is a good product. It is whether you are willing to run a warehouse in order to use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Postgres-Native Version
&lt;/h2&gt;

&lt;p&gt;If your destination is Postgres and you want the job to stay finished, that is the narrow problem &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; exists for.&lt;/p&gt;

&lt;p&gt;You connect Stripe with a restricted, read-only API key, choose a data type, and the destination table is created for you in your own database. It works with Supabase, Neon, AWS RDS, Railway, DigitalOcean and any other Postgres you can reach, and the same setup covers QuickBooks, Xero and Paddle if your billing data is not all in one place. The first sync takes about five minutes.&lt;/p&gt;

&lt;p&gt;The difference from the cloud storage route is not the setup, it is the ownership. There is no Parquet reader, no type mapping, no dedupe logic, no &lt;code&gt;SUCCESS&lt;/code&gt; file to poll, no schema drift to chase. Syncs upsert on the record's Stripe ID, so a re-run corrects rather than duplicates, and rate limits, retries and pagination are ours to worry about.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://codelesssync.com/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; is a flat monthly fee rather than a meter on your payment volume. Free is $0 for 2 manual syncs a day, 2 configurations and one database project. Starter is $19 a month, Pro is $29 and Business is $99, each adding configurations and a larger monthly row allowance, with more database projects from Pro upwards. Scheduled syncs are the paid feature: Starter and Pro run daily, weekly or monthly, and Business adds a 12-hourly option.&lt;/p&gt;

&lt;p&gt;Put the two next to each other on the axes that actually differ:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Stripe Data Pipeline&lt;/th&gt;
&lt;th&gt;Codeless Sync&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;Not a destination&lt;/td&gt;
&lt;td&gt;The only destination&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Priced on&lt;/td&gt;
&lt;td&gt;Your monthly charge count&lt;/td&gt;
&lt;td&gt;Flat monthly fee&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Second system needed&lt;/td&gt;
&lt;td&gt;A warehouse, or a bucket plus a loader you build&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cadence&lt;/td&gt;
&lt;td&gt;Full load every 3 hours, P50 about 6 hours old&lt;/td&gt;
&lt;td&gt;Manual, or scheduled daily to monthly, 12-hourly on Business&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sources&lt;/td&gt;
&lt;td&gt;Stripe&lt;/td&gt;
&lt;td&gt;Stripe, QuickBooks, Xero, Paddle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dataset coverage&lt;/td&gt;
&lt;td&gt;First-party, includes data the API does not expose&lt;/td&gt;
&lt;td&gt;Common billing tables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundled reporting&lt;/td&gt;
&lt;td&gt;Includes Sigma&lt;/td&gt;
&lt;td&gt;No, you query your own Postgres&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Data Pipeline wins coverage and completeness. It cannot win a single row of that table that involves the word Postgres, and that is the row you came here for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does Stripe Data Pipeline support PostgreSQL?
&lt;/h3&gt;

&lt;p&gt;No. Data Pipeline delivers to Snowflake, Amazon Redshift, Databricks and BigQuery, or to Google Cloud Storage, Azure Blob Storage and Amazon S3. PostgreSQL, including Supabase, Neon and AWS RDS, is not a supported destination.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Stripe adding PostgreSQL support to Data Pipeline?
&lt;/h3&gt;

&lt;p&gt;Yes, through a real-time sync to Postgres that is still in preview. Stripe's documentation describes it as continuously streaming Stripe data to a Postgres database, with a schema mapped directly to the public API, and the setup guide is already public: PostgreSQL 13 or later from any provider including RDS, Supabase and Neon, a reserved &lt;code&gt;stripe&lt;/code&gt; schema, a dedicated database user, SSL, and sixteen Stripe IP addresses allowlisted on your firewall. You request access by email, and there is no published general availability date.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is Stripe Database?
&lt;/h3&gt;

&lt;p&gt;A managed, hosted, read-only Postgres database of your Stripe data, announced at Sessions 2026 and still in preview, with documentation gated behind early access enrolment. Note that the data sits in Stripe's infrastructure rather than your own, so you cannot join it against your application tables, and the public preview terms let Stripe throttle access, change fees, and delete all associated data on termination.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I get Stripe Data Pipeline into Supabase or Neon?
&lt;/h3&gt;

&lt;p&gt;Not directly. The nearest path is exporting Parquet files to cloud storage and writing your own job to load them into your database, which means owning a loader, type mapping, three-hourly deduplication and schema drift. A managed Stripe to Postgres sync does the same job without the pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much does Stripe Data Pipeline cost?
&lt;/h3&gt;

&lt;p&gt;It is tiered on your monthly charge count, with a per-charge rate above each tier. Checked in September 2026, the entry tier covers up to 1,000 charges a month at $65 a month, or $50 a month on an annual plan, and the tiers above it are annual only at $75, $280 and $550 a month for 2,500, 10,000 and 25,000 charges. Warehouse storage and compute are billed separately by your warehouse provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  How fresh is Stripe Data Pipeline data?
&lt;/h3&gt;

&lt;p&gt;Stripe delivers a full load every 3 hours. Because a record waits for the next batch and then for delivery, Stripe's own worked example comes to a P50 freshness of about six hours from event creation, with delivery time varying by data volume.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Stripe Data Pipeline the same as Stripe Sigma?
&lt;/h3&gt;

&lt;p&gt;No, though a Data Pipeline subscription includes Sigma. Sigma runs SQL inside the Stripe Dashboard and returns results there. Data Pipeline copies your data to an external destination you own.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the fastest way to get Stripe data into a Postgres database?
&lt;/h3&gt;

&lt;p&gt;A managed sync. Connecting a read-only Stripe key and letting the tool create the destination table takes about five minutes, against the days it takes to build a Parquet loader or a paginated API script, and it keeps working afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Stripe Data Pipeline is a good product aimed at a reader who is not you. It assumes a warehouse, it prices on payment volume, and its Postgres story today is a bucket of Parquet files plus a job you write yourself. The version with a real Postgres destination exists, and it is in preview with no date, which is not something you can plan a quarter around.&lt;/p&gt;

&lt;p&gt;If Postgres is where your data lives, skip the detour. Either adopt a warehouse deliberately, for reasons that go beyond this one export, or use something built to land Stripe data in Postgres and be done with it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://codelesssync.com/sign-up" rel="noopener noreferrer"&gt;Start a free sync&lt;/a&gt; and see your Stripe tables in your own database in about five minutes.&lt;/p&gt;

</description>
      <category>api</category>
      <category>database</category>
      <category>postgres</category>
      <category>stripe</category>
    </item>
    <item>
      <title>How Often Should You Sync Billing Data to Postgres?</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Mon, 24 Aug 2026 07:28:28 +0000</pubDate>
      <link>https://dev.to/ilshadyx/how-often-should-you-sync-billing-data-to-postgres-44ak</link>
      <guid>https://dev.to/ilshadyx/how-often-should-you-sync-billing-data-to-postgres-44ak</guid>
      <description>&lt;p&gt;&lt;em&gt;Daily, twice daily, weekly or monthly. How to pick a sync schedule for Stripe, QuickBooks, Xero or Paddle data, and why real-time is usually the wrong goal.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;By Ilshaad Kheerdali · 24 August 2026&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;You have your billing data landing in Postgres. The connection works, the table is there, the first sync completed. Now there is a dropdown asking how often you want to repeat it, and no obvious way to answer.&lt;/p&gt;

&lt;p&gt;The instinct is to pick the fastest option available, on the reasoning that fresher is better and the sync is automated anyway. That instinct is worth resisting. For billing data specifically, the right cadence is usually slower than people expect, and picking a faster one costs you in places that are not obvious on day one.&lt;/p&gt;

&lt;p&gt;This post covers how to choose, how to match the sync mode to the schedule you picked, and the one case where scheduling is genuinely the wrong tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start With the Decision, Not the Data
&lt;/h2&gt;

&lt;p&gt;The useful question is not "how fresh could this be". It is "what decision am I making with it, and how often do I make that decision".&lt;/p&gt;

&lt;p&gt;Billing data is consumed in a small number of recognisable ways, and almost all of them are periodic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Board reporting and investor updates.&lt;/strong&gt; Monthly, occasionally quarterly. The numbers are cut at a month boundary and nothing that happens on a Tuesday afternoon changes them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MRR, churn and cohort dashboards.&lt;/strong&gt; Reviewed weekly by most teams, daily by some. The underlying figures move slowly enough that hourly refreshes show noise, not signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Month-end reconciliation.&lt;/strong&gt; Intense for a few days, irrelevant for the rest of the month. Daily is comfortably enough, because the work happens against a closed period.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customer support lookups.&lt;/strong&gt; Feels like it needs to be live, usually is not. Support agents overwhelmingly need "what plan is this person on and did their last invoice clear", which a daily copy answers correctly for the vast majority of tickets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dunning and failed payment follow-up.&lt;/strong&gt; The most time-sensitive of the common cases, and the one worth syncing twice a day for if you act on it manually.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your use case is on that list, daily is the default answer and you should need a reason to move away from it. Weekly is right more often than people admit, particularly for accounting data from QuickBooks or Xero where the source system is itself updated in batches by a human.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Changes, and How Fast
&lt;/h2&gt;

&lt;p&gt;The other half of the answer is how much your data really moves, which is usually less than it feels.&lt;/p&gt;

&lt;p&gt;Customer records are close to static. A row changes when someone updates a card, an email or a billing address. For most SaaS businesses that is a low single-digit percentage of the table per month.&lt;/p&gt;

&lt;p&gt;Subscriptions move more than they look like they do, because every status transition is a write: trialing to active, active to past_due, past_due to canceled, plus plan changes, quantity changes and renewals. Still, an individual subscription generates a handful of changes a year, not a day.&lt;/p&gt;

&lt;p&gt;Invoices and charges are the genuinely busy tables, and they are lifecycle-heavy. An invoice moves through draft, open and paid, with a possible detour through a failed payment and a retry. But they are also the most predictable: they are created on billing anniversaries and settle within days.&lt;/p&gt;

&lt;p&gt;Accounting data behaves differently again. QuickBooks and Xero records change when a person enters them, which means they arrive in bursts around invoicing runs and month end, and are quiet in between. Syncing a Xero ledger every hour mostly re-reads a ledger nobody has touched since yesterday.&lt;/p&gt;

&lt;p&gt;The pattern across all four providers is the same: billing data changes in bursts tied to billing cycles and human work rhythms, not continuously. A schedule that matches those rhythms captures nearly everything a faster schedule would, at a fraction of the cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Costs of Syncing Too Often
&lt;/h2&gt;

&lt;p&gt;Faster schedules are not free, and the bill arrives in four places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your provider's rate limits.&lt;/strong&gt; Stripe's &lt;a href="https://docs.stripe.com/rate-limits" rel="noopener noreferrer"&gt;published limits&lt;/a&gt; are 100 requests per second in live mode and 25 per second for an individual endpoint, which sounds like plenty. The constraint that actually bites is quieter: Stripe allocates read requests relative to your transaction count, at "an average of 500 per transaction" over a rolling 30 days, with a floor of 10,000 read requests per month. A business processing 100 transactions a month has a 50,000 read allocation, and a paginated full sync of several tables can consume a meaningful slice of that in a single run. Multiply by 24 runs a day and the arithmetic stops working. Xero is stricter still, with a per-tenant daily cap that starts at 1,000 calls a day on its entry tier. QuickBooks and Paddle both enforce their own per-minute limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sync duration and overlap.&lt;/strong&gt; Every run has fixed overhead: authenticating, paginating, comparing rows, writing. Schedule runs closer together than a run takes to finish and you get overlapping jobs competing for the same table, which is a good way to turn a working pipeline into an intermittent one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your own quota.&lt;/strong&gt; Every managed sync tool meters something. Runs that produce no changes still consume your daily sync allowance, so an aggressive schedule spends your plan on re-reading unchanged rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database write amplification.&lt;/strong&gt; Frequent full syncs rewrite rows that did not change, which inflates your Postgres write volume and, on serverless hosts that bill on compute time, your database bill too.&lt;/p&gt;

&lt;p&gt;None of these are catastrophic individually. Together they mean an hourly schedule on data that changes daily is pure waste.&lt;/p&gt;

&lt;h2&gt;
  
  
  Matching the Sync Mode to the Schedule
&lt;/h2&gt;

&lt;p&gt;This is the part that most often goes wrong, and it matters more than the frequency itself.&lt;/p&gt;

&lt;p&gt;A scheduled sync has two settings, not one: how often it runs, and how much data each run fetches. In Codeless Sync, a &lt;strong&gt;full sync is the default&lt;/strong&gt; and re-reads everything in the table. The alternatives are fixed lookback windows: the last day, the last 7 days, or the last 30 days. These are windows, not a bookmark. A "last 7 days" run always asks for the last 7 days of changes, regardless of when the previous run happened or whether it succeeded.&lt;/p&gt;

&lt;p&gt;That single detail drives the rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Your lookback window must be wider than the gap between runs.&lt;/strong&gt; If they are equal, one failed or delayed run leaves a permanent hole in your data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A daily schedule paired with a last-day window has zero margin. The run that fails at 2am on a Sunday silently loses Saturday's changes, and nothing later goes back for them. Pair a daily schedule with a last-7-days window instead and you get six days of self-healing overlap for almost no extra cost, because re-fetching a mostly-unchanged week is cheap.&lt;/p&gt;

&lt;p&gt;Sensible pairings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Schedule&lt;/th&gt;
&lt;th&gt;Lookback window&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Twice daily&lt;/td&gt;
&lt;td&gt;Last 7 days&lt;/td&gt;
&lt;td&gt;Wide safety margin, still a small fetch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily&lt;/td&gt;
&lt;td&gt;Last 7 days&lt;/td&gt;
&lt;td&gt;Six days of overlap absorbs failed or skipped runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weekly&lt;/td&gt;
&lt;td&gt;Last 30 days&lt;/td&gt;
&lt;td&gt;Covers a missed week and late-arriving edits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly&lt;/td&gt;
&lt;td&gt;Full sync&lt;/td&gt;
&lt;td&gt;Volume is low enough that correctness beats efficiency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any, small table&lt;/td&gt;
&lt;td&gt;Full sync&lt;/td&gt;
&lt;td&gt;Under a few thousand rows, just re-read it and stop thinking&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Full sync deserves more credit than it usually gets. It is the only mode that reliably reflects deletions and corrections made to historical records, which accounting systems produce constantly when someone amends last quarter's invoice. If your tables are small, full sync on a daily schedule is the most robust configuration available and the reason it is the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Scheduling Is the Wrong Tool
&lt;/h2&gt;

&lt;p&gt;There is a case where no schedule is correct, and it is worth naming plainly so you do not try to solve it with frequency.&lt;/p&gt;

&lt;p&gt;If you need to &lt;em&gt;react&lt;/em&gt; to an individual billing event within seconds, provisioning access the moment a payment clears, or emailing a customer the instant a card fails, that is not a reporting problem and a sync will not solve it at any cadence. Even a one-minute schedule gives you an average latency of thirty seconds and a worst case of sixty, with no ordering guarantees. Use the provider's webhooks for that specific workflow: they exist precisely for event-driven reactions, and they push in real time.&lt;/p&gt;

&lt;p&gt;The mistake is assuming that because you need webhooks for one workflow, you need them for everything. Webhooks are excellent at "tell me when this one thing happens" and poor at "give me a complete, queryable table I can run SQL against", because they deliver events rather than state, they can be missed, and they never cover history. A scheduled sync is the opposite on all three counts.&lt;/p&gt;

&lt;p&gt;Most teams end up wanting both, and that is a reasonable architecture: webhooks for the two or three events that trigger immediate action, a scheduled sync for the tables you query. We covered the trade-off in detail for &lt;a href="https://codelesssync.com/blog/stripe-webhooks-vs-database-sync" rel="noopener noreferrer"&gt;Stripe&lt;/a&gt; and for &lt;a href="https://codelesssync.com/blog/paddle-webhooks-vs-database-sync" rel="noopener noreferrer"&gt;Paddle&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting It Up
&lt;/h2&gt;

&lt;p&gt;In Codeless Sync, scheduling lives on the configuration you already created. You pick a frequency, pick a sync mode, and the schedule runs without further involvement. Daily, weekly and monthly are available on every paid plan, and twice daily is available on Business. Free accounts sync manually.&lt;/p&gt;

&lt;p&gt;Two practical notes. First, schedules are evaluated against a fixed time, so choose an hour that suits your reporting rather than leaving everything at midnight UTC, especially if your finance team works to a local month end. Second, a schedule only runs when the project, the configuration and the schedule itself are all active, so if runs stop appearing, check all three before assuming a failure.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://codelesssync.com/docs/core-concepts/schedules" rel="noopener noreferrer"&gt;schedules documentation&lt;/a&gt; covers the mechanics, and &lt;a href="https://codelesssync.com/blog/cron-expressions-for-data-syncs" rel="noopener noreferrer"&gt;how to use cron expressions for scheduled data syncs&lt;/a&gt; explains the syntax behind the presets. If you have not connected a database yet, the &lt;a href="https://codelesssync.com/docs/getting-started/quick-start" rel="noopener noreferrer"&gt;quick start&lt;/a&gt; takes about five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Reasonable Default
&lt;/h2&gt;

&lt;p&gt;If you want to stop reading and just pick something:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Daily, with a last-7-days window, at an hour that suits your reporting.&lt;/strong&gt; Move to twice daily only if you act on dunning manually. Move to weekly if your data is accounting-led and entered by hand. Switch to full sync if your tables are small or your source system amends historical records.&lt;/p&gt;

&lt;p&gt;You can change it later. That is rather the point of not agonising over it now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How often should I sync Stripe data to Postgres?
&lt;/h3&gt;

&lt;p&gt;Daily suits almost every reporting, dashboard and reconciliation use case. Twice daily is worth it if you manually work failed payments. Faster than that rarely changes a decision, and it consumes your Stripe read allocation, which is capped relative to your transaction volume rather than being unlimited.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is real-time syncing better?
&lt;/h3&gt;

&lt;p&gt;Not for billing data. Real-time is better for reacting to individual events, which is a webhook's job. For a queryable table you run SQL against, a scheduled sync gives you completeness and history, which matter more than latency for reporting.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if a scheduled sync fails?
&lt;/h3&gt;

&lt;p&gt;The next scheduled run goes ahead as normal. Because lookback windows are fixed rather than resuming from a bookmark, a failed run is only self-correcting if your window is wider than your schedule interval. A daily schedule with a last-7-days window recovers automatically. A daily schedule with a last-day window does not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use full sync or an incremental window?
&lt;/h3&gt;

&lt;p&gt;Full sync if your tables are small, or if your source system amends historical records, since it is the mode that reflects deletions and corrections. An incremental window once the table is large enough that re-reading it every run is wasteful. Full sync is the default for that reason.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does syncing more often cost more?
&lt;/h3&gt;

&lt;p&gt;The subscription price does not change, but each run counts against your daily sync allowance, and every provider enforces API rate limits. Frequent full syncs also increase Postgres write volume, which can matter on hosts that bill by compute time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I sync different tables on different schedules?
&lt;/h3&gt;

&lt;p&gt;Yes. Scheduling is set per configuration, so you can run invoices daily and customers weekly by creating separate configurations. This is usually a better use of your sync allowance than putting everything on the fastest schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;The honest answer to "how often should I sync" is "less often than you first assumed, with a wider lookback window than you first assumed".&lt;/p&gt;

&lt;p&gt;Billing data moves in bursts tied to billing cycles, and the decisions you make with it are periodic. Matching your schedule to those rhythms, and leaving enough overlap that a failed run heals itself, gets you a more reliable pipeline than chasing freshness ever will.&lt;/p&gt;

&lt;p&gt;See the &lt;a href="https://codelesssync.com/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; for which schedules come with which plan, or start on the free tier and sync manually until you know what cadence you actually need.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/cron-expressions-for-data-syncs" rel="noopener noreferrer"&gt;How to Use Cron Expressions for Scheduled Data Syncs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/stripe-webhooks-vs-database-sync" rel="noopener noreferrer"&gt;Stripe Webhooks vs Database Sync: Which is Better?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/why-stripe-postgresql-sync-keeps-breaking" rel="noopener noreferrer"&gt;Why Your Stripe to PostgreSQL Sync Keeps Breaking&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/supabase-vs-neon-vs-railway-postgresql-for-saas" rel="noopener noreferrer"&gt;Supabase vs Neon vs Railway: Which PostgreSQL for SaaS Data?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/docs/core-concepts/schedules" rel="noopener noreferrer"&gt;Schedules documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>api</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Fivetran Pricing Explained: Why MAR Billing Is Overkill for One Billing Sync</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Mon, 17 Aug 2026 08:14:09 +0000</pubDate>
      <link>https://dev.to/ilshadyx/fivetran-pricing-explained-why-mar-billing-is-overkill-for-one-billing-sync-3g43</link>
      <guid>https://dev.to/ilshadyx/fivetran-pricing-explained-why-mar-billing-is-overkill-for-one-billing-sync-3g43</guid>
      <description>&lt;p&gt;&lt;em&gt;Fivetran prices on monthly active rows. Here is how MAR actually works, what changed in January 2026, and why the bill is hard to forecast for one sync.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;By Ilshaad Kheerdali · 17 August 2026&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;You have one billing source, Stripe or QuickBooks or Xero or Paddle, and one Postgres database. You want the first to land in the second on a schedule. Somewhere in the research you end up on Fivetran, and the pricing page quotes you in a unit you have never used before: monthly active rows.&lt;/p&gt;

&lt;p&gt;MAR is not a bad idea. It is a genuinely fairer meter than per-connector seat pricing for a company moving hundreds of tables. The problem for a reader in your position is narrower and worth naming precisely: MAR is priced on how much your data changes, and you cannot know that number before you start.&lt;/p&gt;

&lt;p&gt;This post explains how MAR works, what changed on 1 January 2026, and where the model stops making sense for a single billing source.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Monthly Active Row Actually Is
&lt;/h2&gt;

&lt;p&gt;A monthly active row is a distinct row that was inserted, updated or deleted in a calendar month, identified by its primary key.&lt;/p&gt;

&lt;p&gt;The part people get wrong is the counting. From &lt;a href="https://fivetran.com/docs/core-concepts/usage-based-pricing" rel="noopener noreferrer"&gt;Fivetran's own documentation&lt;/a&gt;: "We only count a row once per month, even if it syncs multiple times." A subscription record that changes thirty times in March is one MAR, not thirty. That is the model working in your favour, and it is why running syncs more often does not directly cost more.&lt;/p&gt;

&lt;p&gt;Two details matter more than the headline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;MAR is measured per connection, not per account.&lt;/strong&gt; Since March 2025, Fivetran calculates usage separately for each account, destination, connection and table. Syncing the same Stripe data to two destinations counts twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rows that did not change are free.&lt;/strong&gt; Only churn is billable, which is why a table with a million static rows can cost almost nothing while a small, busy table costs more.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What Changed on 1 January 2026
&lt;/h2&gt;

&lt;p&gt;Three things moved, and older comparison posts have not caught up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deletes now count toward paid MAR.&lt;/strong&gt; Fivetran's &lt;a href="https://fivetran.com/docs/core-concepts/usage-based-pricing/pricing-updates/2026-pricing-updates" rel="noopener noreferrer"&gt;2026 pricing update&lt;/a&gt; states it plainly: "As of January 1, 2026, inserts, updates, and deletes will count toward paid MAR. Previously, deletes did not contribute to paid MAR." The reasoning is defensible, since a deletion is a real change with analytical value, but it is a straightforward increase for any source that removes records.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is now a minimum charge per connection.&lt;/strong&gt; A $5 base charge applies to every standard connection doing between 1 and 1 million MAR per month. If you have five small connections that barely move, you now have a $25 floor before any usage is counted. The Free plan is exempt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Starter and Private Deployment plans are gone.&lt;/strong&gt; The current lineup is Free, Standard, Enterprise and Business Critical, the last of which adds customer-managed encryption keys, PCI DSS Level 1 and private networking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Yes, the Free Plan Is Real
&lt;/h2&gt;

&lt;p&gt;Most posts arguing against Fivetran skip this, and skipping it makes the argument worse rather than better.&lt;/p&gt;

&lt;p&gt;Fivetran's &lt;a href="https://www.fivetran.com/pricing" rel="noopener noreferrer"&gt;Free plan&lt;/a&gt; includes &lt;strong&gt;500,000 MAR per month&lt;/strong&gt;, along with 3,500 activation MAR and 5,000 transformation model runs. It is a plan, not a trial. There is also a 14-day account trial, and separately each new connection gets 14 days of free use starting when incremental syncs are first detected.&lt;/p&gt;

&lt;p&gt;For a single billing source at small scale, 500,000 changed rows in a month is a lot of headroom. A SaaS business with a few thousand customers and a few thousand invoices a month will not come close. If that is you, Fivetran may well cost you nothing, and any post telling you it is too expensive is selling you something.&lt;/p&gt;

&lt;p&gt;So the honest question is not "is Fivetran expensive". It is "what happens after that, and can you see it coming".&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Bill Is Hard to Forecast
&lt;/h2&gt;

&lt;p&gt;Here is the part that should decide it for a one-source setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no published per-MAR rate.&lt;/strong&gt; Fivetran's pricing page describes a consumption curve where the per-row cost falls as volume rises, and points you to a Service Consumption Table and a pricing estimator. Neither renders a plain rate card you can read, quote or put in a spreadsheet. I went looking specifically to include real numbers here and could not verify a single per-MAR figure from Fivetran directly.&lt;/p&gt;

&lt;p&gt;Third-party sites fill the gap badly. In the course of writing this I found published claims of $500 per million MAR and $2.50 per million MAR for the same Standard plan. Those differ by a factor of two hundred. At least one is wrong, possibly both, and there is no authoritative document to settle it. If you see a confident per-row number in a comparison post, treat it as a guess.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The meter tracks your customers' behaviour, not your requirements.&lt;/strong&gt; This is the structural issue. Your infrastructure needs are flat: one Stripe account, a handful of tables, a nightly refresh. Your MAR is driven by how many subscriptions changed state, how many invoices moved from open to paid, how many records were removed. A billing month with a promotion, a price migration, a dunning wave or a bulk cleanup produces a bigger invoice than a quiet month, for the same pipeline doing the same job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost arrives as a step, not a slope.&lt;/strong&gt; Below 500,000 MAR you pay nothing. Above it you are on a paid plan with a $5 per-connection floor and a rate you had to run an estimator to discover. Nothing warns you as you approach the line, and the crossing is caused by your customers, not by a decision you made.&lt;/p&gt;

&lt;p&gt;For a data team running fifteen sources into Snowflake, forecasting that is a normal part of the job and the volume discounts are worth having. For one billing sync into Postgres, you have taken on a variable cost and a modelling exercise to solve a fixed problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Drives MAR on a Billing Sync
&lt;/h2&gt;

&lt;p&gt;It helps to see where the churn comes from, because it is rarely the table you expect.&lt;/p&gt;

&lt;p&gt;Customers are the calmest table. A customer record changes when someone updates a card, an address or an email, so a few percent of the table per month is typical.&lt;/p&gt;

&lt;p&gt;Subscriptions are busier than they look. Every status transition is an update: trialing to active, active to past_due, past_due to canceled, plus renewals, plan changes and quantity changes. A subscription can produce several distinct active months per year even when the customer does nothing unusual.&lt;/p&gt;

&lt;p&gt;Invoices and charges are the volume driver, and they are lifecycle-heavy. A single invoice moves through draft, open, paid, and possibly through a failed payment and a retry. Because MAR counts a row once per month regardless of how many of those transitions happen, this is much better than it sounds, but the row count still scales directly with your transaction volume.&lt;/p&gt;

&lt;p&gt;The rough shape: your monthly MAR is closer to "customers who did something" plus "invoices raised this month" than to the total size of your Stripe account. That is the number to estimate before signing anything, and it is also the number that moves without warning.&lt;/p&gt;

&lt;p&gt;One thing that does not cost you: &lt;strong&gt;historical syncs are free.&lt;/strong&gt; Fivetran's documentation confirms that initial syncs do not incur a charge for the historical data they load, and that tracked incremental updates are what may count outside a connection's free trial period. Backfilling five years of Stripe history is not what puts you over the line. Ordinary month-to-month operation is.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Fivetran Is the Right Call
&lt;/h2&gt;

&lt;p&gt;It would be dishonest to write this without the other side, so here it is.&lt;/p&gt;

&lt;p&gt;Fivetran is a serious product and MAR is a serious pricing model. Choose it when you have many sources rather than one, when the destination is a warehouse like Snowflake, BigQuery, Redshift or Databricks, when you need 700+ connectors covering systems nobody else supports, or when you need the compliance posture in the Business Critical tier. At real volume the consumption curve works for you, since the per-row cost falls as usage rises, and an annual commitment takes up to 22% off.&lt;/p&gt;

&lt;p&gt;If you are building a central analytics platform, the flexibility is worth the forecasting work.&lt;/p&gt;

&lt;h2&gt;
  
  
  When It Is Overkill
&lt;/h2&gt;

&lt;p&gt;One billing provider. One Postgres database. A handful of tables. A once- or twice-daily refresh.&lt;/p&gt;

&lt;p&gt;That workload has no need for 700 connectors, no warehouse, no transformation layer, and no reason to be metered on row churn. You are buying a platform to solve a problem that is closer to a scheduled copy, and paying for it in a unit that varies with your customers' behaviour.&lt;/p&gt;

&lt;p&gt;What you want instead is a flat number you can put in a budget and forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Flat-Fee Version
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; does the narrow version of this job: Stripe, QuickBooks, Xero and Paddle into PostgreSQL, including Supabase, Neon, AWS RDS, Railway and DigitalOcean. You connect the provider, pick a data type, and the destination table is created for you.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://codelesssync.com/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; is a short list of fixed numbers you can read before you sign up. Free is $0 for 2 manual syncs a day, 2 configurations and 1 database project. Paid tiers start at $19 a month for Starter, $29 for Pro and $99 for Business, and each adds scheduled syncs, more configurations and a larger monthly row allowance.&lt;/p&gt;

&lt;p&gt;Every tier does include a monthly row allowance, so volume is not irrelevant. The difference is what happens at the boundary: an allowance is a ceiling you can read on the pricing page before you commit, not a meter that turns a busy billing month into a larger invoice. You can outgrow a plan. You cannot be surprised by one.&lt;/p&gt;

&lt;p&gt;Scheduled syncs are the paid feature, and when you create a schedule you choose the sync mode. A full sync is the default and re-reads everything, or you can pick a fixed window that fetches records changed in the last day, 7 days or 30 days. Keep the window wider than the gap between runs, so a daily schedule pairs best with the last-7-days window and a failed run heals itself on the next one. &lt;a href="https://codelesssync.com/blog/cron-expressions-for-data-syncs" rel="noopener noreferrer"&gt;Cron expressions for scheduled data syncs&lt;/a&gt; covers the syntax if you want a specific cadence.&lt;/p&gt;

&lt;p&gt;Side by side, on the axes that actually differ:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Fivetran Standard&lt;/th&gt;
&lt;th&gt;Codeless Sync&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pricing unit&lt;/td&gt;
&lt;td&gt;Monthly active rows, per connection&lt;/td&gt;
&lt;td&gt;Flat monthly fee&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Published rate you can quote&lt;/td&gt;
&lt;td&gt;No, estimator only&lt;/td&gt;
&lt;td&gt;Yes, $19 to $99 a month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same bill every month&lt;/td&gt;
&lt;td&gt;No, tracks row churn&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sources&lt;/td&gt;
&lt;td&gt;700+&lt;/td&gt;
&lt;td&gt;Stripe, QuickBooks, Xero, Paddle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Destinations&lt;/td&gt;
&lt;td&gt;Warehouses, databases, 200+ targets&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free tier&lt;/td&gt;
&lt;td&gt;500,000 MAR per month&lt;/td&gt;
&lt;td&gt;2 syncs per day, no card&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Many sources into a warehouse&lt;/td&gt;
&lt;td&gt;One or two billing sources into Postgres&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fivetran wins the top half of that table and it is not close. The question is whether you need any of it.&lt;/p&gt;

&lt;p&gt;If you want the wider field rather than a straight comparison, &lt;a href="https://codelesssync.com/blog/best-tools-to-sync-stripe-data-to-a-database" rel="noopener noreferrer"&gt;the best tools to sync Stripe data to a database&lt;/a&gt; covers seven options with honest trade-offs, and &lt;a href="https://codelesssync.com/blog/best-stripe-sigma-alternative-for-postgresql" rel="noopener noreferrer"&gt;the best Stripe Sigma alternative for Postgres users&lt;/a&gt; covers the reporting angle specifically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is a monthly active row in Fivetran?
&lt;/h3&gt;

&lt;p&gt;A distinct row, identified by primary key, that was inserted, updated or deleted during a calendar month. It is counted once per month no matter how many times it syncs, and it is measured separately for each connection, destination and table.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Fivetran free for a small Stripe sync?
&lt;/h3&gt;

&lt;p&gt;It can be. The Free plan includes 500,000 MAR per month, which is more changed rows than most small SaaS businesses generate. The catch is that nothing tells you how close you are getting, and crossing the line puts you on a paid plan with a rate you have to run an estimator to find.&lt;/p&gt;

&lt;h3&gt;
  
  
  Did Fivetran's pricing change in 2026?
&lt;/h3&gt;

&lt;p&gt;Yes, on 1 January 2026. Deletes now count toward paid MAR where previously they did not, and a $5 minimum charge now applies to standard connections doing between 1 and 1 million MAR per month. The Starter and Private Deployment plans were also discontinued.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why can't I find Fivetran's price per row?
&lt;/h3&gt;

&lt;p&gt;Because it is not published as a flat rate. The per-MAR cost follows a consumption curve that falls as volume rises, and Fivetran directs you to a pricing estimator rather than a rate card. Per-row figures quoted on comparison sites are unreliable, and I found published claims differing by a factor of two hundred for the same plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a historical backfill count toward my MAR?
&lt;/h3&gt;

&lt;p&gt;No. Fivetran's documentation states that initial syncs do not incur a cost for the historical data they load. It is the ongoing incremental updates that count, so loading years of Stripe history is not what pushes you over a threshold.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a cheaper alternative for one billing source?
&lt;/h3&gt;

&lt;p&gt;If your requirement is one or two billing providers into PostgreSQL, a flat-fee tool is both cheaper and easier to budget. Codeless Sync is $0 for manual syncs, and scheduled syncs start at $19 a month, with the same bill regardless of how many rows changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;MAR is a reasonable answer to a question you probably are not asking. It exists so that a company moving thousands of tables pays in proportion to the work done, and it does that job well.&lt;/p&gt;

&lt;p&gt;For one billing source going into one Postgres database, it converts a fixed requirement into a variable cost, prices it in a unit you cannot look up, and hands you a threshold your customers control. Fivetran's free tier may cover you today. The thing worth weighing is what happens the month it does not, and whether you would rather just know the number.&lt;/p&gt;

&lt;p&gt;Try the flat-fee version at &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;codelesssync.com&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/best-tools-to-sync-stripe-data-to-a-database" rel="noopener noreferrer"&gt;Best Tools to Sync Stripe Data to a Database (2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/best-stripe-sigma-alternative-for-postgresql" rel="noopener noreferrer"&gt;Best Stripe Sigma Alternative for PostgreSQL Users&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/supabase-vs-neon-vs-railway-postgresql-for-saas" rel="noopener noreferrer"&gt;Supabase vs Neon vs Railway: Which PostgreSQL for SaaS Data?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/cron-expressions-for-data-syncs" rel="noopener noreferrer"&gt;How to Use Cron Expressions for Scheduled Data Syncs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/docs/getting-started/quick-start" rel="noopener noreferrer"&gt;Connect Your Database to Codeless Sync (Quick Start)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>api</category>
      <category>fivetran</category>
    </item>
    <item>
      <title>How to Sync Billing Data to DigitalOcean Managed PostgreSQL</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Mon, 10 Aug 2026 13:31:08 +0000</pubDate>
      <link>https://dev.to/ilshadyx/how-to-sync-billing-data-to-digitalocean-managed-postgresql-220m</link>
      <guid>https://dev.to/ilshadyx/how-to-sync-billing-data-to-digitalocean-managed-postgresql-220m</guid>
      <description>&lt;p&gt;&lt;em&gt;Sync Stripe, QuickBooks, Xero or Paddle into DigitalOcean Managed PostgreSQL. Flat monthly database pricing, no ETL bill that grows with your row count.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;By Ilshaad Kheerdali · 10 August 2026&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Most guides about getting billing data into Postgres skip the part people actually care about, which is what the bill looks like at the end of the month. You can build a perfectly good pipeline and still be unpleasantly surprised, because the database is priced per hour, the pipeline is priced per row, and neither number is knowable in advance.&lt;/p&gt;

&lt;p&gt;DigitalOcean Managed PostgreSQL is a good answer to half of that problem. It has a flat monthly price with a small, published list of tiers, and you know what you owe before you start. This guide covers the other half: getting Stripe, QuickBooks, Xero or Paddle data into it without adding a variable-cost pipeline on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why DigitalOcean for Billing Data
&lt;/h2&gt;

&lt;p&gt;Billing data has an unusual shape. It is small (most SaaS companies are talking about thousands of rows, not millions), it changes slowly, and it gets queried constantly because it sits behind every revenue question anyone asks. That profile is a poor fit for infrastructure priced on throughput and a good fit for a fixed monthly box.&lt;/p&gt;

&lt;p&gt;Here is what DigitalOcean &lt;a href="https://www.digitalocean.com/pricing/managed-databases" rel="noopener noreferrer"&gt;actually charges&lt;/a&gt; for Managed PostgreSQL:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;RAM&lt;/th&gt;
&lt;th&gt;vCPUs&lt;/th&gt;
&lt;th&gt;Storage&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 GiB&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;10-30 GiB&lt;/td&gt;
&lt;td&gt;$15.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 GiB&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;30-60 GiB&lt;/td&gt;
&lt;td&gt;$30.45&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 GiB&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;60-120 GiB&lt;/td&gt;
&lt;td&gt;$60.90&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 GiB&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;140-280 GiB&lt;/td&gt;
&lt;td&gt;$122.10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 GiB&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;290-580 GiB&lt;/td&gt;
&lt;td&gt;$244.35&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Extra storage beyond the base allocation is $0.215 per GiB per month.&lt;/p&gt;

&lt;p&gt;The detail worth knowing, and the one most comparison posts miss: &lt;strong&gt;traffic to and from DigitalOcean managed databases does not count against your bandwidth transfer allowance.&lt;/strong&gt; That is &lt;a href="https://docs.digitalocean.com/products/databases/postgresql/details/pricing/" rel="noopener noreferrer"&gt;DigitalOcean's own wording&lt;/a&gt;, not an inference. For a workload that repeatedly pulls data in on a schedule, that removes an entire category of surprise line item. It is one of the few places where a sync workload is genuinely cheaper to run than the marketing implies.&lt;/p&gt;

&lt;p&gt;What you give up compared to AWS RDS is the deep ecosystem: no IAM database authentication, fewer regions, no Aurora-style scaling story. For a billing table that nobody is sharding, none of that matters. If you are already on RDS and want that route instead, we covered it in &lt;a href="https://codelesssync.com/blog/how-to-sync-billing-data-to-aws-rds-postgresql" rel="noopener noreferrer"&gt;syncing billing data to AWS RDS&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DigitalOcean Connection String Gotcha
&lt;/h2&gt;

&lt;p&gt;This is where most first attempts fail, and it has nothing to do with your billing provider.&lt;/p&gt;

&lt;p&gt;DigitalOcean does not use port 5432. It issues exactly two ports, and picking the wrong one produces a confusing failure rather than a clear error:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;25060&lt;/strong&gt; is the direct connection to the database&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;25061&lt;/strong&gt; is the connection pool, if you have created one&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A DigitalOcean connection string looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;postgresql://doadmin:password@db-postgresql-lon1-12345-do-user-123456-0.k.db.ondigitalocean.com:25060/defaultdb?sslmode=require
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to get right:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The port.&lt;/strong&gt; If you created a connection pool and paste the pool's credentials with port 25060, or the direct credentials with 25061, the connection fails in a way that reads like a password problem. Copy the whole string from the DigitalOcean control panel rather than assembling it by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sslmode=require&lt;/code&gt;.&lt;/strong&gt; DigitalOcean enforces SSL. The parameter is already in the string the control panel gives you, so the usual cause of losing it is retyping the string or trimming query parameters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusted sources.&lt;/strong&gt; Under your database cluster's &lt;strong&gt;Network Access&lt;/strong&gt; tab, DigitalOcean lets you &lt;a href="https://docs.digitalocean.com/products/databases/postgresql/how-to/secure/" rel="noopener noreferrer"&gt;restrict inbound connections&lt;/a&gt; to specific Droplets, Kubernetes clusters, App Platform apps, IP addresses or tags. If you have added any trusted sources, an external service cannot reach the database until it is allowed too, so check this tab first when a connection test fails for no obvious reason.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a connection string is not behaving and you would rather not guess, paste it into our free &lt;a href="https://codelesssync.com/tools/postgresql-connection-string-validator" rel="noopener noreferrer"&gt;PostgreSQL connection string validator&lt;/a&gt;. It parses the host, flags whether you are on a direct or pooled port, and tells you what is missing. It handles DigitalOcean's ports specifically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting Your Billing Provider
&lt;/h2&gt;

&lt;p&gt;Once the database is reachable, the rest is short. In &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt;, create a sync configuration, pick &lt;strong&gt;Digital Ocean&lt;/strong&gt; as the database platform, and paste the connection string. It is validated before you continue, so you find out immediately if the port or SSL parameter is wrong.&lt;/p&gt;

&lt;p&gt;Then authorize the provider you want to pull from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe&lt;/strong&gt; and &lt;strong&gt;Paddle&lt;/strong&gt; use an API key. For Stripe, use a restricted key with read-only permissions rather than your secret key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QuickBooks&lt;/strong&gt; and &lt;strong&gt;Xero&lt;/strong&gt; use OAuth, so you approve access on their consent screen and never handle a token yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Choose a data type (Customers is the easiest to eyeball first), let the destination table be created automatically, and run the sync. The full walkthrough with screenshots lives in the &lt;a href="https://codelesssync.com/docs/getting-started/quick-start" rel="noopener noreferrer"&gt;quick start guide&lt;/a&gt; if you want it step by step.&lt;/p&gt;

&lt;p&gt;The table naming is predictable: &lt;code&gt;stripe_customers&lt;/code&gt;, &lt;code&gt;quickbooks_invoices&lt;/code&gt;, &lt;code&gt;xero_contacts&lt;/code&gt;, &lt;code&gt;paddle_subscriptions&lt;/code&gt;, and so on. Each provider gets its own tables, so you can run several providers into one cluster without them colliding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Will It Fit on the $15 Plan?
&lt;/h2&gt;

&lt;p&gt;This is the question the pricing table does not answer, and it is worth two minutes before you commit.&lt;/p&gt;

&lt;p&gt;The entry tier gives you 10 GiB of storage. Billing tables are mostly short text fields, timestamps and numerics, plus a JSONB column holding the original API object. That JSONB column is the one that actually consumes space, because it stores the complete record rather than the handful of columns you query.&lt;/p&gt;

&lt;p&gt;After your first sync, ask Postgres directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;to_char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_live_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'FM999,999,999'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;approx_rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_total_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total_size&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'stripe&lt;/span&gt;&lt;span class="se"&gt;\_&lt;/span&gt;&lt;span class="s1"&gt;%'&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'quickbooks&lt;/span&gt;&lt;span class="se"&gt;\_&lt;/span&gt;&lt;span class="s1"&gt;%'&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'xero&lt;/span&gt;&lt;span class="se"&gt;\_&lt;/span&gt;&lt;span class="s1"&gt;%'&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'paddle&lt;/span&gt;&lt;span class="se"&gt;\_&lt;/span&gt;&lt;span class="s1"&gt;%'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;pg_total_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat: &lt;code&gt;n_live_tup&lt;/code&gt; is an estimate maintained by autovacuum, so straight after a first sync it can still read zero. Run &lt;code&gt;ANALYZE;&lt;/code&gt; first if the row counts look wrong. The size column is always accurate.&lt;/p&gt;

&lt;p&gt;To turn that into a forecast, work out the space per thousand rows and multiply by where you expect to be in a year:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_total_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relid&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;billing_data_total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_total_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_live_tup&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;per_1k_rows&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'stripe&lt;/span&gt;&lt;span class="se"&gt;\_&lt;/span&gt;&lt;span class="s1"&gt;%'&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'quickbooks&lt;/span&gt;&lt;span class="se"&gt;\_&lt;/span&gt;&lt;span class="s1"&gt;%'&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'xero&lt;/span&gt;&lt;span class="se"&gt;\_&lt;/span&gt;&lt;span class="s1"&gt;%'&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'paddle&lt;/span&gt;&lt;span class="se"&gt;\_&lt;/span&gt;&lt;span class="s1"&gt;%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For most small and mid-sized SaaS businesses the answer comes back in tens or low hundreds of megabytes, which means the 10 GiB entry tier is not the constraint and will not be for years. Where the entry tier does bite is RAM: 1 GiB is fine for a billing table you query a few times a minute, and tight if this cluster is also serving your application. If billing data is sharing a database with production traffic, start at 2 GiB.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Actually Costs End to End
&lt;/h2&gt;

&lt;p&gt;Putting the two halves together, for a single small billing sync:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DigitalOcean Managed PostgreSQL, entry tier&lt;/td&gt;
&lt;td&gt;$15.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codeless Sync Free (manual syncs, 2 per day)&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total, manual&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$15.15&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codeless Sync Pro (scheduled syncs, 40 per day)&lt;/td&gt;
&lt;td&gt;$29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total, scheduled and hands-off&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$44.15&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both numbers are the same every month regardless of how many rows move, which is the whole point. Usage-priced ETL tools bill on rows changed, so your invoice tracks your growth in a way you cannot forecast at the start of the month, and a busy billing month costs more precisely when you are least inclined to audit it.&lt;/p&gt;

&lt;p&gt;To be fair to the alternatives: if you are syncing one small provider and stay inside a generous free tier, a usage-priced tool can be cheaper than $29. The trade is predictability, and it flips as soon as you add a second or third provider, because per-connector pricing means each one is metered separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping It Current
&lt;/h2&gt;

&lt;p&gt;A one-off sync is enough to explore. For anything you actually depend on, put it on a schedule so nobody has to remember it.&lt;/p&gt;

&lt;p&gt;Scheduled syncs are a paid feature. When you create a schedule you also pick the sync mode: a full sync, which is the default and re-reads everything, or an incremental window that fetches only records changed in the last minute, day, 7 days or 30 days. Matching the window to the cadence is what keeps each run short and keeps load on a 1 GiB cluster negligible, so a daily schedule pairs naturally with the last-day window.&lt;/p&gt;

&lt;p&gt;Different providers can run at different cadences. Stripe customers hourly and QuickBooks invoices daily is a reasonable default, since invoices genuinely do not change often enough to justify hourly polling. If you want help choosing, &lt;a href="https://codelesssync.com/blog/cron-expressions-for-data-syncs" rel="noopener noreferrer"&gt;cron expressions for scheduled data syncs&lt;/a&gt; covers the syntax.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does Codeless Sync work with DigitalOcean's connection pool?
&lt;/h3&gt;

&lt;p&gt;Yes. Both the direct port (25060) and the pool port (25061) work. Use whichever the control panel gives you, and make sure the port matches the credentials, since pool and direct connections have separate ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to add Codeless Sync to my trusted sources?
&lt;/h3&gt;

&lt;p&gt;If you have added trusted sources to the cluster, yes. Trusted sources live under the &lt;strong&gt;Network Access&lt;/strong&gt; tab and act as a firewall, so any source not on the list is refused. If you have never touched that tab, there is nothing to add. It is the first place to look when the connection test fails but the connection string is definitely correct.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will syncing count against my DigitalOcean bandwidth?
&lt;/h3&gt;

&lt;p&gt;No. Traffic to and from managed databases does not count against your account's bandwidth transfer allowance, so sync frequency does not create a bandwidth charge.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I sync more than one billing provider into the same cluster?
&lt;/h3&gt;

&lt;p&gt;Yes. Each provider writes to its own prefixed tables (&lt;code&gt;stripe_&lt;/code&gt;, &lt;code&gt;quickbooks_&lt;/code&gt;, &lt;code&gt;xero_&lt;/code&gt;, &lt;code&gt;paddle_&lt;/code&gt;), so they coexist in one database and can be joined together in a single query.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the 1 GiB entry tier enough?
&lt;/h3&gt;

&lt;p&gt;For billing data alone, almost certainly. Storage is rarely the limit because billing tables are small. RAM is the thing to watch, and if the cluster is also serving application traffic, start one tier up at 2 GiB.&lt;/p&gt;

&lt;h3&gt;
  
  
  How is this different from syncing to AWS RDS?
&lt;/h3&gt;

&lt;p&gt;Mainly cost predictability and setup effort. RDS needs VPC and security group configuration before anything external can connect; DigitalOcean needs the right port and an SSL parameter. RDS gives you a deeper ecosystem you probably do not need for a billing table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;DigitalOcean Managed PostgreSQL is a sensible home for billing data specifically because it is boring: a fixed price, a short list of tiers, and no bandwidth meter running in the background. Adding a usage-priced pipeline on top would reintroduce the unpredictability you picked it to avoid.&lt;/p&gt;

&lt;p&gt;Get the connection string right, pick your provider, and the data lands as ordinary tables you can query with the tools you already use.&lt;/p&gt;

&lt;p&gt;Try it at &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;codelesssync.com&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/how-to-sync-billing-data-to-aws-rds-postgresql" rel="noopener noreferrer"&gt;How to Sync Your Billing Data to AWS RDS PostgreSQL&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/supabase-vs-neon-vs-railway-postgresql-for-saas" rel="noopener noreferrer"&gt;Supabase vs Neon vs Railway: Which PostgreSQL for SaaS Data?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/fix-postgresql-connection-string" rel="noopener noreferrer"&gt;How to Fix a PostgreSQL Connection String That Won't Connect&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/tools/postgresql-connection-string-validator" rel="noopener noreferrer"&gt;Free PostgreSQL Connection String Validator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/docs/getting-started/quick-start" rel="noopener noreferrer"&gt;Connect Your Database to Codeless Sync (Quick Start)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>api</category>
      <category>digitalocean</category>
    </item>
    <item>
      <title>Xero API Integration Guide (2026): OAuth, Tenants, and Your First Query</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Tue, 04 Aug 2026 09:41:44 +0000</pubDate>
      <link>https://dev.to/ilshadyx/xero-api-integration-guide-2026-oauth-tenants-and-your-first-query-ked</link>
      <guid>https://dev.to/ilshadyx/xero-api-integration-guide-2026-oauth-tenants-and-your-first-query-ked</guid>
      <description>&lt;p&gt;&lt;em&gt;Step-by-step Xero API integration: OAuth 2.0, tenant routing, paging, rate limits, the 2026 scope and pricing changes, plus a no-code path to PostgreSQL.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;By Ilshaad Kheerdali · 4 August 2026&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;The Xero API is well documented and pleasant to work with once it clicks, but the first integration always takes longer than people expect. There is an extra discovery step that most accounting APIs don't have, tokens expire faster than you'd guess, and 2026 brought two changes that alter how you scope and budget an integration.&lt;/p&gt;

&lt;p&gt;This guide walks the whole flow: creating an app, running OAuth 2.0, resolving which organisation you're actually talking to, making your first call, paging through results, staying inside the rate limits, and pulling incremental updates. At the end it covers what changed in 2026 and the shortcut if the plumbing isn't the part you want to own.&lt;/p&gt;

&lt;p&gt;Everything below targets the &lt;strong&gt;Xero Accounting API&lt;/strong&gt; over OAuth 2.0. Xero retired OAuth 1.0a some years ago, so any tutorial you find that mentions consumer keys and signed requests is out of date.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Xero API Is
&lt;/h2&gt;

&lt;p&gt;The Xero Accounting API is a REST API that returns XML by default and JSON if you ask for it. You read and write accounting entities: &lt;code&gt;Invoices&lt;/code&gt;, &lt;code&gt;Contacts&lt;/code&gt;, &lt;code&gt;Payments&lt;/code&gt;, &lt;code&gt;BankTransactions&lt;/code&gt;, &lt;code&gt;Accounts&lt;/code&gt;, &lt;code&gt;CreditNotes&lt;/code&gt;, &lt;code&gt;Items&lt;/code&gt;, &lt;code&gt;PurchaseOrders&lt;/code&gt;, &lt;code&gt;ManualJournals&lt;/code&gt; and a few dozen more, plus a set of report endpoints.&lt;/p&gt;

&lt;p&gt;The thing that surprises most developers coming from Stripe or QuickBooks is the &lt;strong&gt;tenant model&lt;/strong&gt;. A single Xero login can have access to many organisations: an accountant might be connected to two hundred client orgs. So authorisation and targeting are two separate concerns. Your token proves the user said yes, and a separate header tells Xero which organisation the call is for.&lt;/p&gt;

&lt;p&gt;That means every integration has a step that a Stripe integration simply doesn't: after you get a token, you have to ask Xero which tenants that token can reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Create a Xero App
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Sign in at the &lt;a href="https://developer.xero.com/app/manage" rel="noopener noreferrer"&gt;Xero Developer portal&lt;/a&gt; and create an app.&lt;/li&gt;
&lt;li&gt;Pick the grant type. &lt;strong&gt;Auth Code&lt;/strong&gt; is what you want for a web server app that can keep a client secret safe. Native desktop and mobile apps use &lt;strong&gt;PKCE&lt;/strong&gt; instead, and single page apps are not supported at all. &lt;strong&gt;Custom Connections&lt;/strong&gt; are the machine-to-machine option for a single organisation, which suits internal tooling.&lt;/li&gt;
&lt;li&gt;Set your &lt;strong&gt;redirect URI&lt;/strong&gt;. It has to be an absolute HTTPS address and match exactly what you send during OAuth. &lt;code&gt;http://localhost/&lt;/code&gt; is allowed for local testing, but &lt;code&gt;http://127.0.0.1&lt;/code&gt; is not. You can register up to 50 per app.&lt;/li&gt;
&lt;li&gt;Copy the &lt;strong&gt;Client ID&lt;/strong&gt; and generate a &lt;strong&gt;Client Secret&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Xero gives you a &lt;strong&gt;Demo Company&lt;/strong&gt; with realistic data, which is the sane place to develop. There is no separate sandbox host: you point at the same production API and authorise against the demo org.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Authenticate with OAuth 2.0
&lt;/h2&gt;

&lt;p&gt;Xero uses the standard authorization code flow. Send the user to Xero, they pick which organisation to connect, and you exchange the returned code for tokens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. Build the consent URL&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://login.xero.com/identity/connect/authorize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;authUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;response_type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;authUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;client_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;XERO_CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;authUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;redirect_uri&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;XERO_REDIRECT_URI&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;authUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scope&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openid profile email accounting.contacts.read accounting.invoices.read offline_access&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// CSRF guard. State only protects you if you store it and compare it on return.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveOAuthState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;authUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;state&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 2. Exchange the code in your redirect handler&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/callback&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;takeOAuthState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// read it once, then delete it&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid state&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;basic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;XERO_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;XERO_CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokenRes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://identity.xero.com/connect/token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Basic &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;basic&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;grant_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authorization_code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;redirect_uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;XERO_REDIRECT_URI&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tokenRes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// token.access_token  -&amp;gt; 30 minutes&lt;/span&gt;
  &lt;span class="c1"&gt;// token.refresh_token -&amp;gt; rotates on every use&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four details worth internalising before you build anything on top of this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;offline_access&lt;/code&gt; is not optional.&lt;/strong&gt; Leave it out of your scope string and Xero won't issue a refresh token at all. You'll get thirty minutes of access and then a dead integration, which is a confusing thing to debug at 5pm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The authorization code is single use and expires 5 minutes after issuance.&lt;/strong&gt; If your callback handler is slow or you retry the exchange, you'll be looking at an invalid_grant error rather than an obvious timeout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access tokens last 30 minutes.&lt;/strong&gt; Short, so refresh logic isn't something you can defer to "later". The &lt;code&gt;id_token&lt;/code&gt; expires even faster, at 5 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh tokens rotate.&lt;/strong&gt; Every refresh returns a new refresh token and the old one stops working. Persist the new value in the same transaction you use it, or a crash mid-refresh will lock that connection out. Unused refresh tokens expire after 60 days. If a refresh call fails without a response, Xero lets you retry with the existing refresh token for a grace period of 30 minutes before you have to send the user back through consent.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;storedRefreshToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;basic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;XERO_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;XERO_CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://identity.xero.com/connect/token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Basic &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;basic&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;grant_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refresh_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;refresh_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;storedRefreshToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// token.refresh_token has changed. Save it.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Resolve the Tenant
&lt;/h2&gt;

&lt;p&gt;This is the step people miss. Your access token does not identify an organisation, so before you can read anything you ask Xero what the token can reach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.xero.com/connections&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;connections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// [{&lt;/span&gt;
&lt;span class="c1"&gt;//   id: 'e1eede29-...',        // connection id, used to DELETE the connection&lt;/span&gt;
&lt;span class="c1"&gt;//   authEventId: 'd99ecdfe-...',&lt;/span&gt;
&lt;span class="c1"&gt;//   tenantId: '70784a63-...',  // this is what goes in the header&lt;/span&gt;
&lt;span class="c1"&gt;//   tenantType: 'ORGANISATION',&lt;/span&gt;
&lt;span class="c1"&gt;//   tenantName: 'Maple Florist',&lt;/span&gt;
&lt;span class="c1"&gt;//   createdDateUtc: '2019-07-09T23:40:30.1833130',&lt;/span&gt;
&lt;span class="c1"&gt;//   updatedDateUtc: '2020-05-15T01:35:13.8491980',&lt;/span&gt;
&lt;span class="c1"&gt;// }]&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;connections&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store &lt;code&gt;tenantId&lt;/code&gt; next to the tokens. Every subsequent API call carries it in the &lt;code&gt;Xero-tenant-id&lt;/code&gt; header. If it is missing, malformed, or not authorised for that token, Xero returns &lt;code&gt;403 Forbidden&lt;/code&gt; with &lt;code&gt;"detail": "AuthenticationUnsuccessful"&lt;/code&gt;, which is easy to misread as an expired token when the token is fine. The genuinely silent failure is the other one: a tenant the token &lt;em&gt;is&lt;/em&gt; authorised for but that belongs to a different organisation returns &lt;code&gt;200&lt;/code&gt; with that org's data, so a mixed-up tenant looks like missing records rather than an error.&lt;/p&gt;

&lt;p&gt;Two things make this step subtler than it looks. First, the endpoint returns &lt;strong&gt;every&lt;/strong&gt; tenant the user has ever connected, not just the ones authorised in this flow. If you want only the new ones, decode the &lt;code&gt;authentication_event_id&lt;/code&gt; claim from the access token JWT and filter with &lt;code&gt;?authEventId=...&lt;/code&gt;. Second, &lt;code&gt;tenantType&lt;/code&gt; is not always &lt;code&gt;ORGANISATION&lt;/code&gt;: a user can connect a Practice Manager account too, and those entries come back with a null &lt;code&gt;tenantName&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If your user connects several organisations, you'll need a way for them to pick. That is a genuine product decision, not just plumbing: agencies and bookkeepers routinely connect dozens of orgs, and as Step 6 shows, each one carries its own separate rate-limit budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Make Your First API Call
&lt;/h2&gt;

&lt;p&gt;The Accounting API lives under &lt;code&gt;https://api.xero.com/api.xro/2.0/&lt;/code&gt;. Three headers on every request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.xero.com/api.xro/2.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/Invoices`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Xero-tenant-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// omit this and you get XML&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Invoices&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Invoices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Invoices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;InvoiceNumber&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two quirks to expect. Endpoints and response fields are &lt;strong&gt;PascalCase&lt;/strong&gt; (&lt;code&gt;Invoices&lt;/code&gt;, &lt;code&gt;InvoiceNumber&lt;/code&gt;, &lt;code&gt;AmountDue&lt;/code&gt;), which trips up anyone auto-mapping into snake_case Postgres columns. And dates come back in Microsoft's legacy JSON format, &lt;code&gt;/Date(1712345678000+0000)/&lt;/code&gt;, so plan on parsing them rather than passing them straight into a timestamp column.&lt;/p&gt;

&lt;p&gt;For filtering, Xero uses a &lt;code&gt;where&lt;/code&gt; query parameter with its own expression syntax. It has to be percent encoded before you append it, and so does the space in an &lt;code&gt;order&lt;/code&gt; clause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;where&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Status=="AUTHORISED"&amp;amp;&amp;amp;Type=="ACCREC"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Date DESC&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/Invoices?where=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;order=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;where&lt;/code&gt; clauses simple. Xero's own advice is to stick to &lt;code&gt;==&lt;/code&gt; comparisons where you can, because long or complex expressions time out against larger organisations. The busiest endpoints also have dedicated parameters that are faster than a &lt;code&gt;where&lt;/code&gt;: &lt;code&gt;Invoices?Statuses=AUTHORISED&lt;/code&gt;, &lt;code&gt;Invoices?ContactIDs=...&lt;/code&gt;, and &lt;code&gt;IDs=...&lt;/code&gt; all take comma-separated lists. There's also &lt;code&gt;summaryOnly=true&lt;/code&gt; on Invoices and Contacts, which strips the computation-heavy fields and is worth using when you're just building an index.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Page Through Results
&lt;/h2&gt;

&lt;p&gt;Xero pages with a simple &lt;code&gt;page&lt;/code&gt; parameter, and &lt;code&gt;pageSize&lt;/code&gt; controls how many records come back. The default is &lt;strong&gt;100&lt;/strong&gt; and the maximum is &lt;strong&gt;1,000&lt;/strong&gt;, so always set it explicitly or you'll make ten times the calls you need to. Values outside the range are clamped rather than rejected, so &lt;code&gt;pageSize=5000&lt;/code&gt; quietly gives you 1,000.&lt;/p&gt;

&lt;p&gt;Paging is available on Invoices, Contacts, CreditNotes, BankTransactions, ManualJournals, Payments, PurchaseOrders, Prepayments and Overpayments. It's worth using even when you don't need the volume: on several endpoints the unpaged response is a summarised version, so paged results come back with extra detail such as line items, and you avoid a follow-up request per record.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchAllInvoices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/Invoices?page=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;pageSize=1000`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Xero-tenant-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Invoices&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Paged responses carry a pagination object: page, pageSize, pageCount, itemCount&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pagination&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pageCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;pagination&lt;/code&gt; object supersedes the older approach of fetching pages until one comes back short. If you're maintaining code that still does that, it works, but the object tells you &lt;code&gt;itemCount&lt;/code&gt; and &lt;code&gt;pageCount&lt;/code&gt; up front, which is what you want for progress reporting and for deciding whether a backfill will fit inside the daily call budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Handle Rate Limits and Errors
&lt;/h2&gt;

&lt;p&gt;Xero's limits are considerably tighter than most billing APIs, and they stack. As of 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Concurrent&lt;/td&gt;
&lt;td&gt;5 calls in progress&lt;/td&gt;
&lt;td&gt;Per organisation, per app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per minute&lt;/td&gt;
&lt;td&gt;60 calls&lt;/td&gt;
&lt;td&gt;Per organisation, per app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per day&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,000 calls on the Starter tier, 5,000 on Core and above&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Per organisation, per app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App-wide&lt;/td&gt;
&lt;td&gt;10,000 calls per minute&lt;/td&gt;
&lt;td&gt;Across all your connections&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The daily cap is the one that bites, and note that it is &lt;strong&gt;tiered&lt;/strong&gt;, which is easy to miss because most write-ups quote a flat 5,000. On the free Starter tier you get 1,000 calls per day per organisation. A full backfill of a busy org, paged at 100 records because you forgot &lt;code&gt;pageSize&lt;/code&gt;, can exhaust that before it finishes, and you are then locked out of that tenant until the window resets. The limits are per tenant rather than per app, so ten connected organisations means ten separate daily budgets.&lt;/p&gt;

&lt;p&gt;You don't have to guess where you stand. Every response carries &lt;code&gt;X-DayLimit-Remaining&lt;/code&gt;, &lt;code&gt;X-MinLimit-Remaining&lt;/code&gt; and &lt;code&gt;X-AppMinLimit-Remaining&lt;/code&gt;, so a scheduler can back off before it trips anything.&lt;/p&gt;

&lt;p&gt;Exceeding a limit returns HTTP &lt;code&gt;429&lt;/code&gt; with an &lt;code&gt;X-Rate-Limit-Problem&lt;/code&gt; header naming which of the four you hit. For the minute and daily limits you also get a &lt;code&gt;Retry-After&lt;/code&gt; header with the seconds to wait. The windows are fixed and reset at different times per tenant, so &lt;code&gt;Retry-After&lt;/code&gt; is the only reliable signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;problem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Rate-Limit-Problem&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// which limit tripped&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;retryAfter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Retry-After&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Concurrent-limit 429s have no Retry-After. Back off briefly and retry.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;waitSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;retryAfter&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;retryAfter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Hit &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;problem&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; limit, waiting &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;waitSeconds&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;waitSeconds&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check &lt;a href="https://developer.xero.com/documentation/guides/oauth2/limits/" rel="noopener noreferrer"&gt;Xero's current limits documentation&lt;/a&gt; before you size a backfill, since these numbers do get revised.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Sync Incrementally with If-Modified-Since
&lt;/h2&gt;

&lt;p&gt;Re-pulling everything on a schedule is exactly how you exhaust the daily cap. For updates, send the &lt;code&gt;If-Modified-Since&lt;/code&gt; header with a UTC timestamp and Xero returns only records modified after it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/Invoices?pageSize=1000`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Xero-tenant-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;If-Modified-Since&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-07-01T00:00:00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the format: UTC, accurate to the second. The pattern is a full backfill once, then &lt;code&gt;If-Modified-Since&lt;/code&gt; on a schedule with the watermark stored per tenant per endpoint. Xero explicitly recommends it for any endpoint with a large result set.&lt;/p&gt;

&lt;p&gt;One caveat matters a lot if you're building a mirror of the data. The filter keys off &lt;code&gt;UpdatedDateUTC&lt;/code&gt;, and Xero documents specific changes that &lt;strong&gt;don't&lt;/strong&gt; bump that field: edits to partially paid transactions that don't generate a journal, such as &lt;code&gt;DueDate&lt;/code&gt; or &lt;code&gt;SentToContact&lt;/code&gt;, and Contact fields derived from elsewhere such as &lt;code&gt;Balances&lt;/code&gt;, &lt;code&gt;IsSupplier&lt;/code&gt; and &lt;code&gt;IsCustomer&lt;/code&gt;. Records changed only in those ways will never appear in an incremental pull. If completeness matters, schedule a periodic full reconciliation pass rather than trusting the incremental stream indefinitely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed in 2026: Scopes and Pricing
&lt;/h2&gt;

&lt;p&gt;Two changes landed in March 2026 that affect how you build and what it costs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Granular scopes.&lt;/strong&gt; Xero replaced three broad scopes with sixteen narrower ones. &lt;code&gt;accounting.transactions&lt;/code&gt; splits into &lt;code&gt;accounting.invoices&lt;/code&gt;, &lt;code&gt;accounting.payments&lt;/code&gt;, &lt;code&gt;accounting.banktransactions&lt;/code&gt; and &lt;code&gt;accounting.manualjournals&lt;/code&gt; (plus the matching &lt;code&gt;.read&lt;/code&gt; variants), and &lt;code&gt;accounting.reports.read&lt;/code&gt; splits into eight report-specific scopes covering aged reports, balance sheet, bank summary, budget summary, executive summary, profit and loss, trial balance and tax reports. Scopes for settings, contacts, attachments and budgets were not affected.&lt;/p&gt;

&lt;p&gt;Web and PKCE apps created from 2 March 2026 use granular scopes from the start, apps created before that date were assigned them by the end of April 2026, and custom connections followed on 29 April 2026. Broad scopes keep working until &lt;strong&gt;September 2027&lt;/strong&gt;. The catch is that migration isn't a config change: because you're requesting a different permission set, every connected user has to consent again. Scopes are additive, so the usual approach is to update your authorisation link now, let re-authorisations and new organisations migrate people organically, then chase the remainder before the deadline.&lt;/p&gt;

&lt;p&gt;Worth wiring up defensively: calling an endpoint whose granular scope you haven't requested returns a &lt;code&gt;401&lt;/code&gt; with a &lt;code&gt;WWW-Authenticate: insufficient_scope&lt;/code&gt; header, so catch that case specifically and prompt the user to update permissions rather than showing a generic auth error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The API is no longer free.&lt;/strong&gt; This is the bigger one, and it's easy to miss because most Xero tutorials predate it. Xero retired its revenue-share model on &lt;strong&gt;2 March 2026&lt;/strong&gt; and replaced it with five tiers priced on two axes: how many organisations you're connected to, and how much data you pull.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Max connections&lt;/th&gt;
&lt;th&gt;Monthly fee (AUD)&lt;/th&gt;
&lt;th&gt;Included egress&lt;/th&gt;
&lt;th&gt;Overage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Starter&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Core&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;$35&lt;/td&gt;
&lt;td&gt;10 GB&lt;/td&gt;
&lt;td&gt;$2.40/GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plus&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;$245&lt;/td&gt;
&lt;td&gt;50 GB&lt;/td&gt;
&lt;td&gt;$2.40/GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Advanced&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;$1,445&lt;/td&gt;
&lt;td&gt;250 GB&lt;/td&gt;
&lt;td&gt;$2.40/GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;No limit&lt;/td&gt;
&lt;td&gt;On application&lt;/td&gt;
&lt;td&gt;On application&lt;/td&gt;
&lt;td&gt;On application&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fees are tax exclusive, and allowances reset on the first of the calendar month (UTC). Billing is on &lt;strong&gt;data egress&lt;/strong&gt;, so only data you download counts. Uploads into Xero are unlimited at every tier, and the Organisation endpoint is excluded from the calculation. Plus and above require app certification, and Advanced adds an annual security assessment, which also gates the premium endpoints: Journals, the Practice Manager API and bulk connections are Advanced-tier features.&lt;/p&gt;

&lt;p&gt;Two things follow from this. First, an inefficient integration now has a line item. Paging at 100 instead of 1,000, re-pulling full history nightly, or syncing entities nobody queries used to be merely untidy, and now they show up on an invoice. Second, the free tier's 5-connection cap and 1,000 calls per day make it a testing tier rather than a production one, so budget for at least Core if you're shipping.&lt;/p&gt;

&lt;p&gt;One more change landed alongside the pricing: the updated developer terms prohibit using data obtained from Xero's APIs to train, fine tune, adapt or enhance AI or ML models. If that was on your roadmap, read clause 7 of the &lt;a href="https://developer.xero.com/xero-developer-platform-terms-conditions" rel="noopener noreferrer"&gt;developer platform terms and conditions&lt;/a&gt; before you build. Current tiers and allowances are on &lt;a href="https://developer.xero.com/pricing" rel="noopener noreferrer"&gt;Xero's developer pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hard Parts
&lt;/h2&gt;

&lt;p&gt;A first integration is very achievable. Keeping it healthy across many organisations is where the effort actually lands:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Token lifecycle at scale.&lt;/strong&gt; Thirty-minute access tokens and rotating refresh tokens, per tenant, with safe persistence and a recovery path when a refresh fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-tenant rate budgets.&lt;/strong&gt; Limits are per organisation, so a hundred connected orgs means a hundred separate budgets to schedule against without tripping the app-wide ceiling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema mapping.&lt;/strong&gt; PascalCase fields, &lt;code&gt;/Date(...)/&lt;/code&gt; timestamps, nested &lt;code&gt;LineItems&lt;/code&gt;, and per-endpoint quirks all need flattening before the data is queryable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope migration.&lt;/strong&gt; If your app predates March 2026, the September 2027 deadline is a re-authorisation campaign across your entire user base.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Egress awareness.&lt;/strong&gt; Sync design is now a cost decision, not just an engineering one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're still weighing whether to build this at all, &lt;a href="https://codelesssync.com/blog/xero-api-vs-database-sync" rel="noopener noreferrer"&gt;Xero API vs Database Sync&lt;/a&gt; works through the build-versus-buy maths in detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Simpler Path: Sync Xero to PostgreSQL with No Code
&lt;/h2&gt;

&lt;p&gt;If the goal is simply having Xero data in your own database to query and report on, none of the above is differentiating work. &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; connects Xero to your PostgreSQL database (Supabase, Neon, Railway, AWS RDS, or any Postgres host) in about five minutes. You authorise Xero once, pick your organisation, and CLS handles token refresh, tenant routing, paging, rate-limit backoff, incremental pulls and table creation. Data lands as clean relational tables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Outstanding receivables by contact, oldest first&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_due&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;outstanding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;due_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;oldest_due&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;xero_invoices&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;xero_contacts&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contact_id&lt;/span&gt;
                    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'AUTHORISED'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACCREC'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_due&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;oldest_due&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CLS also offers incremental pulls (last day, last 7 days, last 30 days) alongside a full sync, so once the backfill is done a scheduled run fetches only what changed rather than spending your daily call budget and egress allowance on history you already have. Pricing is a flat monthly fee rather than usage-metered, so your bill doesn't move when your invoice volume does. There's a free tier and no credit card required.&lt;/p&gt;

&lt;p&gt;For the step-by-step version, see &lt;a href="https://codelesssync.com/blog/how-to-sync-xero-data-to-postgresql" rel="noopener noreferrer"&gt;How to Sync Xero to PostgreSQL Automatically in 5 Minutes&lt;/a&gt;, or the &lt;a href="https://codelesssync.com/xero-to-postgresql" rel="noopener noreferrer"&gt;Xero to PostgreSQL&lt;/a&gt; overview.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is the Xero API free to use?
&lt;/h3&gt;

&lt;p&gt;Not really, since March 2026. Xero replaced its revenue-share model with five tiers priced on connection count and data egress, effective 2 March 2026. There is a free Starter tier, but it caps you at 5 connections and 1,000 API calls per day per organisation, which suits testing rather than production. The next step up is Core at $35 AUD per month for 50 connections and 10 GB of monthly egress. Only data you download counts toward the allowance; writing data into Xero is unlimited at every tier.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long do Xero access tokens last?
&lt;/h3&gt;

&lt;p&gt;Access tokens expire after 30 minutes. Refresh tokens rotate on every use and expire after 60 days if unused, so you must persist the new refresh token each time you refresh or you'll lose the connection.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the Xero-tenant-id header and why do I need it?
&lt;/h3&gt;

&lt;p&gt;A Xero access token can have access to multiple organisations, so the token alone doesn't say which one you mean. You call &lt;code&gt;GET https://api.xero.com/connections&lt;/code&gt; to list the tenants the token can reach, then pass the chosen &lt;code&gt;tenantId&lt;/code&gt; in the &lt;code&gt;Xero-tenant-id&lt;/code&gt; header on every Accounting API request.&lt;/p&gt;

&lt;h3&gt;
  
  
  What are the Xero API rate limits?
&lt;/h3&gt;

&lt;p&gt;As of 2026: 5 concurrent calls, 60 calls per minute, and a daily cap that depends on your tier (1,000 calls per day on Starter, 5,000 on Core and above). Those are measured per organisation per app, so each connected tenant has its own budget. There is also an app-wide ceiling of 10,000 calls per minute across all connections. Exceeding any of them returns HTTP 429 with an &lt;code&gt;X-Rate-Limit-Problem&lt;/code&gt; header naming the limit, plus a &lt;code&gt;Retry-After&lt;/code&gt; header for the minute and daily limits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I sync Xero data to PostgreSQL without writing code?
&lt;/h3&gt;

&lt;p&gt;Yes. &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; handles the OAuth flow, tenant selection, token refresh, paging and schema mapping, writing Xero data straight into PostgreSQL tables. You authorise once and it stays in sync on a schedule.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/how-to-sync-xero-data-to-postgresql" rel="noopener noreferrer"&gt;How to Sync Xero to PostgreSQL Automatically in 5 Minutes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/xero-api-vs-database-sync" rel="noopener noreferrer"&gt;Xero API vs Database Sync: Which is Better?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/how-to-export-xero-data-to-database" rel="noopener noreferrer"&gt;How to Export Xero Data to a Database&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/quickbooks-api-integration-guide" rel="noopener noreferrer"&gt;QuickBooks API Integration Guide for Developers&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>api</category>
      <category>database</category>
      <category>xero</category>
    </item>
    <item>
      <title>How to Export Paddle Data to a Database</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Mon, 27 Jul 2026 14:12:24 +0000</pubDate>
      <link>https://dev.to/ilshadyx/how-to-export-paddle-data-to-a-database-2h0</link>
      <guid>https://dev.to/ilshadyx/how-to-export-paddle-data-to-a-database-2h0</guid>
      <description>&lt;p&gt;&lt;em&gt;How to export Paddle data to a database: dashboard CSV reports, the Reports API, the Paddle API, Zapier, and no-code sync. Pros, cons, and real costs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;By Ilshaad Kheerdali · 27 July 2026&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Paddle reports build asynchronously, carry up to 24 hours of lag, and delete themselves after 14 days. The API is fresher, but it hands you transactions 30 at a time behind a cursor. Neither one is an export, and if you run your billing on Paddle you have probably already hit that wall.&lt;/p&gt;

&lt;p&gt;The awkward part is that "export Paddle data to a database" sounds like it should be a single button. It isn't. Five different methods exist, each solving a different slice of the problem, and most of them either go stale before you download them, expire before you use them, or leave you maintaining a pipeline that quietly breaks at 3am.&lt;/p&gt;

&lt;p&gt;This guide walks through all five practical ways to get Paddle Billing data into a real, queryable database: dashboard CSV reports, the Reports API, the Paddle API directly, Zapier-style automation, and no-code sync. What each one costs, and where each one breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Exporting Paddle Data Is Harder Than It Should Be
&lt;/h2&gt;

&lt;p&gt;As a merchant of record, Paddle is the system of record for your revenue, not just another payment processor. It holds your customers, subscriptions, transactions, and adjustments, and it handles the sales tax filing on top. That makes a queryable local copy more valuable than it would be elsewhere, and it also makes the data harder to get at than most teams expect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Built-in reports are asynchronous snapshots.&lt;/strong&gt; Paddle Billing generates &lt;a href="https://developer.paddle.com/build/reports/" rel="noopener noreferrer"&gt;CSV reports for transactions, transaction line items, adjustments, adjustment line items, products and prices, discounts, checkouts, and payout reconciliation&lt;/a&gt;, but each report has to be built, then downloaded before it expires (files are kept for 14 days). And the data inside a report can be delayed by up to 24 hours, so even a fresh export isn't fully current.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There's no bulk "export everything" endpoint.&lt;/strong&gt; Paddle's API is designed to run a billing system, so every entity (customers, subscriptions, transactions, products, prices) is its own set of &lt;a href="https://developer.paddle.com/api-reference/about/pagination/" rel="noopener noreferrer"&gt;cursor-paginated list calls&lt;/a&gt;. Page sizes vary by endpoint, and transactions cap at 30 records per page. A busy account means hundreds of sequential requests per entity, stitched back together in order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhooks notify about new events only.&lt;/strong&gt; Paddle notifications are push-based and start capturing from the moment you create the destination. Your historical transactions and existing subscribers never flow through them, so webhooks can't backfill a database. (For the full webhook trade-off, see &lt;a href="https://codelesssync.com/blog/paddle-webhooks-vs-database-sync" rel="noopener noreferrer"&gt;Paddle Webhooks vs Database Sync&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You configure everything twice.&lt;/strong&gt; Sandbox and production are isolated Paddle environments with their own API keys, so a report you build or a pipeline you script against sandbox has to be recreated, and re-verified, against live data before you trust the numbers.&lt;/p&gt;

&lt;p&gt;So the job falls to one of five approaches. Here is how each one holds up.&lt;/p&gt;

&lt;h2&gt;
  
  
  5 Ways to Export Paddle Data to a Database
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Method 1: Manual Paddle CSV Export from the Dashboard
&lt;/h3&gt;

&lt;p&gt;The simplest option. In the Paddle dashboard, go to &lt;strong&gt;Reports&lt;/strong&gt;, find the report type you need under the &lt;strong&gt;Build reports&lt;/strong&gt; tab (transactions, adjustments, transaction line items, products and prices, or discounts), click &lt;strong&gt;Build report&lt;/strong&gt;, filter the date range, then click &lt;strong&gt;Generate report&lt;/strong&gt;. Paddle emails you once the file is ready. The output is a UTF-8, comma-delimited CSV.&lt;/p&gt;

&lt;p&gt;Once you have the file, you import it from your own machine with psql's &lt;code&gt;\copy&lt;/code&gt; command (the server-side &lt;code&gt;COPY FROM&lt;/code&gt; variant needs filesystem access on the database server, which managed hosts like Supabase, Neon, and RDS don't give you):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="k"&gt;copy&lt;/span&gt; &lt;span class="n"&gt;paddle_transactions_export&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transaction_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;billed_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="s1"&gt;'paddle-transactions.csv'&lt;/span&gt; &lt;span class="n"&gt;CSV&lt;/span&gt; &lt;span class="n"&gt;HEADER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the destination table before you run that, and check the column list against the file: &lt;code&gt;CSV HEADER&lt;/code&gt; skips the header row, it does not match columns by name, so the order has to line up. Paddle's transaction report ships a lot more columns than the six above, so trim the file or list every column.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free and built into Paddle&lt;/li&gt;
&lt;li&gt;No code, no API setup, no developer required&lt;/li&gt;
&lt;li&gt;Useful for one-off analysis or a finance handoff&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Report data can lag up to 24 hours, so the export is stale before you even download it&lt;/li&gt;
&lt;li&gt;Asynchronous: you request the report, wait for it to build, then download&lt;/li&gt;
&lt;li&gt;Files expire 14 days after creation; miss the window and you rebuild the report&lt;/li&gt;
&lt;li&gt;Manual every time. If you need fresh data weekly, you're clicking through this every week&lt;/li&gt;
&lt;li&gt;Each report type is separate, so a full dataset means several builds and several imports&lt;/li&gt;
&lt;li&gt;No automation, no incremental updates, no joins with your application data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dashboard reports are fine when someone in finance needs a spreadsheet once a quarter. As a way to keep a database current, they fall over immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 2: Automate the Reports API
&lt;/h3&gt;

&lt;p&gt;Paddle exposes the same reporting engine over the API: &lt;code&gt;POST /reports&lt;/code&gt; creates a report, you poll until its status is &lt;code&gt;ready&lt;/code&gt; (or listen for the &lt;code&gt;report.updated&lt;/code&gt; notification), then call the download-url endpoint to get a link to the CSV. That link expires after 72 hours, and the report itself stays available for 14 days, so you can request a fresh link at any point inside that window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scriptable version of Method 1; can run on a schedule&lt;/li&gt;
&lt;li&gt;Same broad report types as the dashboard&lt;/li&gt;
&lt;li&gt;Good fit if you already have a data pipeline that ingests CSV files&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're building a pipeline anyway: create, poll, download via an expiring link, parse CSV, load, dedupe&lt;/li&gt;
&lt;li&gt;Inherits Method 1's data lag, so your database is always up to a day behind&lt;/li&gt;
&lt;li&gt;CSV column layouts follow the report, so you still design the table schema and upserts yourself&lt;/li&gt;
&lt;li&gt;Sandbox/live duplication, credential storage, and failure alerting are all on you&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a legitimate middle path for data teams, but by the time it runs reliably on a schedule, you've built and now own a small ETL system.&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 3: Direct Paddle API Integration
&lt;/h3&gt;

&lt;p&gt;If you need current data and you're comfortable writing code, you can pull directly from the Paddle API's list endpoints and write the results into PostgreSQL yourself.&lt;/p&gt;

&lt;p&gt;A minimal version looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;exportTransactions&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.paddle.com/transactions?per_page=30&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PADDLE_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;`INSERT INTO paddle_transactions_raw (id, customer_id, status, currency_code, grand_total, billed_at, updated_at)
         VALUES ($1, $2, $3, $4, $5, $6, $7)
         ON CONFLICT (id) DO UPDATE
         SET status = $3, grand_total = $5, updated_at = $7`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;grand_total&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;billed_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;pagination&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;has_more&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pagination&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That hand-rolled schema is not the same as the table Codeless Sync creates, which stores &lt;code&gt;details.totals.total&lt;/code&gt; in a column called &lt;code&gt;total&lt;/code&gt;. Keep the two apart, or the SQL further down this page won't run against your table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Real, current data on demand, no report lag&lt;/li&gt;
&lt;li&gt;Full control over which entities you export and how they map to your schema&lt;/li&gt;
&lt;li&gt;Simple Bearer-key auth for your own account, so there are no OAuth tokens to refresh the way QuickBooks and Xero require&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cursor pagination with small pages: transactions cap at 30 records per page, so large accounts mean many requests per run&lt;/li&gt;
&lt;li&gt;Rate limiting, retries on 429s, and error recovery are all on you&lt;/li&gt;
&lt;li&gt;Amounts arrive as strings of integer minor units inside nested objects (&lt;code&gt;details.totals.grand_total&lt;/code&gt;), so schema mapping and type conversion are manual work&lt;/li&gt;
&lt;li&gt;Each new entity (customers, subscriptions, products, prices, adjustments, discounts) is another loop, another schema, another set of edge cases&lt;/li&gt;
&lt;li&gt;Sandbox and live need separate configuration and testing&lt;/li&gt;
&lt;li&gt;Maintenance is forever. The build is the easy part&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most teams the arithmetic is simple: a weekend to build it, then an open-ended commitment to keep it alive as Paddle's API and your own schema both move.&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 4: Zapier, Make, or Generic Automation Platforms
&lt;/h3&gt;

&lt;p&gt;If you want fresh data without writing code, automation platforms can catch Paddle webhooks. Neither Zapier nor Make has a native Paddle trigger (Make's Paddle app is actions-only, and Zapier has no Paddle app at all), but both have generic webhook modules. You point Paddle's &lt;code&gt;transaction.completed&lt;/code&gt; webhook at a catch-hook URL, then map the payload fields to a PostgreSQL insert action.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No code required, though you do set up a webhook destination and map fields by hand&lt;/li&gt;
&lt;li&gt;Both platforms have solid PostgreSQL insert actions&lt;/li&gt;
&lt;li&gt;Reasonable for low-volume, single-trigger use cases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No historical backfill; only future events trigger the automation. Your existing customers, subscriptions, and transaction history stay outside the database unless you export them separately&lt;/li&gt;
&lt;li&gt;Per-task pricing scales with your sales volume; a growing subscription business generates a lot of events&lt;/li&gt;
&lt;li&gt;Limited transformation logic; anything beyond direct field mapping needs custom code steps, which take you back toward Method 3&lt;/li&gt;
&lt;li&gt;Failures retry, but silently; debugging a stuck automation is painful&lt;/li&gt;
&lt;li&gt;Your "export pipeline" lives inside a third-party automation account, not your codebase&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Automation platforms earn their place on single-trigger flows. Asking one to hold a complete, current copy of your Paddle data is asking it to act as a replication tool, which it was never built to be.&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 5: A Purpose-Built No-Code Sync (Codeless Sync)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; was built for exactly this problem: getting API data into a PostgreSQL database without writing code and without a report-lag delay, then keeping it there without a pipeline to maintain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Connect your PostgreSQL database via connection string (&lt;a href="https://codelesssync.com/paddle-to-supabase" rel="noopener noreferrer"&gt;Supabase&lt;/a&gt;, &lt;a href="https://codelesssync.com/paddle-to-neon" rel="noopener noreferrer"&gt;Neon&lt;/a&gt;, &lt;a href="https://codelesssync.com/paddle-to-aws-rds" rel="noopener noreferrer"&gt;AWS RDS&lt;/a&gt;, &lt;a href="https://codelesssync.com/paddle-to-railway" rel="noopener noreferrer"&gt;Railway&lt;/a&gt;, Heroku, or self-hosted)&lt;/li&gt;
&lt;li&gt;Add your Paddle API key (read access is enough)&lt;/li&gt;
&lt;li&gt;Pick which data to export (customers, subscriptions, transactions, products, prices, adjustments, or discounts)&lt;/li&gt;
&lt;li&gt;The destination table is auto-created with the right schema and indexes&lt;/li&gt;
&lt;li&gt;Run the first export: a full sync pulls your complete history. Schedule recurring syncs, or trigger them manually&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No code to write, and no report polling or pagination loops left running&lt;/li&gt;
&lt;li&gt;Historical backfill plus ongoing incremental updates in one workflow (no 24-hour report lag)&lt;/li&gt;
&lt;li&gt;Works with any PostgreSQL host&lt;/li&gt;
&lt;li&gt;Free tier for small projects, flat predictable pricing as you scale&lt;/li&gt;
&lt;li&gt;Setup takes about 5 minutes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Runs in batches on a schedule (webhooks remain the right tool for instant reactions like access provisioning)&lt;/li&gt;
&lt;li&gt;Currently focused on Stripe, QuickBooks, Xero, and Paddle; not a general-purpose ETL tool&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If what you want is a current, queryable copy of your Paddle data sitting in your own database, this is the shortest route to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison: Which Paddle Export Method Fits Your Use Case?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Setup time&lt;/th&gt;
&lt;th&gt;Keeps data current?&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dashboard CSV report&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Minutes&lt;/td&gt;
&lt;td&gt;No; async snapshot, up to 24h data lag&lt;/td&gt;
&lt;td&gt;A finance handoff you do once a quarter&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reports API pipeline&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Days&lt;/td&gt;
&lt;td&gt;Partial; scheduled but inherits report lag&lt;/td&gt;
&lt;td&gt;Teams already loading CSV feeds on a schedule&lt;/td&gt;
&lt;td&gt;Server time plus the build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Direct Paddle API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Days to weeks&lt;/td&gt;
&lt;td&gt;Yes, if you maintain the polling&lt;/td&gt;
&lt;td&gt;A custom entity mix worth the engineering&lt;/td&gt;
&lt;td&gt;Server time plus ongoing upkeep&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Zapier / Make&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hours&lt;/td&gt;
&lt;td&gt;Partial; future events only, no backfill&lt;/td&gt;
&lt;td&gt;Reacting to one Paddle event at low volume&lt;/td&gt;
&lt;td&gt;Per-task, rises with sales volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Codeless Sync&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~5 minutes&lt;/td&gt;
&lt;td&gt;Yes; backfill plus scheduled incremental&lt;/td&gt;
&lt;td&gt;Anyone who wants the data there and current&lt;/td&gt;
&lt;td&gt;Free tier, then flat plans&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read down the table and the trade is clear enough. The free routes hand you a snapshot that is already stale and will delete itself. The API hands you freshness in exchange for permanent upkeep. The automation platforms only ever see what happens next. A purpose-built sync is the one option that gives you history and freshness without putting a pipeline in your name.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Can Do Once Paddle Data Is in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;Paddle's own reporting can tell you what happened inside Paddle. Once the same data is in Postgres, you can ask questions Paddle has no way to answer, because you can join your billing records against your application's tables and against each other.&lt;/p&gt;

&lt;p&gt;The queries below run against the tables Codeless Sync creates. Paddle returns amounts as strings in the lowest denomination for the currency (cents for USD, pence for GBP), so totals are cast to numeric and divided by 100. Most currencies use two decimal places, but a few (JPY, for example) use none, so adjust the divisor if you sell in those. If you sell in more than one currency, add &lt;code&gt;AND currency_code = 'USD'&lt;/code&gt; or group by &lt;code&gt;currency_code&lt;/code&gt;, since every row is stored in its original currency. Add &lt;code&gt;AND livemode = true&lt;/code&gt; to keep sandbox rows out of the numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monthly revenue with month-over-month growth:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;monthly_revenue&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;DATE_TRUNC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;billed_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;transaction_count&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;paddle_transactions&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;transaction_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;LAG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LAG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;mom_growth_pct&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;monthly_revenue&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Refund and chargeback rate from adjustments:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;adj&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;DATE_TRUNC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'refund'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'approved'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'chargeback'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'approved'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;chargebacks&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;paddle_adjustments&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;DATE_TRUNC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;billed_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;completed_transactions&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;paddle_transactions&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chargebacks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refunds&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chargebacks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completed_transactions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;refund_chargeback_rate_pct&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;adj&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;month&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;month&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Trial-to-paid conversion by cohort (only possible with full history):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;DATE_TRUNC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cohort_month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;subscriptions_started&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;first_billed_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;converted_to_paid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;first_billed_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;conversion_pct&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;paddle_subscriptions&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;livemode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;cohort_month&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last one is the query a webhook-fed table can never answer, because the cohorts you most want to measure started before you set the webhook up. That is what a real export gets you: a queryable dataset sitting next to your application data, ready for dashboards or alerts, instead of a CSV that deletes itself in 14 days. For ready-made metric queries, see &lt;a href="https://codelesssync.com/blog/calculate-mrr-churn-ltv-postgresql" rel="noopener noreferrer"&gt;How to Calculate MRR, Churn, and LTV in PostgreSQL&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-Step: Set Up an Automated Paddle Export in 5 Minutes
&lt;/h2&gt;

&lt;p&gt;If Method 5 looks like the right fit, the whole setup is four things: paste your PostgreSQL connection string, add a Paddle API key with Permissions set to Read for All (it's encrypted at rest), let the wizard auto-create the destination table, then run the first export. Transactions is the data type to start with, since it's your revenue record, and the &lt;a href="https://codelesssync.com/docs/sql-templates/paddle-transactions" rel="noopener noreferrer"&gt;Paddle transactions SQL template&lt;/a&gt; shows the exact schema you'll get.&lt;/p&gt;

&lt;p&gt;That first run is a full backfill, so your complete history lands in the table rather than only what happens from now on. After that you either trigger exports manually or put them on a schedule (every 12 hours, daily, weekly, or monthly, depending on your plan). Repeat the wizard for customers, subscriptions, or any other data type, and each one becomes its own table on the same cadence.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://codelesssync.com/pricing" rel="noopener noreferrer"&gt;free tier&lt;/a&gt; covers small projects without a credit card, and the full walkthrough, including which PostgreSQL hosts are supported, lives on the &lt;a href="https://codelesssync.com/paddle-to-postgresql" rel="noopener noreferrer"&gt;Paddle to PostgreSQL page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I export Paddle data without using the API?
&lt;/h3&gt;

&lt;p&gt;Yes, via a Paddle CSV export from the dashboard: go to Reports, build the report type you need (transactions, adjustments, line items, products and prices, or discounts), and download the CSV once Paddle emails you that it's generated. This works for one-off analysis, but reports build asynchronously, the data inside can lag up to 24 hours, and files expire after 14 days. For an ongoing, current copy of your data, you'll want one of the automated methods, either built on the API yourself or handled by a sync tool like &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Paddle have a full data export or backup option?
&lt;/h3&gt;

&lt;p&gt;Not as a single button. Exports are per report type in the dashboard, or per entity through the API's paginated list endpoints. A complete picture of your billing data means several separate exports, repeated whenever you need fresh data. This is the main reason teams reach for a sync tool: a complete, continuously updated copy in one database is exactly what those tools produce.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the best way to export Paddle data to PostgreSQL?
&lt;/h3&gt;

&lt;p&gt;It depends on freshness needs and engineering time. For a one-time snapshot, a dashboard report plus a psql &lt;code&gt;\copy&lt;/code&gt; import is fastest. For a current, queryable copy with minimal maintenance, a no-code sync is the lowest-effort path and avoids the report lag entirely. Building directly against the Paddle API gives the most control, but you own cursor pagination (transactions cap at 30 records per page), rate-limit handling, and schema mapping forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long are Paddle report exports available?
&lt;/h3&gt;

&lt;p&gt;Generated report files are available to download for 14 days after creation, and each download URL the API returns expires after 72 hours, so you can request a fresh link at any point inside that 14-day window. Reports are best treated as one-off snapshots rather than an archive. If you need a permanent, queryable history, load the data into your own database, then the retention question disappears.&lt;/p&gt;

&lt;h3&gt;
  
  
  How fresh will my exported Paddle data be?
&lt;/h3&gt;

&lt;p&gt;That depends entirely on the method. A dashboard report or a Reports API pull can be up to 24 hours behind before you even download it, because Paddle builds those asynchronously. Anything that reads the API directly, including a sync tool, is current as of the moment the run starts. So if the number you are looking at needs to match what Paddle's dashboard says right now, rule out the report-based methods. For monthly finance reviews a daily run is plenty; for internal dashboards and MRR tracking, twice a day keeps the numbers feeling live. Codeless Sync runs manual syncs on the free tier, and paid tiers add scheduled runs from every 12 hours down to monthly, depending on plan.&lt;/p&gt;




&lt;p&gt;Need a current, queryable copy of your Paddle data without babysitting reports or pagination loops? &lt;a href="https://codelesssync.com/pricing" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; has a free tier, no credit card required. For the webhook side of the story, see &lt;a href="https://codelesssync.com/blog/paddle-webhooks-vs-database-sync" rel="noopener noreferrer"&gt;Paddle Webhooks vs Database Sync&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/paddle-to-postgresql" rel="noopener noreferrer"&gt;Sync Paddle to PostgreSQL&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/paddle-webhooks-vs-database-sync" rel="noopener noreferrer"&gt;Paddle Webhooks vs Database Sync: Which is Better?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/calculate-mrr-churn-ltv-postgresql" rel="noopener noreferrer"&gt;How to Calculate MRR, Churn, and LTV in PostgreSQL&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/how-to-export-quickbooks-data-to-database" rel="noopener noreferrer"&gt;How to Export QuickBooks Data to a Database&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/how-to-export-xero-data-to-database" rel="noopener noreferrer"&gt;How to Export Xero Data to a Database&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>api</category>
      <category>paddle</category>
    </item>
    <item>
      <title>How to Export Xero Data to a Database</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Mon, 20 Jul 2026 11:19:58 +0000</pubDate>
      <link>https://dev.to/ilshadyx/how-to-export-xero-data-to-a-database-291d</link>
      <guid>https://dev.to/ilshadyx/how-to-export-xero-data-to-a-database-291d</guid>
      <description>&lt;p&gt;&lt;em&gt;Compare 5 ways to export Xero data to a database: CSV report exports, the Xero API, Zapier, ETL platforms, and no-code sync. Honest pros, cons, and costs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;By Ilshaad Kheerdali · 20 July 2026&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;If you run your accounting on Xero, you've probably hit a wall trying to get the data out. There is no one-click "export everything" button. Contacts export from one screen, invoices from another, reports from a third, and every file is a static snapshot that's stale the moment you download it. The API works, but only after you register an app, wire up OAuth 2.0, and babysit tokens forever.&lt;/p&gt;

&lt;p&gt;The frustrating part is that "export Xero data to a database" sounds like it should be simple. It isn't. Different methods exist for different needs, and most of them either go stale immediately, cost more than they should, or leave you maintaining a pipeline that quietly breaks at 3am.&lt;/p&gt;

&lt;p&gt;This guide walks through the five practical ways to export Xero data into a real, queryable database: CSV exports, the Xero API directly, Zapier-style automation, enterprise ETL platforms, and no-code sync. Honest pros, honest cons, and what each one actually costs to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Exporting Xero Data Is Harder Than It Should Be
&lt;/h2&gt;

&lt;p&gt;Xero holds the data you care about: contacts, invoices, payments, bank transactions, the whole accounting picture. But getting it out in a form you can actually use takes more work than most teams expect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Built-in exports are per-screen snapshots.&lt;/strong&gt; Xero lets you &lt;a href="https://central.xero.com/s/article/Export-data-out-of-Xero-GL" rel="noopener noreferrer"&gt;export contacts, invoices, and bills as CSV files, and reports as Excel, PDF, or Google Sheets&lt;/a&gt;, but each data type exports separately from its own screen. A complete picture of your accounts means running several exports, and every one is frozen in time the second you download it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There's no bulk "export everything" endpoint.&lt;/strong&gt; The Xero API is built for transactional access, not data extraction. Invoices page through 100 records at a time (up to 1,000 with the &lt;code&gt;pageSize&lt;/code&gt; parameter), contacts are a separate set of calls, payments another. For a complete dataset you're making dozens of calls and stitching the responses together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;List calls hide the detail you actually need.&lt;/strong&gt; Ask the API for a list of invoices and it returns trimmed summaries with no line items, to keep responses fast. Getting the full records means fetching them individually or paging with larger page sizes, so a single "get my invoices" is rarely one call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhooks notify but don't deliver data.&lt;/strong&gt; Xero webhooks only fire for four things (contacts, invoices, credit notes, and App Store subscriptions), and the payload carries just a reference to the record, not the record itself. You still call the API to fetch what changed, which means you're maintaining the polling layer regardless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OAuth 2.0 is non-negotiable.&lt;/strong&gt; Every Xero integration needs a registered app, a consent flow, and token management. Access tokens last 30 minutes, and the refresh token is single-use with a rolling 60-day life: every refresh returns a new refresh token and invalidates the old one. Store the wrong one and your export job silently stops working. (For the full breakdown of the integration burden, see &lt;a href="https://codelesssync.com/blog/xero-api-vs-database-sync" rel="noopener noreferrer"&gt;Xero API vs Database Sync&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;The result is that "export Xero data to a database" gets solved one of five ways. Here they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  5 Ways to Export Xero Data to a Database
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Method 1: Manual CSV / Excel Exports from Xero
&lt;/h3&gt;

&lt;p&gt;The simplest option. From inside Xero, export each data type from its own screen: &lt;strong&gt;Contacts → Export&lt;/strong&gt; for your customer and supplier list, &lt;strong&gt;Business → Invoices → Export&lt;/strong&gt; for &lt;a href="https://central.xero.com/s/article/Export-invoices-and-bills" rel="noopener noreferrer"&gt;invoices and bills&lt;/a&gt;, and the &lt;strong&gt;Reports&lt;/strong&gt; section for the General Ledger, Trial Balance, and other reports (which export to Excel, PDF, or Google Sheets; notably, Xero's newer reports don't offer CSV).&lt;/p&gt;

&lt;p&gt;Once you have a file, you import it into your database with a &lt;code&gt;COPY&lt;/code&gt; statement or a one-off script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt; &lt;span class="n"&gt;xero_invoices_export&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contact_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;invoice_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount_due&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="s1"&gt;'/path/to/xero-invoices.csv'&lt;/span&gt;
&lt;span class="k"&gt;DELIMITER&lt;/span&gt; &lt;span class="s1"&gt;','&lt;/span&gt;
&lt;span class="n"&gt;CSV&lt;/span&gt; &lt;span class="n"&gt;HEADER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free and built into Xero&lt;/li&gt;
&lt;li&gt;No code, no API setup, no developer required&lt;/li&gt;
&lt;li&gt;Useful for one-off analysis or sending to an accountant&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stale the moment you click export; the file represents a single point in time&lt;/li&gt;
&lt;li&gt;Manual every time. If you need fresh data weekly, you're running this every week&lt;/li&gt;
&lt;li&gt;Each data type exports separately, so a full dataset means many separate exports from different screens&lt;/li&gt;
&lt;li&gt;Column layouts differ between screens and reports, so your import scripts need per-file handling&lt;/li&gt;
&lt;li&gt;No automation, no incremental updates, no joins with your application data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CSV exports are fine for a quarterly accountant handoff. They are not a database export strategy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 2: Direct Xero API Integration
&lt;/h3&gt;

&lt;p&gt;If you need fresh data and you're comfortable writing code, you can pull directly from the Xero Accounting API and write the results into PostgreSQL yourself.&lt;/p&gt;

&lt;p&gt;Here's a stripped-down example in TypeScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;exportInvoices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`https://api.xero.com/api.xro/2.0/Invoices?page=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;pageSize=1000`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Xero-tenant-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Invoices&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inv&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;`INSERT INTO xero_invoices (xero_id, invoice_number, contact_name, type, status, total, amount_due, updated_at)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
         ON CONFLICT (xero_id) DO UPDATE
         SET status = $5, total = $6, amount_due = $7, updated_at = $8`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InvoiceID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InvoiceNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Contact&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Total&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AmountDue&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UpdatedDateUTC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Real, current data on demand&lt;/li&gt;
&lt;li&gt;Full control over which data types you export and how they map to your schema&lt;/li&gt;
&lt;li&gt;Free in tooling cost; you only pay for the infrastructure that runs it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OAuth 2.0 setup: app registration, consent flow, redirect URI handling, and token storage&lt;/li&gt;
&lt;li&gt;Access tokens expire every 30 minutes, and the single-use refresh token must be re-persisted on every refresh&lt;/li&gt;
&lt;li&gt;One Xero login can cover several organisations, so you also enumerate tenants and send the right &lt;code&gt;Xero-tenant-id&lt;/code&gt; header on every call&lt;/li&gt;
&lt;li&gt;Rate limiting is on you: &lt;a href="https://developer.xero.com/documentation/guides/oauth2/limits/" rel="noopener noreferrer"&gt;60 calls per minute per organisation, 5 concurrent requests, plus a daily cap&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Incremental sync via &lt;code&gt;If-Modified-Since&lt;/code&gt; has blind spots (some changes never bump &lt;code&gt;UpdatedDateUTC&lt;/code&gt;), so you still need periodic full reconciliation&lt;/li&gt;
&lt;li&gt;Each new data type (contacts, payments, bank transactions, credit notes) is another query, another schema, another set of edge cases&lt;/li&gt;
&lt;li&gt;Maintenance is forever. The build is the easy part&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the right path if your needs are unusual or you have engineering time to spare. For most teams, the upkeep cost outweighs the benefit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 3: Zapier, Make, or Generic Automation Platforms
&lt;/h3&gt;

&lt;p&gt;If you want fresh data without writing code, automation platforms like Zapier and Make have pre-built Xero triggers. You can wire up "when an invoice is created in Xero, insert a row into Postgres" and it just works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No code required&lt;/li&gt;
&lt;li&gt;Decent library of triggers: new invoice, new contact, new payment, and so on&lt;/li&gt;
&lt;li&gt;Quick to set up for simple flows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Per-task pricing scales fast. A growing business with thousands of monthly invoices can hit the higher tiers within a couple of months&lt;/li&gt;
&lt;li&gt;No historical backfill; only future events trigger zaps. Your existing contacts and invoices stay outside the database unless you export them separately&lt;/li&gt;
&lt;li&gt;Limited transformation logic. Anything more complex than a direct field mapping needs custom code steps, which take you back toward the territory of Method 2&lt;/li&gt;
&lt;li&gt;Failures retry, but silently; debugging a stuck zap is painful&lt;/li&gt;
&lt;li&gt;Vendor lock-in. Your "data export pipeline" lives inside a Zapier account, not in your codebase&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Zapier-style platforms work for single-trigger flows. They're a poor fit for "I want a complete, current copy of my Xero data in Postgres."&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 4: Enterprise ETL Platforms (Fivetran, Airbyte)
&lt;/h3&gt;

&lt;p&gt;General-purpose ETL platforms have Xero connectors and will land your data in a warehouse or database on a schedule, alongside hundreds of other sources.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mature scheduling, monitoring, and retry infrastructure&lt;/li&gt;
&lt;li&gt;Handles many sources beyond accounting, useful if you're already consolidating a large data stack&lt;/li&gt;
&lt;li&gt;Managed OAuth and rate-limit handling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Usage-based pricing (Fivetran bills by monthly active rows) is hard to predict and usually overkill for one accounting sync&lt;/li&gt;
&lt;li&gt;Self-hosting Airbyte trades the subscription for your own infrastructure and upgrade maintenance&lt;/li&gt;
&lt;li&gt;Built for data teams feeding warehouses, so setup assumes more data-engineering context than most small teams have&lt;/li&gt;
&lt;li&gt;A heavyweight platform for what is, for most readers, a single-source job&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you already run a Fivetran or Airbyte stack, adding Xero to it is reasonable. Adopting one just to export your accounting data is like buying a truck to deliver one parcel.&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 5: A Purpose-Built No-Code Sync (Codeless Sync)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; was built for exactly this problem: getting API data into a PostgreSQL database without code, without ETL infrastructure, and without per-row pricing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Connect your PostgreSQL database via connection string (Supabase, Neon, AWS RDS, Railway, Heroku, or self-hosted)&lt;/li&gt;
&lt;li&gt;Authorize Xero with one click; the OAuth consent, token storage, and refresh are handled for you&lt;/li&gt;
&lt;li&gt;Pick which data to export (contacts, invoices, payments, accounts, bank transactions, credit notes, items, purchase orders, journals, or organisation details)&lt;/li&gt;
&lt;li&gt;The destination table is auto-created with the right schema and indexes&lt;/li&gt;
&lt;li&gt;The first export runs immediately. Schedule recurring syncs, or trigger them manually&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No code, no OAuth plumbing, no token refresh maintenance&lt;/li&gt;
&lt;li&gt;Historical backfill plus ongoing incremental updates in one workflow&lt;/li&gt;
&lt;li&gt;Works with any PostgreSQL host&lt;/li&gt;
&lt;li&gt;Free tier for small projects, flat predictable pricing as you scale&lt;/li&gt;
&lt;li&gt;Setup takes about 5 minutes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Batch-based, not real-time (though incremental syncs run as often as every minute on paid plans)&lt;/li&gt;
&lt;li&gt;Currently focused on Stripe, QuickBooks, Xero, and Paddle; not a general-purpose ETL tool&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the recommended path if your goal is a current, queryable copy of your Xero data in your own database, with the lowest possible maintenance burden.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison: Which Export Method Fits Your Use Case?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Setup time&lt;/th&gt;
&lt;th&gt;Keeps data current?&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CSV / Excel exports&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Minutes&lt;/td&gt;
&lt;td&gt;No; single snapshot per screen&lt;/td&gt;
&lt;td&gt;One-off accountant handoffs&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Direct Xero API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Days to weeks&lt;/td&gt;
&lt;td&gt;Yes, if you maintain the polling&lt;/td&gt;
&lt;td&gt;Bespoke integrations with engineering capacity&lt;/td&gt;
&lt;td&gt;Infrastructure only, plus dev time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Zapier / Make&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hours&lt;/td&gt;
&lt;td&gt;Partial; future events only, no backfill&lt;/td&gt;
&lt;td&gt;Single-trigger flows for small volumes&lt;/td&gt;
&lt;td&gt;Tiered, scales with task volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fivetran / Airbyte&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hours to days&lt;/td&gt;
&lt;td&gt;Yes; scheduled connector runs&lt;/td&gt;
&lt;td&gt;Data teams already running a multi-source stack&lt;/td&gt;
&lt;td&gt;Usage-priced or self-hosted infra&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Codeless Sync&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~5 minutes&lt;/td&gt;
&lt;td&gt;Yes; backfill plus scheduled incremental&lt;/td&gt;
&lt;td&gt;Developers and small teams who want it to just work&lt;/td&gt;
&lt;td&gt;Free tier, then flat plans&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The split is roughly: free options give you stale per-screen files, the API gives you fresh data at the cost of forever-maintenance, automation platforms work until your volume grows, enterprise ETL works if you already own the stack, and a purpose-built sync sits in the middle: fresh data, low maintenance, predictable cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Can Do Once Xero Data Is in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;The whole point of exporting Xero data into a database is what becomes possible afterwards. With the data in Postgres, you have full SQL access to everything, and you can join it with your application's own tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monthly sales revenue with month-over-month growth:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;monthly_revenue&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;DATE_TRUNC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;invoice_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;invoice_count&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;xero_invoices&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACCREC'&lt;/span&gt;          &lt;span class="c1"&gt;-- sales invoices, not supplier bills&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PAID'&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;invoice_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;LAG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LAG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;mom_growth_pct&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;monthly_revenue&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Outstanding receivables by contact:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;contact_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_due&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;outstanding_balance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;unpaid_invoices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;due_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;oldest_due_date&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;xero_invoices&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACCREC'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;amount_due&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'AUTHORISED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SUBMITTED'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;contact_name&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;outstanding_balance&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Net position: what you're owed vs what you owe:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                     &lt;span class="c1"&gt;-- ACCREC = sales invoices, ACCPAY = bills&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;amount_due&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;open_invoices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_due&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;outstanding&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;xero_invoices&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'AUTHORISED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SUBMITTED'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is what a real export gets you. Not a folder of per-screen CSV files, but a queryable dataset that lives next to your application data, ready for dashboards, alerts, or any analysis you want to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-Step: Export Xero to Postgres with Codeless Sync
&lt;/h2&gt;

&lt;p&gt;If Method 5 looks like the right fit, the setup itself takes about five minutes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create a Codeless Sync account.&lt;/strong&gt; The &lt;a href="https://codelesssync.com/pricing" rel="noopener noreferrer"&gt;free tier&lt;/a&gt; covers small projects without a credit card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add your PostgreSQL database.&lt;/strong&gt; Paste your connection string. Codeless Sync tests the connection before saving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open the configuration wizard and choose Xero&lt;/strong&gt; as the source. Pick the data type you want first; contacts is a good starting point because it's easy to verify.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Click Connect to Xero&lt;/strong&gt; and authorize through Xero's standard consent screen, then pick which organisation to sync. The OAuth flow, token storage, and refresh loop are handled automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-create the destination table.&lt;/strong&gt; Codeless Sync builds the schema for you, with the right column types and indexes. If you'd rather review the SQL first, copy the template and run it manually.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the first export.&lt;/strong&gt; The full backfill pulls every matching record. For most organisations this takes seconds to a couple of minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedule recurring exports&lt;/strong&gt; (every minute, hourly, or daily depending on your plan), or trigger them manually from the dashboard.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When the run finishes, your Xero data is in your Postgres database. Repeat the wizard for invoices, payments, or any other data type you need. Each one becomes its own table, each one stays current.&lt;/p&gt;

&lt;p&gt;For a deeper walkthrough including the full Xero setup flow, see the &lt;a href="https://codelesssync.com/blog/how-to-sync-xero-data-to-postgresql" rel="noopener noreferrer"&gt;step-by-step Xero-to-PostgreSQL sync guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I export Xero data without using the API?
&lt;/h3&gt;

&lt;p&gt;Yes. The no-API option is to export each data type from its own screen inside Xero (contacts, invoices, bills) or export reports as Excel, PDF, or Google Sheets. This works for one-off analysis but produces static files that are stale the moment they're downloaded, and a complete dataset means several separate exports. For ongoing access, every other method eventually involves the Xero API in some form; the question is whether you build that integration yourself or use a tool that handles it for you. A no-code sync like &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; uses the API behind the scenes so you don't have to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Xero have a full backup or bulk export option?
&lt;/h3&gt;

&lt;p&gt;Not in the way developers usually mean it. There's no single button or API endpoint that returns all of your data at once. Xero's own guidance is to export each data type separately: contacts from the Contacts screen, invoices and bills from the Business menu, and the General Ledger and other reports from the Reports section. This is the main reason most teams reach for a sync tool; a complete, current copy of your data in one place is exactly what those tools do.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the best way to export Xero data to PostgreSQL?
&lt;/h3&gt;

&lt;p&gt;It depends on how often the data needs to refresh and how much engineering time you can spare. For a one-time export, per-screen CSV files plus a &lt;code&gt;COPY&lt;/code&gt; statement is fastest. For a current, queryable copy of your Xero data with minimal maintenance, a no-code sync tool is the lowest-effort path. Building directly against the Xero API gives you the most control but the highest ongoing maintenance cost: 30-minute access tokens, single-use refresh tokens, tenant routing, and rate limits are all yours to manage.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often should I export Xero data?
&lt;/h3&gt;

&lt;p&gt;It depends on what you're using the data for. For monthly accounting reviews, daily syncs are plenty. For internal dashboards or cash-flow monitoring, hourly updates feel close to live. For event-driven workflows (like flagging overdue invoices to your ops team), you'll want incremental syncs running at least every 5-15 minutes. Codeless Sync supports schedules from every minute up to daily, depending on plan tier.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will exporting Xero data affect my API rate limits?
&lt;/h3&gt;

&lt;p&gt;Xero enforces &lt;a href="https://developer.xero.com/documentation/guides/oauth2/limits/" rel="noopener noreferrer"&gt;60 calls per minute per connected organisation, with 5 concurrent requests and a daily cap on top&lt;/a&gt;. A well-designed export tool stays well under that; incremental syncs typically only fetch records that changed since the last run, so the request count is small. If you're building a custom integration, you'll need to implement your own backoff and retry logic to stay under the limits. Sync tools handle this for you.&lt;/p&gt;




&lt;p&gt;Need a current, queryable copy of your Xero data without writing a pipeline? &lt;a href="https://codelesssync.com/pricing" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; has a free tier, no credit card required. For the build-vs-buy decision in more depth, see &lt;a href="https://codelesssync.com/blog/xero-api-vs-database-sync" rel="noopener noreferrer"&gt;Xero API vs Database Sync&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/how-to-sync-xero-data-to-postgresql" rel="noopener noreferrer"&gt;How to Sync Xero to PostgreSQL Automatically in 5 Minutes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/xero-api-vs-database-sync" rel="noopener noreferrer"&gt;Xero API vs Database Sync: Which is Better?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/how-to-export-quickbooks-data-to-database" rel="noopener noreferrer"&gt;How to Export QuickBooks Data to a Database&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>api</category>
      <category>xero</category>
    </item>
    <item>
      <title>How to Design a PostgreSQL Schema for Stripe Data</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Tue, 14 Jul 2026 16:27:09 +0000</pubDate>
      <link>https://dev.to/ilshadyx/how-to-design-a-postgresql-schema-for-stripe-data-m0d</link>
      <guid>https://dev.to/ilshadyx/how-to-design-a-postgresql-schema-for-stripe-data-m0d</guid>
      <description>&lt;p&gt;&lt;em&gt;Design a PostgreSQL schema for Stripe data: convert unix timestamps, store amounts in integer minor units, use a JSONB catch-all, and survive API drift.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;By Ilshaad Kheerdali · 14 July 2026&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Once you have decided to get your Stripe data into PostgreSQL, the first practical question is what the tables should look like. The Stripe API returns tidy, self-describing JSON, so the obvious move is to create one column per field and call it done.&lt;/p&gt;

&lt;p&gt;That obvious schema is a trap. The types in a Stripe payload lie to you (timestamps are integers, money is integers, half the interesting fields are nested objects), the payload shape changes as Stripe evolves its API, and a table built from one example payload gives you no plan for what happens on the second sync when the same customer comes back with new values.&lt;/p&gt;

&lt;p&gt;This post walks through the three type traps, why the flat one-column-per-field dump ages badly, and the schema pattern that holds up in production. If you want to see finished schemas first, the free &lt;a href="https://codelesssync.com/tools/stripe-to-postgres-schema" rel="noopener noreferrer"&gt;Stripe to Postgres Schema tool&lt;/a&gt; shows the exact production table for 9 core Stripe objects, side by side with the naive dump so the differences are obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three type traps in Stripe JSON
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Timestamps are unix epoch integers
&lt;/h3&gt;

&lt;p&gt;Every timestamp Stripe sends is epoch seconds: &lt;code&gt;"created": 1782950400&lt;/code&gt;. Dump that into a &lt;code&gt;BIGINT&lt;/code&gt; column and every date query you ever write needs a conversion wrapped around it. Convert once at insert time instead, with PostgreSQL's &lt;a href="https://www.postgresql.org/docs/current/functions-datetime.html" rel="noopener noreferrer"&gt;&lt;code&gt;to_timestamp()&lt;/code&gt;&lt;/a&gt;, and store a &lt;code&gt;TIMESTAMPTZ&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Once, at insert time:&lt;/span&gt;
&lt;span class="n"&gt;to_timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1782950400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;-- 2026-07-02 00:00:00+00&lt;/span&gt;

&lt;span class="c1"&gt;-- Forever after, plain SQL just works:&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;stripe_customers&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Amounts are integers in minor units
&lt;/h3&gt;

&lt;p&gt;Stripe represents money in the smallest currency unit to avoid floating-point rounding errors, so £29.99 arrives as &lt;code&gt;2999&lt;/code&gt;. Store it as an integer and divide by 100 for display. One exception is documented in &lt;a href="https://docs.stripe.com/currencies" rel="noopener noreferrer"&gt;Stripe's currency docs&lt;/a&gt;: zero-decimal currencies like JPY are already whole units. Stripe also supports a handful of three-decimal currencies like KWD, where the minor unit is 1/1000, so the divisor there is 1000.&lt;/p&gt;

&lt;p&gt;The trap is not the storage, it is the reading. An &lt;code&gt;amount&lt;/code&gt; column holding &lt;code&gt;2999&lt;/code&gt; looks like two thousand pounds to anyone querying it cold. Keep amounts as integers (never &lt;code&gt;REAL&lt;/code&gt; or &lt;code&gt;FLOAT&lt;/code&gt;), and make the minor-units convention loud in your column comments and dashboards.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nested objects do not flatten
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;address&lt;/code&gt;, &lt;code&gt;invoice_settings&lt;/code&gt;, &lt;code&gt;recurring&lt;/code&gt;, &lt;code&gt;metadata&lt;/code&gt;: a good chunk of every Stripe payload is nested. Flattening them into columns explodes the column count, and dumping each one into its own &lt;code&gt;JSONB&lt;/code&gt; column just gives you a pile of fragments. Neither gets you queryable data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one column per JSON field ages badly
&lt;/h2&gt;

&lt;p&gt;Beyond the type traps, the flat dump has three structural problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No upsert key.&lt;/strong&gt; A schema generated from a payload gives you columns, not decisions. The decision that matters most is the primary key, because syncing is repetitive by nature: the same customer comes back on every sync with updated values. Without a primary key there is nothing to &lt;code&gt;ON CONFLICT&lt;/code&gt; against, so re-syncs either duplicate rows or fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No indexes.&lt;/strong&gt; The fields you will actually filter on (&lt;code&gt;customer&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;created&lt;/code&gt;) get no indexes, so queries crawl as the table grows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema drift.&lt;/strong&gt; The Stripe API changes shape. In API version 2025-03-31 (basil), Stripe &lt;a href="https://docs.stripe.com/changelog/basil/2025-03-31/deprecate-subscription-current-period-start-and-end" rel="noopener noreferrer"&gt;removed &lt;code&gt;current_period_start&lt;/code&gt; and &lt;code&gt;current_period_end&lt;/code&gt; from the Subscription object&lt;/a&gt; (they moved to subscription items), removed the &lt;code&gt;paid&lt;/code&gt; boolean from invoices, and restructured invoice line items. A table generated from one payload is only correct for that payload, on that API version, on that day. When Stripe adds or moves a field, the flat dump breaks silently: new fields vanish, and inserts written against the old shape start failing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PostgreSQL schema pattern that works: curated columns plus a JSONB catch-all
&lt;/h2&gt;

&lt;p&gt;Here is the actual production table for Stripe customers (this is the template &lt;a href="https://codelesssync.com/" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; auto-creates, shown verbatim in the &lt;a href="https://codelesssync.com/tools/stripe-to-postgres-schema" rel="noopener noreferrer"&gt;schema tool&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;stripe_customers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="c1"&gt;-- Core queryable fields&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;currency&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;delinquent&lt;/span&gt; &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;-- Full payload&lt;/span&gt;
  &lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;-- Sync metadata&lt;/span&gt;
  &lt;span class="n"&gt;livemode&lt;/span&gt; &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;synced_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;idx_stripe_customers_email&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;stripe_customers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;idx_stripe_customers_created&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;stripe_customers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;idx_stripe_customers_livemode&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;stripe_customers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;livemode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;idx_stripe_customers_delinquent&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;stripe_customers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delinquent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four deliberate choices are doing the work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A short set of typed, queryable columns.&lt;/strong&gt; Only the fields you filter, join, and report on. Timestamps are already &lt;code&gt;TIMESTAMPTZ&lt;/code&gt;, amounts are integers, and everything has a real type instead of a guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;data JSONB&lt;/code&gt; catch-all holding the full payload.&lt;/strong&gt; Nothing is ever lost, and this is the drift insurance: when Stripe adds a field next quarter, it lands inside &lt;code&gt;data&lt;/code&gt; instead of breaking your inserts. You can query it any time with &lt;code&gt;data-&amp;gt;&amp;gt;'phone'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync metadata.&lt;/strong&gt; &lt;code&gt;livemode&lt;/code&gt; keeps test and live records separable, and &lt;code&gt;synced_at&lt;/code&gt; tells you how fresh every row is, which is the first thing you check when a number looks off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indexes on the fields you actually query.&lt;/strong&gt; Email lookups, date ranges, and delinquency filters stay fast at any table size.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Pick the upsert key before the first insert
&lt;/h2&gt;

&lt;p&gt;Stripe IDs (&lt;code&gt;cus_...&lt;/code&gt;, &lt;code&gt;in_...&lt;/code&gt;, &lt;code&gt;sub_...&lt;/code&gt;) are stable, unique text values, which makes them the natural primary key. With &lt;code&gt;id TEXT PRIMARY KEY&lt;/code&gt; in place, re-syncing is one idempotent statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;stripe_customers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delinquent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;livemode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt;
  &lt;span class="n"&gt;email&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;balance&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;delinquent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delinquent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;data&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;synced_at&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it once or a thousand times, the table stays correct. This is the single decision the flat dump never makes for you, and it is why re-syncs against naive schemas end in duplicate rows.&lt;/p&gt;

&lt;p&gt;For joins, the reference fields do the same job across tables: &lt;code&gt;stripe_invoices.customer&lt;/code&gt; points at &lt;code&gt;stripe_customers.id&lt;/code&gt;, &lt;code&gt;stripe_subscriptions.customer&lt;/code&gt; likewise. One table per Stripe object, joined on IDs, and metrics queries like the ones in &lt;a href="https://codelesssync.com/blog/calculate-mrr-churn-ltv-postgresql" rel="noopener noreferrer"&gt;how to calculate MRR, churn, and LTV in PostgreSQL&lt;/a&gt; fall out naturally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schema drift is the part nobody budgets for
&lt;/h2&gt;

&lt;p&gt;Designing the schema is a one-off. Keeping it correct is not, and this is the cost that surprises teams months later.&lt;/p&gt;

&lt;p&gt;Stripe versions its API and moves fields between objects, as the basil changes above show. Your own usage changes too: the day you need subscription analytics, you need the subscriptions and subscription items tables you did not create on day one. And every change lands on you as migration work: alter the table, backfill the column, update the insert code, re-test the sync. It is exactly the kind of recurring, unglamorous maintenance that &lt;a href="https://codelesssync.com/blog/why-stripe-postgresql-sync-keeps-breaking" rel="noopener noreferrer"&gt;quietly breaks Stripe-to-Postgres pipelines&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;You can absolutely own that chore yourself, and if you are hand-building, this post's pattern (curated columns, JSONB catch-all, upsert key, sync metadata) minimises it, because unknown fields flow into &lt;code&gt;data&lt;/code&gt; instead of breaking the pipeline.&lt;/p&gt;

&lt;p&gt;The alternative is to not own it at all. &lt;a href="https://codelesssync.com/" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; creates these exact tables in one click and keeps them filled on a schedule (hourly, daily, or custom cron on paid tiers), with full and incremental sync modes, timestamps converted, amounts kept in minor units, and the complete payload preserved in &lt;code&gt;data&lt;/code&gt;. There are no webhooks or cron jobs to babysit and no drift handling on your plate. The 5-minute walkthrough is in &lt;a href="https://codelesssync.com/blog/how-to-sync-stripe-data-to-postgresql" rel="noopener noreferrer"&gt;how to sync Stripe data to PostgreSQL&lt;/a&gt;, and if you are still weighing approaches, &lt;a href="https://codelesssync.com/blog/5-ways-to-get-stripe-data-into-postgresql" rel="noopener noreferrer"&gt;5 ways to get Stripe data into PostgreSQL&lt;/a&gt; compares the options honestly.&lt;/p&gt;

&lt;p&gt;Either way, start from the right schema. All nine production templates (customers, invoices, subscriptions, payment intents, products, prices, refunds, invoice line items, subscription items) are free to inspect and copy in the &lt;a href="https://codelesssync.com/tools/stripe-to-postgres-schema" rel="noopener noreferrer"&gt;Stripe to Postgres Schema tool&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should I store the whole Stripe payload in one JSONB column?
&lt;/h3&gt;

&lt;p&gt;Not on its own. A single JSONB column preserves everything but makes every query a JSON-path expression, indexes get awkward, and type safety disappears. The hybrid works better: a handful of typed columns for the fields you query, plus one &lt;code&gt;data JSONB&lt;/code&gt; column holding the full payload for everything else. You get fast, readable SQL and lose nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should Stripe amounts be INTEGER, BIGINT, or NUMERIC in PostgreSQL?
&lt;/h3&gt;

&lt;p&gt;Use an integer type and keep Stripe's minor units. &lt;code&gt;INTEGER&lt;/code&gt; covers single-object amounts comfortably (its ceiling is over 21 million pounds in pence), and &lt;code&gt;BIGINT&lt;/code&gt; is the safe pick for aggregates or if you would rather never think about the ceiling. Avoid &lt;code&gt;REAL&lt;/code&gt; and &lt;code&gt;FLOAT&lt;/code&gt; entirely for money, and only convert to &lt;code&gt;NUMERIC&lt;/code&gt; at display or reporting time when you divide by 100 (or 1000 for three-decimal currencies).&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I store Stripe metadata in PostgreSQL?
&lt;/h3&gt;

&lt;p&gt;As JSONB. Stripe's &lt;code&gt;metadata&lt;/code&gt; object is arbitrary user-defined keys, so it has no fixed shape to model as columns. Query it with &lt;code&gt;data-&amp;gt;'metadata'-&amp;gt;&amp;gt;'your_key'&lt;/code&gt;, and if you filter on metadata often, add a GIN index on the JSONB column or an expression index on the specific key you query.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a separate table for every Stripe object?
&lt;/h3&gt;

&lt;p&gt;Yes, one table per object type: customers, invoices, subscriptions, payment intents, and so on. Stripe objects reference each other by ID, so separate tables joined on those IDs (for example &lt;code&gt;stripe_invoices.customer&lt;/code&gt; to &lt;code&gt;stripe_customers.id&lt;/code&gt;) mirror the API's own structure and keep queries simple. The &lt;a href="https://codelesssync.com/tools/stripe-to-postgres-schema" rel="noopener noreferrer"&gt;schema tool&lt;/a&gt; shows the table for each of the 9 core objects.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I keep my Stripe tables up to date when the API changes?
&lt;/h3&gt;

&lt;p&gt;Three habits if you are hand-rolling: pin your Stripe API version so changes only arrive when you choose, watch the &lt;a href="https://docs.stripe.com/changelog" rel="noopener noreferrer"&gt;Stripe API changelog&lt;/a&gt; for moved or removed fields, and keep a full-payload JSONB column so new fields are captured even before you model them. Or hand the chore off entirely: a managed sync like Codeless Sync owns the schema, the conversions, and the drift handling, so your tables stay current without migration work.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/how-to-sync-stripe-data-to-postgresql" rel="noopener noreferrer"&gt;How to Sync Stripe Data to PostgreSQL in 5 Minutes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/why-stripe-postgresql-sync-keeps-breaking" rel="noopener noreferrer"&gt;Why Your Stripe to PostgreSQL Sync Keeps Breaking&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/calculate-mrr-churn-ltv-postgresql" rel="noopener noreferrer"&gt;How to Calculate MRR, Churn, and LTV in PostgreSQL&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/5-ways-to-get-stripe-data-into-postgresql" rel="noopener noreferrer"&gt;5 Ways to Get Stripe Data into PostgreSQL&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/tools/stripe-to-postgres-schema" rel="noopener noreferrer"&gt;Stripe to Postgres Schema (free tool)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>api</category>
      <category>stripe</category>
    </item>
    <item>
      <title>Xero API vs Database Sync: Which is Better?</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Mon, 06 Jul 2026 14:11:03 +0000</pubDate>
      <link>https://dev.to/ilshadyx/xero-api-vs-database-sync-which-is-better-57pm</link>
      <guid>https://dev.to/ilshadyx/xero-api-vs-database-sync-which-is-better-57pm</guid>
      <description>&lt;p&gt;&lt;em&gt;Xero API vs database sync for getting accounting data into PostgreSQL: a build-vs-buy guide covering OAuth tokens, rate limits, and the real maintenance cost.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;By Ilshaad Kheerdali · 6 July 2026&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;If you're building on top of Xero, you'll eventually need that accounting data in your own database, to power dashboards, reconcile revenue, or join it against your product tables. There are two ways to get it there: build your own integration against the Xero API, or hand the job to a managed database sync. Both work, but they cost you very different things.&lt;/p&gt;

&lt;p&gt;It helps to be honest about the framing first. This isn't really "API versus no API", because a managed sync uses the Xero API under the hood too. The real question is build versus buy: do you own the OAuth tokens, the tenant routing, the pagination, the rate-limit backoff, and the schema mapping yourself, or do you let a service run all of that for you? This post compares the two at decision altitude. (If you just want the step-by-step setup instead, here is &lt;a href="https://codelesssync.com/blog/how-to-sync-xero-data-to-postgresql" rel="noopener noreferrer"&gt;how to sync Xero to PostgreSQL in five minutes&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  How a Xero API Integration Works
&lt;/h2&gt;

&lt;p&gt;Building your own integration means going directly to Xero's developer platform. The shape of the work is always the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Register an app in the Xero Developer portal and get your client credentials.&lt;/li&gt;
&lt;li&gt;Send each customer through the OAuth 2.0 consent flow with the &lt;code&gt;offline_access&lt;/code&gt; scope, then store the access and refresh tokens. Because one Xero login can cover several companies, you also call the Connections endpoint and keep track of each &lt;code&gt;tenantId&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Poll the Accounting API for the objects you care about (invoices, contacts, payments, and so on), paging through the results and sending the right &lt;code&gt;Xero-tenant-id&lt;/code&gt; header on every call.&lt;/li&gt;
&lt;li&gt;Map each object shape into your own tables, and keep refreshing tokens so the connection never goes stale.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In code, the loop you end up owning looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The moving parts you operate yourself, on every connected organisation, forever&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;refreshIfExpired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 30-minute access tokens&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pageThrough&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/Invoices&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 100 per page&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;upsertIntoPostgres&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// your mapping, your schema&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// plus: refresh-token rotation, 429 backoff, per-tenant scheduling, and schema drift&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That snippet hides a lot. The OAuth handshake, the tenant enumeration, the pagination, and the incremental fetching are each a small project of their own. The point here is narrower: what does that integration cost you after it ships?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hidden Costs of Rolling Your Own
&lt;/h2&gt;

&lt;p&gt;The first version is the fun part. The cost shows up later, in the things you have to keep running:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OAuth tokens you babysit forever.&lt;/strong&gt; &lt;a href="https://developer.xero.com/documentation/guides/oauth2/token-types" rel="noopener noreferrer"&gt;Access tokens last 30 minutes&lt;/a&gt;, so you are refreshing constantly. The refresh token has a rolling 60-day life, but it is single-use: every refresh returns a brand new refresh token and invalidates the old one, so you must persist the newest value every single time. Store the wrong one and you get a dead connection that only a manual reconnect can fix. Let a connection sit idle past 60 days and it expires too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limits you engineer around.&lt;/strong&gt; Xero throttles &lt;a href="https://developer.xero.com/documentation/guides/oauth2/limits/" rel="noopener noreferrer"&gt;each connected organisation&lt;/a&gt; to 60 calls per minute, with only 5 requests allowed in flight at once and a further daily cap, on top of a 10,000-per-minute ceiling across your whole app. Cross a line and you get an HTTP 429 with a &lt;code&gt;Retry-After&lt;/code&gt; header, then you back off and retry. With one client that is trivial. Across dozens of connected organisations, throttling becomes its own scheduling problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;List calls hide the detail you actually need.&lt;/strong&gt; Ask Xero for a list of invoices and it returns a trimmed summary with no line items, to keep the response fast. To get the full record you either fetch each invoice individually or page through with the &lt;code&gt;pageSize&lt;/code&gt; parameter (100 per page by default, up to 1000). So a single "get my invoices" is rarely one call, it's many.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incremental sync that quietly misses changes.&lt;/strong&gt; The efficient way to pull only what changed is the &lt;code&gt;If-Modified-Since&lt;/code&gt; header, which filters on each record's &lt;code&gt;UpdatedDateUTC&lt;/code&gt;. The catch is that some changes never bump that timestamp, including a contact's outstanding balance and its customer or supplier flags. A naive incremental sync slowly drifts out of date, so you still need a periodic full reconciliation to stay honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhooks cover almost nothing.&lt;/strong&gt; If you were hoping to skip polling, Xero webhooks only fire for four things: contacts, invoices, credit notes, and Xero App Store subscriptions. There is nothing for accounts, payments, bank transactions, items, or journals. They emit create and update events only (a deletion or void arrives, if at all, as an update), and the payload carries just a reference, the resource id and URL, not the data itself, so you still call the API to fetch the actual record. Undelivered events are retried and stored for around 31 days, which means idempotency and replay handling are yours to build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema mapping across many objects, and it moves.&lt;/strong&gt; Invoices, contacts, accounts, bank transactions, credit notes, items, purchase orders, and journals each carry their own shape and their own relationships. You map every one of them into tables, and when Xero changes a payload, you update the mapping.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Database Sync Works
&lt;/h2&gt;

&lt;p&gt;Database sync takes the opposite approach. Instead of you operating a pipeline against the Xero API, a managed service does the pulling and writes the results straight into your PostgreSQL database.&lt;/p&gt;

&lt;p&gt;The flow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Connect your PostgreSQL database (Supabase, Neon, Railway, AWS RDS, or any PostgreSQL host)&lt;/li&gt;
&lt;li&gt;Authorize Xero once through the standard consent flow, and pick which organisation to sync&lt;/li&gt;
&lt;li&gt;The sync service calls the Xero API for you, pages through the data, and writes structured tables&lt;/li&gt;
&lt;li&gt;On every later run it pulls what changed and upserts, so there are no duplicates and no gaps&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is no Xero app to register, no refresh-token rotation to schedule, no 429 backoff to code, and no tenant headers to juggle. The accounting data simply shows up in your database, ready to query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side-by-Side Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Build It Yourself (Xero API)&lt;/th&gt;
&lt;th&gt;Managed Database Sync&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Time to first data&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Days to weeks: app, OAuth, tenants, paging, schema&lt;/td&gt;
&lt;td&gt;About 5 minutes: connect database, authorize&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OAuth token lifecycle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yours to run: 30-min access, 60-day rolling single-use refresh&lt;/td&gt;
&lt;td&gt;Handled for you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Multi-organisation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You enumerate tenants and route every call&lt;/td&gt;
&lt;td&gt;Pick an organisation and sync it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rate-limit handling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You build it: 60/min per org, 5 concurrent, 429 backoff&lt;/td&gt;
&lt;td&gt;Managed by the service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Historical backfill&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You write paginated backfill&lt;/td&gt;
&lt;td&gt;Full backfill on first sync&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Incremental updates&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You track &lt;code&gt;UpdatedDateUTC&lt;/code&gt; and its blind spots&lt;/td&gt;
&lt;td&gt;Pulls changes and upserts every run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Schema mapping&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You map every object and track payload changes&lt;/td&gt;
&lt;td&gt;Tables auto-created and maintained&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data freshness&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;On-demand; near real-time for the 4 webhook objects&lt;/td&gt;
&lt;td&gt;Batch (scheduled, e.g. hourly or daily)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Control &amp;amp; customization&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full: fields, transforms, write-back&lt;/td&gt;
&lt;td&gt;Standard tables, less custom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total cost of ownership&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Build plus perpetual maintenance&lt;/td&gt;
&lt;td&gt;Predictable flat subscription&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When to Build It Yourself
&lt;/h2&gt;

&lt;p&gt;Rolling your own is the right call in a few real cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need write-back.&lt;/strong&gt; Sync tools are read-only by design. If you have to create or update records inside Xero, like raising invoices or adding contacts, you need the API directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need real-time reaction&lt;/strong&gt; to one of the few events Xero actually pushes, such as a new invoice or contact, rather than a scheduled batch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You have unusual object or field requirements&lt;/strong&gt; that a standard sync does not cover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You already run pipeline infrastructure,&lt;/strong&gt; so the marginal cost of one more integration is genuinely low.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want full control&lt;/strong&gt; over every transform and mapping, and you are willing to maintain it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If that sounds like you, build it directly against the API and budget for the upkeep above. The control is real, and so is the maintenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use Managed Sync
&lt;/h2&gt;

&lt;p&gt;Managed sync is the right call when you mainly want to query the data, not operate a pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build dashboards and reporting&lt;/strong&gt; on revenue, cash flow, or accounts payable and receivable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run ad-hoc SQL&lt;/strong&gt; against your accounting data, like "which suppliers are we due to pay this week?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Join Xero data with your own tables,&lt;/strong&gt; matching invoices or contacts to your app's users&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep maintenance near zero,&lt;/strong&gt; with no token rotation, rate-limit code, or reconciliation job to own&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pay a predictable flat cost&lt;/strong&gt; instead of perpetual engineer time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once your data is in Postgres, a question like "what are we owed, and what do we owe?" is just SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Net position from xero_invoices: sales owed to you vs bills you owe&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                     &lt;span class="c1"&gt;-- ACCREC = sales invoices, ACCPAY = bills&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;amount_due&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;open_invoices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_due&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;outstanding&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;xero_invoices&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'AUTHORISED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SUBMITTED'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Try doing that against the Xero API directly. You would need paginated calls per organisation, client-side filtering, and careful rate-limit handling. With synced data, it's one query. If you would rather not build any of the pipeline, here is &lt;a href="https://codelesssync.com/blog/how-to-sync-xero-data-to-postgresql" rel="noopener noreferrer"&gt;how to sync Xero data to PostgreSQL automatically&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use Both
&lt;/h2&gt;

&lt;p&gt;These two approaches are not mutually exclusive, and the strongest setups use both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Xero API (or a webhook)&lt;/strong&gt; for the few moments you need to react instantly, like flagging a large invoice the second it's approved&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed sync&lt;/strong&gt; for the queryable, reconciled copy your team runs reports against&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your app reacts to the events that genuinely need real-time handling, while your team runs any query it likes against the synced database. The common mistake is building and maintaining a whole custom integration just to power dashboards that never needed sub-minute freshness, then paying for that decision in token refreshes, 429 retries, and reconciliation jobs for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started with Database Sync
&lt;/h2&gt;

&lt;p&gt;If you'd rather not build and babysit all of that, &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; connects your PostgreSQL database and syncs Xero data, contacts, invoices, payments, accounts, bank transactions, credit notes, items, purchase orders, journals, and organisation details, in about 5 minutes. It handles the OAuth consent and token refresh for you, lets you pick which organisation to sync, auto-creates the destination tables, and upserts on every run so there are no duplicates. There's a free tier, no credit card required.&lt;/p&gt;

&lt;p&gt;The same model works for &lt;a href="https://codelesssync.com/pricing" rel="noopener noreferrer"&gt;Stripe, QuickBooks, and Paddle&lt;/a&gt; too, so if your finances span more than one provider, all of it lands in the same Postgres database. You can point it at &lt;a href="https://codelesssync.com/xero-to-supabase" rel="noopener noreferrer"&gt;Supabase&lt;/a&gt;, &lt;a href="https://codelesssync.com/xero-to-postgresql" rel="noopener noreferrer"&gt;Neon, Railway, or AWS RDS&lt;/a&gt;, whichever host you already use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should I build my own Xero integration or use a sync tool?
&lt;/h3&gt;

&lt;p&gt;Build it if you need to write data back into Xero, want real-time reaction to specific events, or already run pipeline infrastructure. Use a sync tool if you mainly need a queryable copy of your accounting data for dashboards, reporting, or reconciliation, and you would rather not own OAuth token rotation, tenant routing, and rate-limit handling. Plenty of teams do both: the API for the few real-time events, managed sync for everything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does it actually cost to maintain a Xero API integration?
&lt;/h3&gt;

&lt;p&gt;The code is a one-time build. The cost is everything after: rotating the single-use refresh token on every refresh before the 60-day window lapses, backing off when you hit the 60-calls-per-minute or 5-concurrent limit on each organisation, running a periodic reconciliation for changes that don't bump &lt;code&gt;UpdatedDateUTC&lt;/code&gt;, and updating your mapping when Xero changes a payload. That is recurring engineer time, which is what a flat-rate managed sync is really replacing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a managed Xero sync still use the Xero API?
&lt;/h3&gt;

&lt;p&gt;Yes. A sync tool like &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; talks to the same OAuth 2.0 and Accounting API endpoints under the hood. The difference is that it operates the consent flow, the tenant selection, the pagination, and the schema mapping for you, so what you end up with is a set of PostgreSQL tables to query rather than a pipeline to maintain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can Xero webhooks replace a scheduled sync?
&lt;/h3&gt;

&lt;p&gt;Not for analytics. Xero webhooks only fire for contacts, invoices, credit notes, and App Store subscriptions, emit create and update events only (no deletes), and carry just a reference rather than the record itself, so you still call the API to fetch the data. For a complete, queryable copy of your accounting data, a scheduled sync is more reliable and needs no public endpoint. Use webhooks only for the specific events you must react to the instant they happen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which Xero data can I sync to my database?
&lt;/h3&gt;

&lt;p&gt;Codeless Sync supports ten Xero data types: contacts, invoices, payments, accounts, bank transactions, credit notes, items, purchase orders, journals, and organisation details, each written to its own PostgreSQL table. After the first full pull, later runs are incremental, so each sync only brings over what changed.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/stripe-webhooks-vs-database-sync" rel="noopener noreferrer"&gt;Stripe Webhooks vs Database Sync: Which is Better?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/paddle-webhooks-vs-database-sync" rel="noopener noreferrer"&gt;Paddle Webhooks vs Database Sync: Which is Better?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/quickbooks-api-vs-database-sync" rel="noopener noreferrer"&gt;QuickBooks API vs Database Sync: Which is Better?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/how-to-sync-xero-data-to-postgresql" rel="noopener noreferrer"&gt;How to Sync Xero to PostgreSQL Automatically in 5 Minutes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>api</category>
      <category>xero</category>
    </item>
  </channel>
</rss>
