<?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>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>
    <item>
      <title>QuickBooks API vs Database Sync: Which is Better?</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Tue, 30 Jun 2026 11:30:30 +0000</pubDate>
      <link>https://dev.to/ilshadyx/quickbooks-api-vs-database-sync-which-is-better-4bbh</link>
      <guid>https://dev.to/ilshadyx/quickbooks-api-vs-database-sync-which-is-better-4bbh</guid>
      <description>&lt;p&gt;&lt;em&gt;QuickBooks API vs database sync for getting accounting data into PostgreSQL: a build-vs-buy guide covering OAuth, rate limits, and the real maintenance cost.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;By Ilshaad Kheerdali · 30 June 2026&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;If you're building on top of QuickBooks Online, 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 QuickBooks 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 QuickBooks API under the hood too. The real question is build versus buy: do you own the OAuth tokens, the polling, the rate-limit handling, 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 also want CSV, IIF, and Zapier in the mix, this other post &lt;a href="https://codelesssync.com/blog/how-to-export-quickbooks-data-to-database" rel="noopener noreferrer"&gt;compares five ways to export QuickBooks data&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  How a QuickBooks API Integration Works
&lt;/h2&gt;

&lt;p&gt;Building your own integration means going directly to Intuit'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 Intuit Developer portal and get your client credentials.&lt;/li&gt;
&lt;li&gt;Send each customer through the OAuth 2.0 consent flow, then store the access and refresh tokens against their company (Intuit calls the company a &lt;code&gt;realmId&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Poll the API for the entities you care about (customers, invoices, payments, and so on), or subscribe to webhooks for change events.&lt;/li&gt;
&lt;li&gt;Map roughly 30 different entity shapes 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 company, 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;company&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;realmId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 1-hour access tokens&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;changes&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;pollQuickBooks&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="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invoice&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;Customer&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;Payment&lt;/span&gt;&lt;span class="dl"&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;entity&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;changes&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;entity&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: token rotation, 429 backoff, missed-event recovery, and schema drift&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That snippet hides a lot. The full version, the OAuth handshake, pagination, Change Data Capture, and webhook verification, is a project in itself. We wrote the whole thing up in the &lt;a href="https://codelesssync.com/blog/quickbooks-api-integration-guide" rel="noopener noreferrer"&gt;QuickBooks API integration guide&lt;/a&gt; if you want the line-by-line version. 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; Access tokens last one hour, so you are refreshing them constantly. The refresh token lasts 100 days, but it rotates roughly every day, and if you ever store the wrong one you get an &lt;code&gt;invalid_grant&lt;/code&gt; and a dead connection. Intuit also &lt;a href="https://blogs.intuit.com/2025/11/12/important-changes-to-refresh-token-policy" rel="noopener noreferrer"&gt;added a hard five-year cap on refresh tokens&lt;/a&gt; in late 2025, so even a flawlessly maintained connection eventually forces the customer to reconnect by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limits you engineer around.&lt;/strong&gt; QuickBooks throttles each company to 500 requests per minute and 10 requests per second. Cross either line and you get an HTTP 429 with a &lt;code&gt;ThrottleExceeded&lt;/code&gt; error, then you back off and retry. With one client that is trivial. Across dozens of connected companies, throttling becomes its own scheduling problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhooks that aren't guaranteed.&lt;/strong&gt; QuickBooks can push change events, but Intuit is clear that delivery is best-effort. Their own recommended fix for missed events is to run a scheduled Change Data Capture (CDC) reconciliation job on the side. So the "real-time" path still needs a polling backstop before you can trust it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A reconciliation window with hard edges.&lt;/strong&gt; That CDC backstop only looks back 30 days, and returns at most 1,000 objects per response. If your sync is down longer than a month, CDC cannot fill the gap, and you are back to a full re-pull. You own that recovery logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema mapping across ~30 entities, and it moves.&lt;/strong&gt; Every entity has its own payload shape to map, and the target shifts under you. Intuit is migrating the webhook payload to a new CloudEvents format with a July 31, 2026 cutover deadline, so the mapping you wrote last year is not necessarily the mapping you ship next year.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ordering and duplicates are your problem.&lt;/strong&gt; A single notification can carry events for several companies at once, and those events can arrive out of order or more than once. The payload timestamp is the only source of truth, so the idempotency and per-company ordering logic is yours to build and test.&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 QuickBooks 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 QuickBooks once through the standard Connect flow&lt;/li&gt;
&lt;li&gt;The sync service calls the QuickBooks API and CDC for you, 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 Intuit app to register, no token rotation to schedule, no 429 backoff to code, and no CDC window to manage. 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 (QuickBooks 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, polling, 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: 1h access, 100-day rolling refresh, 5-year cap&lt;/td&gt;
&lt;td&gt;Handled for you&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: 500/min, 10/sec, 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;Missed-event recovery&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You build a CDC job: 30-day window, gaps beyond&lt;/td&gt;
&lt;td&gt;Re-pulls 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 ~30 entities 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 possible&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 QuickBooks, you need the API directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need on-demand or real-time freshness&lt;/strong&gt; for specific events, not a scheduled batch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You have unusual entity 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, accounts receivable, or cash flow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run ad-hoc SQL&lt;/strong&gt; against your accounting data, like "which customers owe us more than £5,000 right now?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Join QuickBooks data with your own tables,&lt;/strong&gt; matching invoices to your app's users or orders&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep maintenance near zero,&lt;/strong&gt; with no token rotation, rate-limit code, or CDC 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 "how much am I owed, and how overdue is it?" 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;-- Outstanding invoices from quickbooks_invoices, aged into buckets&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;CASE&lt;/span&gt;
    &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="k"&gt;CURRENT_DATE&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;due_date&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'Not yet due'&lt;/span&gt;
    &lt;span class="k"&gt;WHEN&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;due_date&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'1-30 days overdue'&lt;/span&gt;
    &lt;span class="k"&gt;WHEN&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;due_date&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'31-60 days overdue'&lt;/span&gt;
    &lt;span class="k"&gt;WHEN&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;due_date&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'61-90 days overdue'&lt;/span&gt;
    &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'90+ days overdue'&lt;/span&gt;
  &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;age_bucket&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="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;balance&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;quickbooks_invoices&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;balance&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="n"&gt;age_bucket&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;Try doing that against the QuickBooks API directly. You would need paginated calls, 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-quickbooks-data-to-postgresql" rel="noopener noreferrer"&gt;how to sync QuickBooks 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 QuickBooks API (or webhooks)&lt;/strong&gt; for the few moments you need to react instantly, like flagging a payment the second it lands&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 CDC 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 QuickBooks data, customers, invoices, payments, items, accounts, vendors, and bills, in about 5 minutes. It handles the OAuth and token refresh for you, auto-creates the destination tables, upserts on every run so there are no duplicates, and recovers from gaps on the next scheduled sync. There's a free tier, no credit card required.&lt;/p&gt;

&lt;p&gt;The same model works for Stripe, Paddle, and Xero too, so if your finances span more than one provider, all of it lands in the same Postgres database.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Should I build my own QuickBooks integration or use a sync tool?
&lt;/h3&gt;

&lt;p&gt;Build it if you need to write data back into QuickBooks, 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 accounting, and you would rather not own OAuth token rotation, rate-limit handling, and missed-event recovery. 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 QuickBooks API integration?
&lt;/h3&gt;

&lt;p&gt;The code is a one-time build. The cost is everything after: refreshing tokens before they rotate or hit the new five-year cap, backing off when you hit the 500-requests-per-minute limit, running a Change Data Capture job to catch missed webhook events, and updating your payload mapping when Intuit changes the format, as with the CloudEvents migration. 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 QuickBooks sync still use the QuickBooks 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 Intuit API and CDC endpoints under the hood. The difference is that it operates the OAuth flow, the polling, 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 I move from a custom integration to managed sync without losing data?
&lt;/h3&gt;

&lt;p&gt;Yes. A sync upserts into a PostgreSQL database that you own and control, so there is no lock-in. You can run your existing integration and a managed sync side by side during a transition, compare the tables, and cut over only once you are happy the synced data matches.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a managed sync real-time, or will my QuickBooks data be stale?
&lt;/h3&gt;

&lt;p&gt;Managed sync is batch based, on a schedule such as hourly or daily, which is plenty for analytics, reporting, and the accounting close. If you genuinely need to react the instant something happens in QuickBooks, pair the sync with a webhook for just those specific events, and let the scheduled sync keep the full queryable copy current.&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-integration-guide" rel="noopener noreferrer"&gt;QuickBooks API Integration Guide for Developers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/how-to-sync-quickbooks-data-to-postgresql" rel="noopener noreferrer"&gt;How to Sync QuickBooks Data to PostgreSQL Automatically&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>api</category>
      <category>quickbooks</category>
    </item>
    <item>
      <title>How to Validate and Secure Your Stripe API Keys</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Sat, 27 Jun 2026 10:41:50 +0000</pubDate>
      <link>https://dev.to/ilshadyx/how-to-validate-and-secure-your-stripe-api-keys-385d</link>
      <guid>https://dev.to/ilshadyx/how-to-validate-and-secure-your-stripe-api-keys-385d</guid>
      <description>&lt;p&gt;&lt;em&gt;Validate and secure your Stripe API keys: key types and prefixes, restricted least-privilege keys, safe storage, key rotation, and what to do after a leak.&lt;/em&gt;&lt;/p&gt;

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




&lt;p&gt;A leaked Stripe secret key is not a small mistake. Anyone holding your &lt;code&gt;sk_live_&lt;/code&gt; key can create charges, issue refunds, read your entire customer list, and trigger payouts to themselves. That is full control of the money side of your business, handed over in a single string.&lt;/p&gt;

&lt;p&gt;The frustrating part is that almost none of these leaks come from clever attacks. They come from sloppy handling: a key pasted into a frontend bundle, committed to a public repo, dropped into a Slack thread, or baked into a screenshot in a bug report. Bad actors run automated scanners against public repositories around the clock, so a key that hits GitHub can be abused within minutes.&lt;/p&gt;

&lt;p&gt;This post walks through how to recognise each Stripe credential type, validate a key before you trust it, store it safely, and rotate it without downtime. If you want to sanity-check a key right now, paste it into the free &lt;a href="https://codelesssync.com/tools/stripe-api-key-validator" rel="noopener noreferrer"&gt;Stripe API Key Validator&lt;/a&gt;. It runs entirely in your browser and never sends the key anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understanding Stripe API key types and prefixes
&lt;/h2&gt;

&lt;p&gt;Stripe uses the key prefix to encode both what a credential is and which mode it belongs to. Get fluent in reading prefixes and most key mistakes disappear.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prefix&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Safe client-side?&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pk_test_&lt;/code&gt; / &lt;code&gt;pk_live_&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Publishable&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Identifies your account in browser/mobile code (Stripe.js, Elements)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;sk_test_&lt;/code&gt; / &lt;code&gt;sk_live_&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Secret&lt;/td&gt;
&lt;td&gt;No, server only&lt;/td&gt;
&lt;td&gt;Full access to your account via the API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;rk_test_&lt;/code&gt; / &lt;code&gt;rk_live_&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Restricted&lt;/td&gt;
&lt;td&gt;No, server only&lt;/td&gt;
&lt;td&gt;Scoped access limited to the permissions you grant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;whsec_&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Webhook signing secret&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Verifies webhook events came from Stripe, not an API key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sk_org_&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Organization key&lt;/td&gt;
&lt;td&gt;No, server only&lt;/td&gt;
&lt;td&gt;Organization-level access across multiple Stripe accounts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The middle substring is the mode switch. &lt;code&gt;_test_&lt;/code&gt; keys operate on sandbox data and &lt;code&gt;_live_&lt;/code&gt; keys operate on real data, and objects never cross between modes. A test key pointed at live data will silently return empty results rather than throw a loud error, which is why a "my sync returns nothing" bug is so often just a test/live mismatch.&lt;/p&gt;

&lt;p&gt;Stripe is blunt about exposure in its &lt;a href="https://docs.stripe.com/keys" rel="noopener noreferrer"&gt;official key documentation&lt;/a&gt;: "Only publishable keys are safe to expose outside your application's backend. You're responsible for protecting other Stripe API keys, including restricted API keys." Publishable keys are designed to be visible. Everything else belongs server-side.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to validate and secure your Stripe API keys
&lt;/h2&gt;

&lt;p&gt;Before you wire a key into config or paste it into a third-party tool, confirm it is actually the key you think it is. There are three quick checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Prefix.&lt;/strong&gt; Does it start with the type you intended? If you meant to grant read-only access but the string starts with &lt;code&gt;sk_live_&lt;/code&gt;, you are about to hand over full account control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mode.&lt;/strong&gt; Is it &lt;code&gt;_test_&lt;/code&gt; or &lt;code&gt;_live_&lt;/code&gt;? Make sure it matches the environment you are configuring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Character set and length.&lt;/strong&gt; Stripe keys are a fixed alphabet with an expected length. A truncated copy-paste or a stray whitespace character is a common reason a "correct" key fails auth.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Doing this by eye is error-prone, especially when keys are masked in dashboards. The &lt;a href="https://codelesssync.com/tools/stripe-api-key-validator" rel="noopener noreferrer"&gt;Stripe API Key Validator&lt;/a&gt; does all three at once: it detects the key type and mode from the prefix, validates the character set and length, masks the value for safe display with a visibility toggle, and prints security guidance specific to that key type. Crucially it is 100% client-side with zero network requests, so even a live secret key never leaves your browser. That makes it safe to use on a real key, unlike a random "paste your key here" web form you should never trust.&lt;/p&gt;

&lt;p&gt;To secure your Stripe API keys properly, validation is step one. The rest of this post covers the handling rules that keep a valid key from becoming a liability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the webhook signing secret (whsec_) is not an API key
&lt;/h2&gt;

&lt;p&gt;This trips up a lot of developers. The &lt;code&gt;whsec_&lt;/code&gt; value looks like a key, so people try to authenticate API requests with it and get nothing but auth errors.&lt;/p&gt;

&lt;p&gt;The webhook signing secret is not a credential for calling Stripe. Stripe generates a unique secret for each webhook endpoint, and you use it to verify that incoming events genuinely came from Stripe rather than a spoofed request. Your handler reads the &lt;code&gt;Stripe-Signature&lt;/code&gt; header and runs it through &lt;code&gt;constructEvent&lt;/code&gt; (or &lt;code&gt;Webhook.construct_event&lt;/code&gt;) along with the secret. Signature scheme v1 is used, and Stripe's libraries enforce a default timestamp tolerance of 5 minutes to block replay attacks.&lt;/p&gt;

&lt;p&gt;Two things ruin webhook verification in practice. First, you must verify against the &lt;strong&gt;raw request body&lt;/strong&gt;, not a parsed and re-serialized JSON object, or the signature will never match. Second, the secret is per-endpoint: the secret printed by the Stripe CLI is different from the one shown for a Dashboard endpoint, so using the wrong one fails every time. See the &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;Stripe webhooks documentation&lt;/a&gt; for the exact verification flow in your language.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restricted keys and the principle of least privilege
&lt;/h2&gt;

&lt;p&gt;If your code only needs to read data, never give it a key that can move money. A restricted API key (RAK) starts with &lt;code&gt;rk_live_&lt;/code&gt; or &lt;code&gt;rk_test_&lt;/code&gt; and, in Stripe's words, "can do only what you give it permission to do." When you create one in the Dashboard you set a permission of Read, Write, or None per Stripe resource. Note that write implies read: any key that can write a resource can also read it.&lt;/p&gt;

&lt;p&gt;This is the principle of least privilege in action, where a key should have the minimum permissions necessary to do its job and no more. Stripe's own example is sharp: a restricted key scoped to read dispute data only lets a bad actor read dispute data. They cannot create charges, touch customer payment methods, or trigger payouts. The blast radius of a leak shrinks to almost nothing.&lt;/p&gt;

&lt;p&gt;Stripe recommends giving each service its own restricted key (billing, reporting, and your webhook handler each get a separate scoped key) and recommends always preferring restricted keys over unrestricted secret keys, especially when handing a key to an AI agent or any third-party integration. The full guidance lives in the &lt;a href="https://docs.stripe.com/keys/restricted-api-keys" rel="noopener noreferrer"&gt;restricted API keys docs&lt;/a&gt;. The takeaway: a single all-powerful &lt;code&gt;sk_live_&lt;/code&gt; shared across every integration is the worst possible pattern, and the easiest one to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storing keys safely: environment variables, secrets managers, and never committing to git
&lt;/h2&gt;

&lt;p&gt;Once you have the right key, where it lives matters as much as what it can do. Stripe's &lt;a href="https://docs.stripe.com/keys-best-practices" rel="noopener noreferrer"&gt;best practices guide&lt;/a&gt; lays out a clear hierarchy.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never put secret keys in source code.&lt;/strong&gt; Bad actors continuously scan public repositories for Stripe keys. And remember git history retains a key even after you delete it from the latest commit, so a "quick fix" that removes the line does nothing unless you rewrite history and rotate the key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never embed keys in client applications.&lt;/strong&gt; Use publishable keys client-side. A secret key in a frontend bundle or mobile app is a full-account compromise waiting to be discovered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store secrets in a vault.&lt;/strong&gt; AWS Secrets Manager, Google Cloud Secret Manager, Azure Key Vault, or HashiCorp Vault are the recommended homes. Environment variables are an acceptable fallback when a vault is not available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a guardrail at commit time.&lt;/strong&gt; Periodically audit your codebase for &lt;code&gt;sk_live_&lt;/code&gt; and &lt;code&gt;rk_live_&lt;/code&gt; patterns, and add a pre-commit hook that rejects any commit containing them. This catches the mistake before it ever reaches a remote.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A minimal example of reading a key from the environment rather than hard-coding it:&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;// Good: the key lives in the environment, never in the repo&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="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&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="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_RESTRICTED_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Bad: this string is now in your git history forever&lt;/span&gt;
&lt;span class="c1"&gt;// const stripe = require('stripe')('sk_live_51Hxxxx...');&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also restrict keys to stable IP addresses and monitor your API request logs to spot misuse early. Limiting where a key works and watching how it is used are both cheap insurance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotating and expiring Stripe keys without downtime
&lt;/h2&gt;

&lt;p&gt;Keys are not set-and-forget. Rotate them periodically, and rotate immediately whenever a team member with access leaves or a key has been pasted somewhere it should not be.&lt;/p&gt;

&lt;p&gt;Rotating a key in the Dashboard revokes it and generates a replacement that is ready to use immediately. The detail that saves you from an outage: both the old and new keys keep working for up to 7 days. That window lets you deploy the new key everywhere before the old one dies, so older deployments still holding the previous key do not suddenly break. If you need longer than 7 days, create a new key manually, migrate, then expire the old one. When you do cut over, you can choose Now to delete the old key instantly or schedule a future expiration.&lt;/p&gt;

&lt;p&gt;One quirk worth knowing: you can expire a secret or restricted key (after which you create a new one and update your code), but you cannot expire a publishable key. Publishable keys can only be rotated and replaced. Also, a key left unused for 180 or more days may have its access limited, which you can restore from the Dashboard.&lt;/p&gt;

&lt;p&gt;The mistake to avoid is a hard cutover that revokes the old key the instant you create the new one. Use the dual-key window instead and a rotation becomes a non-event.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do if your Stripe key is exposed
&lt;/h2&gt;

&lt;p&gt;Treat any exposure as a compromise, full stop. Stripe's guidance is unambiguous: if a restricted or secret key is exposed or compromised, rotate it immediately even if you are not sure anyone saw it. If you find a sensitive key somewhere it should not be, assume it has been seen.&lt;/p&gt;

&lt;p&gt;Concretely:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rotate the key now.&lt;/strong&gt; Do not wait to confirm misuse. Generate a replacement using the dual-key window and migrate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit your request logs.&lt;/strong&gt; Check Stripe's API logs for unexpected charges, refunds, or reads around the time of exposure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find the source.&lt;/strong&gt; A key in git history needs the history rewritten, not just a new commit. A key in a screenshot or chat needs that artefact removed too.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Stripe does proactively monitor for exposed keys and may deactivate one and notify you, but it explicitly does not guarantee it will catch every leak. Your own rotation and monitoring process is the real safety net.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop hand-rolling key management: let CLS handle it
&lt;/h2&gt;

&lt;p&gt;Here is where most of this gets easier in practice. If your reason for touching the Stripe API at all is to get your payments data into Postgres, you do not need a powerful secret key sitting in your own pipeline.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://codelesssync.com/" rel="noopener noreferrer"&gt;CLS&lt;/a&gt; syncs Stripe data into your PostgreSQL database (Supabase, Neon, AWS RDS, Railway, and more) and only ever needs &lt;strong&gt;read&lt;/strong&gt; access. The best-practice setup is exactly what this post recommends: create a restricted, read-only key scoped to the resources you want to sync, then connect that. CLS stores the credential encrypted at rest with AES-256, runs managed scheduled syncs so you are not writing and babysitting your own cron jobs, and auto-creates the tables for you. The walkthrough is in the &lt;a href="https://codelesssync.com/docs/guides/stripe-setup" rel="noopener noreferrer"&gt;Stripe setup guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So validate your key in the &lt;a href="https://codelesssync.com/tools/stripe-api-key-validator" rel="noopener noreferrer"&gt;Stripe API Key Validator&lt;/a&gt;, scope it down to read-only, and hand the minimal version to whatever consumes it. If that consumer is your analytics warehouse, see &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 a comparison of the &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&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What does a Stripe secret key look like?
&lt;/h3&gt;

&lt;p&gt;A Stripe secret key starts with &lt;code&gt;sk_test_&lt;/code&gt; in test mode or &lt;code&gt;sk_live_&lt;/code&gt; in live mode, followed by a long string of letters and numbers. It grants full access to your account, so it must stay server-side and never appear in client code or source control. You can confirm a key's type and mode by checking its prefix, or by pasting it into a client-side validator.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I expose my Stripe publishable key?
&lt;/h3&gt;

&lt;p&gt;Yes. Publishable keys (&lt;code&gt;pk_test_&lt;/code&gt; and &lt;code&gt;pk_live_&lt;/code&gt;) are the only Stripe credentials designed to be safe in browser and mobile code. They identify your account to Stripe.js and Elements but cannot read sensitive data or move money. Every other key type, including restricted keys, must be kept private.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I rotate a Stripe API key?
&lt;/h3&gt;

&lt;p&gt;Rotate it from the Stripe Dashboard, which revokes the old key and issues a replacement immediately. Both the old and new keys keep working for up to 7 days, so deploy the new key everywhere within that window before the old one expires. If you need more time, create a new key manually, migrate, then expire the old one.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the whsec_ webhook secret used for?
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;whsec_&lt;/code&gt; value verifies that incoming webhook events genuinely came from Stripe. It is not an API key and cannot authenticate API requests. Your handler passes the raw request body, the &lt;code&gt;Stripe-Signature&lt;/code&gt; header, and this secret into a verification function to confirm the event is legitimate and not replayed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are restricted keys safer than secret keys?
&lt;/h3&gt;

&lt;p&gt;Yes, when scoped correctly. A restricted key (&lt;code&gt;rk_&lt;/code&gt;) only has the permissions you grant it, so a leaked read-only key cannot create charges or trigger payouts. Stripe recommends giving each service its own restricted key and preferring restricted keys over unrestricted secret keys, especially for third-party integrations and AI agents.&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/best-tools-to-sync-stripe-data-to-a-database" rel="noopener noreferrer"&gt;Best Tools to Sync Stripe Data to a 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/tools/stripe-api-key-validator" rel="noopener noreferrer"&gt;Stripe API Key Validator (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>How to Use Cron Expressions for Scheduled Data Syncs</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Fri, 26 Jun 2026 10:51:39 +0000</pubDate>
      <link>https://dev.to/ilshadyx/how-to-use-cron-expressions-for-scheduled-data-syncs-5b4g</link>
      <guid>https://dev.to/ilshadyx/how-to-use-cron-expressions-for-scheduled-data-syncs-5b4g</guid>
      <description>&lt;p&gt;&lt;em&gt;Learn cron expressions for scheduled data syncs: the 5 fields, special characters, common sync schedules, the day-of-week OR trap, and timezone gotchas.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;By Ilshaad Kheerdali · 26 June 2026&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;You wanted one simple thing: keep a Postgres copy of your Stripe, QuickBooks, or Xero data fresh on a schedule. Someone on your team said "just use cron." Now you are staring at five asterisks in a terminal, unsure whether &lt;code&gt;0 9 * * 1-5&lt;/code&gt; means 9am your time or 9am somewhere else, and whether you just told a server to run a job every minute by accident.&lt;/p&gt;

&lt;p&gt;Cron is a genuinely good tool, and once the syntax clicks it stops being scary. This guide walks through the standard 5-field format, the special characters, real sync schedules you will actually use, and the handful of gotchas that quietly break jobs in production. If you want to skip ahead and just see an expression broken down with its next run times, paste it into the free &lt;a href="https://codelesssync.com/tools/cron-expression-generator" rel="noopener noreferrer"&gt;Cron Expression Generator&lt;/a&gt; while you read.&lt;/p&gt;

&lt;h2&gt;
  
  
  How cron expressions work for scheduled data syncs
&lt;/h2&gt;

&lt;p&gt;A standard cron expression is five space-separated fields that together describe &lt;em&gt;when&lt;/em&gt; a job should run. Read left to right, they are: minute, hour, day of month, month, and day of week. There is no seconds field in standard cron, which is the single most common source of confusion (more on that later).&lt;/p&gt;

&lt;p&gt;Building cron expressions for scheduled data syncs really comes down to answering one question per field: at which minutes, hours, days, months, and weekdays should this sync fire? The cron daemon checks the clock once a minute and runs any entry whose five fields all match the current time. The authoritative reference is the Linux &lt;a href="https://man7.org/linux/man-pages/man5/crontab.5.html" rel="noopener noreferrer"&gt;crontab(5) man page&lt;/a&gt;, which is worth a bookmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the five fields: minute, hour, day-of-month, month, day-of-week
&lt;/h2&gt;

&lt;p&gt;Here is the layout, with the valid range for each field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;* * * * *
| | | | |
| | | | +-- &lt;span class="n"&gt;day&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;week&lt;/span&gt;  (&lt;span class="m"&gt;0&lt;/span&gt;-&lt;span class="m"&gt;7&lt;/span&gt;, &lt;span class="n"&gt;both&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt; &lt;span class="n"&gt;are&lt;/span&gt; &lt;span class="n"&gt;Sunday&lt;/span&gt;; &lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;)
| | | +---- &lt;span class="n"&gt;month&lt;/span&gt;         (&lt;span class="m"&gt;1&lt;/span&gt;-&lt;span class="m"&gt;12&lt;/span&gt;, &lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;)
| | +------ &lt;span class="n"&gt;day&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;month&lt;/span&gt;  (&lt;span class="m"&gt;1&lt;/span&gt;-&lt;span class="m"&gt;31&lt;/span&gt;)
| +-------- &lt;span class="n"&gt;hour&lt;/span&gt;          (&lt;span class="m"&gt;0&lt;/span&gt;-&lt;span class="m"&gt;23&lt;/span&gt;)
+---------- &lt;span class="n"&gt;minute&lt;/span&gt;        (&lt;span class="m"&gt;0&lt;/span&gt;-&lt;span class="m"&gt;59&lt;/span&gt;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A couple of details matter. The day-of-week field treats both &lt;code&gt;0&lt;/code&gt; and &lt;code&gt;7&lt;/code&gt; as Sunday under the Vixie/Linux convention used by most Linux servers. Strict POSIX only defines &lt;code&gt;0-6&lt;/code&gt; with &lt;code&gt;0&lt;/code&gt; as Sunday and does not include &lt;code&gt;7&lt;/code&gt;, so do not assume &lt;code&gt;7=Sunday&lt;/code&gt; works everywhere. Month and day-of-week also accept three-letter names like &lt;code&gt;JAN&lt;/code&gt; or &lt;code&gt;MON&lt;/code&gt;, but numbers are more portable across schedulers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Special characters: asterisk, comma, hyphen, and the step operator
&lt;/h2&gt;

&lt;p&gt;Four characters do most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Asterisk (&lt;code&gt;*&lt;/code&gt;)&lt;/strong&gt; means "every value" for that field, literally first through last. &lt;code&gt;* * * * *&lt;/code&gt; runs every minute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hyphen (&lt;code&gt;-&lt;/code&gt;)&lt;/strong&gt; defines an inclusive range. &lt;code&gt;1-5&lt;/code&gt; in the day-of-week field is Monday through Friday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comma (&lt;code&gt;,&lt;/code&gt;)&lt;/strong&gt; defines a list. &lt;code&gt;1,3,5&lt;/code&gt; is Monday, Wednesday, Friday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slash (&lt;code&gt;/&lt;/code&gt;)&lt;/strong&gt; is the step operator. &lt;code&gt;*/15&lt;/code&gt; in the minute field means every 15 units, so minutes 0, 15, 30, and 45.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can combine them. &lt;code&gt;0,30 9-17 * * 1-5&lt;/code&gt; means at minute 0 and 30, during hours 9 through 17, Monday through Friday. Keep steps in the portable &lt;code&gt;*/n&lt;/code&gt; form; some implementations treat &lt;code&gt;0/15&lt;/code&gt; and &lt;code&gt;*/15&lt;/code&gt; differently, so stick with &lt;code&gt;*/n&lt;/code&gt; to avoid surprises.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cron expression examples for common sync schedules
&lt;/h2&gt;

&lt;p&gt;Most data-sync jobs fall into a few recurring shapes. Here are the ones you will reach for, each tied to a real use case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;*/&lt;span class="m"&gt;5&lt;/span&gt; * * * *      &lt;span class="n"&gt;Every&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;        &lt;span class="n"&gt;Near&lt;/span&gt;-&lt;span class="n"&gt;real&lt;/span&gt;-&lt;span class="n"&gt;time&lt;/span&gt; &lt;span class="n"&gt;mirror&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;fast&lt;/span&gt;-&lt;span class="n"&gt;moving&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;
&lt;span class="m"&gt;0&lt;/span&gt; * * * *        &lt;span class="n"&gt;Every&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; :&lt;span class="m"&gt;00&lt;/span&gt;       &lt;span class="n"&gt;Hourly&lt;/span&gt; &lt;span class="n"&gt;refresh&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="n"&gt;or&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt;
&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;9&lt;/span&gt; * * &lt;span class="m"&gt;1&lt;/span&gt;-&lt;span class="m"&gt;5&lt;/span&gt;      &lt;span class="m"&gt;09&lt;/span&gt;:&lt;span class="m"&gt;00&lt;/span&gt; &lt;span class="n"&gt;Mon&lt;/span&gt;-&lt;span class="n"&gt;Fri&lt;/span&gt;           &lt;span class="n"&gt;Business&lt;/span&gt;-&lt;span class="n"&gt;hours&lt;/span&gt;-&lt;span class="n"&gt;only&lt;/span&gt; &lt;span class="n"&gt;sync&lt;/span&gt; &lt;span class="n"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;reporting&lt;/span&gt; &lt;span class="n"&gt;DB&lt;/span&gt;
&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; * * *        &lt;span class="m"&gt;02&lt;/span&gt;:&lt;span class="m"&gt;00&lt;/span&gt; &lt;span class="n"&gt;every&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt;          &lt;span class="n"&gt;Nightly&lt;/span&gt; &lt;span class="n"&gt;off&lt;/span&gt;-&lt;span class="n"&gt;peak&lt;/span&gt; &lt;span class="n"&gt;full&lt;/span&gt; &lt;span class="n"&gt;sync&lt;/span&gt;
&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; * *        &lt;span class="n"&gt;Midnight&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;     &lt;span class="n"&gt;Monthly&lt;/span&gt; &lt;span class="n"&gt;reconciliation&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;
&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; * * &lt;span class="m"&gt;0&lt;/span&gt;        &lt;span class="n"&gt;Midnight&lt;/span&gt; &lt;span class="n"&gt;every&lt;/span&gt; &lt;span class="n"&gt;Sunday&lt;/span&gt;    &lt;span class="n"&gt;Weekly&lt;/span&gt; &lt;span class="n"&gt;rollup&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="n"&gt;Monday&lt;/span&gt; &lt;span class="n"&gt;standup&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few notes on intent. &lt;code&gt;*/5 * * * *&lt;/code&gt; is the workhorse for keeping a Postgres copy of Stripe or QuickBooks reasonably fresh without hammering the API. &lt;code&gt;0 2 * * *&lt;/code&gt; runs at 2am, which on a UTC server is genuinely off-peak for most US and EU traffic, making it ideal for a heavier full sync. If you want to confirm any of these or build your own, drop it into the &lt;a href="https://codelesssync.com/tools/cron-expression-generator" rel="noopener noreferrer"&gt;Cron Expression Generator&lt;/a&gt;: it parses the five fields, gives you a plain-English description, and lists the next five run times in UTC.&lt;/p&gt;

&lt;h2&gt;
  
  
  The day-of-month vs day-of-week OR rule that breaks schedules
&lt;/h2&gt;

&lt;p&gt;This is the gotcha that catches even experienced developers. Suppose you want "Friday the 13th" and write &lt;code&gt;0 0 13 * 5&lt;/code&gt;, expecting day-of-month 13 &lt;em&gt;and&lt;/em&gt; day-of-week Friday. It does not do that. The crontab(5) man page is explicit:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If both fields are restricted (i.e., do not contain the &lt;code&gt;*&lt;/code&gt; character), the command will be run when either field matches the current time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So &lt;code&gt;0 0 13 * 5&lt;/code&gt; actually runs at midnight on the 13th of &lt;em&gt;every&lt;/em&gt; month &lt;strong&gt;and&lt;/strong&gt; every Friday. POSIX confirms the same OR behaviour. The two day fields are combined with OR, not AND, whenever both are restricted. The practical fix: keep one of the two fields as &lt;code&gt;*&lt;/code&gt; and test the other condition inside your command, or use a scheduler that does AND matching. If your sync only needs to skip weekends, &lt;code&gt;0 2 * * 1-5&lt;/code&gt; is safe because the day-of-month field stays &lt;code&gt;*&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5 fields vs 6: why your cron expression has the wrong number of fields
&lt;/h2&gt;

&lt;p&gt;The other classic failure is field count. Standard cron has exactly 5 fields. Quartz Scheduler, used by a lot of Java and Spring apps, uses 6 or 7 fields: it adds a leading Seconds field (0-59) and an optional trailing Year (1970-2099). The &lt;a href="https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html" rel="noopener noreferrer"&gt;Quartz CronTrigger tutorial&lt;/a&gt; documents this format.&lt;/p&gt;

&lt;p&gt;The trap is copy-paste. If you lift a 6-field Quartz expression like &lt;code&gt;0 0 9 * * ?&lt;/code&gt; into a 5-field crontab, every field shifts one position to the left and you silently schedule the wrong time. Going the other direction is just as bad. Before pasting any expression, confirm whether the target system expects 5 fields or 6. The Cron Expression Generator validates standard 5-field cron only, so a 6-field expression will flag immediately rather than fail at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  @hourly, @daily, @weekly: cron macros that save typing
&lt;/h2&gt;

&lt;p&gt;Vixie cron, the implementation on most Linux boxes, supports nickname macros so you do not have to memorise the field layout for common cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;@&lt;span class="n"&gt;hourly&lt;/span&gt;   -&amp;gt;  &lt;span class="m"&gt;0&lt;/span&gt; * * * *
@&lt;span class="n"&gt;daily&lt;/span&gt;    -&amp;gt;  &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; * * *
@&lt;span class="n"&gt;weekly&lt;/span&gt;   -&amp;gt;  &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; * * &lt;span class="m"&gt;0&lt;/span&gt;
@&lt;span class="n"&gt;monthly&lt;/span&gt;  -&amp;gt;  &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; * *
@&lt;span class="n"&gt;yearly&lt;/span&gt;   -&amp;gt;  &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; *
@&lt;span class="n"&gt;reboot&lt;/span&gt;   -&amp;gt;  &lt;span class="n"&gt;runs&lt;/span&gt; &lt;span class="n"&gt;once&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;daemon&lt;/span&gt; &lt;span class="n"&gt;startup&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are a cron extension, not POSIX, so they will not exist on every scheduler. But on a standard Linux server &lt;code&gt;@daily&lt;/code&gt; and &lt;code&gt;0 0 * * *&lt;/code&gt; are identical, and the macro is harder to typo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cron, UTC, and daylight saving time pitfalls
&lt;/h2&gt;

&lt;p&gt;Cron evaluates schedules in the daemon's timezone, which is the system local time and is commonly UTC on servers. That means &lt;code&gt;0 9 * * *&lt;/code&gt; is 9am &lt;em&gt;in the server's timezone&lt;/em&gt;, not your wall clock. If your laptop is in London and your server is on UTC, those happen to line up in winter and drift by an hour in summer. Vixie/cronie supports a &lt;code&gt;CRON_TZ&lt;/code&gt; variable to pin a timezone for crontab entries if you really need local time.&lt;/p&gt;

&lt;p&gt;Daylight saving is where this gets nasty, and the exact behaviour depends on your scheduler. The cronie/Vixie cron on most Linux servers actually tries to compensate for shifts under three hours: at spring-forward, a fixed-time job whose hour is skipped is run immediately instead of being lost, and at fall-back, cron avoids running the same fixed-time job twice. Clock changes larger than three hours are treated as a correction and the new time is just adopted. The catch is that this only covers fixed-time entries on cronie. Other schedulers, container cron, and managed platforms each handle the transition differently, so you cannot assume the missed or doubled run is handled for you. The clean fix is to keep your servers on UTC and make your syncs idempotent, so a double-run or a skipped-run never corrupts your data. This is also why the generator tool reports its next run times in UTC: it removes the ambiguity rather than guessing your local offset.&lt;/p&gt;

&lt;h2&gt;
  
  
  From cron jobs to managed scheduled syncs with Codeless Sync
&lt;/h2&gt;

&lt;p&gt;Here is the honest part. Cron syntax is the easy bit. Running a reliable sync on cron means owning everything around it: a server to host the job, monitoring so you know it actually ran, retries when the provider API times out, overlap handling when a 5-minute schedule meets an 8-minute run, and alerting for the day it silently stops. That operational tail is exactly why &lt;a href="https://codelesssync.com/blog/why-stripe-postgresql-sync-keeps-breaking" rel="noopener noreferrer"&gt;a self-rolled Stripe to Postgres sync keeps breaking&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://codelesssync.com/" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; takes that whole layer off your plate. Instead of writing cron and babysitting a worker, you pick a frequency (Hourly, Daily, Weekly, or Monthly) plus a sync mode (full or incremental), and CLS runs and monitors the sync for Stripe, QuickBooks, Xero, and Paddle into your own Postgres database. There is no cron server to keep alive and no DST math to get wrong. See &lt;a href="https://codelesssync.com/docs/core-concepts/schedules" rel="noopener noreferrer"&gt;how schedules work in the docs&lt;/a&gt;, and if you still want to understand or hand-tune a raw expression for your own jobs, the &lt;a href="https://codelesssync.com/tools/cron-expression-generator" rel="noopener noreferrer"&gt;Cron Expression Generator&lt;/a&gt; is free and entirely client-side. When you are ready to stop maintaining pipelines, the &lt;a href="https://codelesssync.com/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; lays out the managed option.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What are the 5 fields in a cron expression?
&lt;/h3&gt;

&lt;p&gt;Standard cron uses five fields in this order: minute (0-59), hour (0-23), day of month (1-31), month (1-12), and day of week (0-7, where both 0 and 7 are Sunday). The daemon runs your command when all five fields match the current time. Month and day-of-week also accept names like &lt;code&gt;JAN&lt;/code&gt; or &lt;code&gt;MON&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does cron have a seconds field?
&lt;/h3&gt;

&lt;p&gt;No. Standard, POSIX, and Vixie cron all use exactly five fields with no seconds, so the smallest interval you can schedule is one minute. Schedulers like Quartz add a leading Seconds field and an optional Year, giving 6 or 7 fields. That difference is the most common reason a copied expression schedules the wrong time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my Friday the 13th cron job run on the wrong days?
&lt;/h3&gt;

&lt;p&gt;Because cron ORs the day-of-month and day-of-week fields when both are restricted. An expression like &lt;code&gt;0 0 13 * 5&lt;/code&gt; runs on the 13th of every month AND every Friday, not only on Friday the 13th. To require both conditions, keep one field as &lt;code&gt;*&lt;/code&gt; and test the other inside your command, or use a scheduler that does AND matching.&lt;/p&gt;

&lt;h3&gt;
  
  
  What timezone do cron jobs run in?
&lt;/h3&gt;

&lt;p&gt;Cron runs in the daemon's timezone, which is the system local time and is usually UTC on servers. So &lt;code&gt;0 9 * * *&lt;/code&gt; is 9am in that timezone, not necessarily your local 9am. Vixie/cronie supports &lt;code&gt;CRON_TZ&lt;/code&gt; to override per crontab, but keeping servers on UTC is the simplest way to avoid daylight saving bugs.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does */5 * * * * mean?
&lt;/h3&gt;

&lt;p&gt;It means "every 5 minutes." The &lt;code&gt;*/5&lt;/code&gt; in the minute field is the step operator, firing at minutes 0, 5, 10, and so on through 55, while the four &lt;code&gt;*&lt;/code&gt; fields match every hour, day, month, and weekday. It is the most common schedule for keeping a near-real-time copy of fast-moving data.&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/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/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/how-to-sync-quickbooks-data-to-postgresql" rel="noopener noreferrer"&gt;How to Sync QuickBooks Data to PostgreSQL Automatically&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/tools/cron-expression-generator" rel="noopener noreferrer"&gt;Cron Expression Generator (free tool)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>api</category>
      <category>software</category>
    </item>
    <item>
      <title>How to Fix a PostgreSQL Connection String That Won't Connect</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Thu, 25 Jun 2026 12:40:13 +0000</pubDate>
      <link>https://dev.to/ilshadyx/how-to-fix-a-postgresql-connection-string-that-wont-connect-1h8d</link>
      <guid>https://dev.to/ilshadyx/how-to-fix-a-postgresql-connection-string-that-wont-connect-1h8d</guid>
      <description>&lt;p&gt;You copied the connection string straight out of your provider's dashboard (Supabase, Neon, Railway, AWS RDS, DigitalOcean) and pasted it into your app or &lt;code&gt;psql&lt;/code&gt;, and it still won't connect. The string &lt;em&gt;looks&lt;/em&gt; right. It came from the source of truth. And yet the terminal throws &lt;code&gt;password authentication failed&lt;/code&gt;, or &lt;code&gt;could not connect to server: Connection refused&lt;/code&gt;, or some cryptic &lt;code&gt;Tenant or user not found&lt;/code&gt; you've never seen before.&lt;/p&gt;

&lt;p&gt;Here's the thing almost every tutorial gets wrong: it assumes you control the Postgres server and can edit &lt;code&gt;pg_hba.conf&lt;/code&gt;, restart the daemon, or &lt;code&gt;sudo -i -u postgres&lt;/code&gt;. If you're on a managed host, you can't touch any of that. The fix lives in the &lt;strong&gt;connection string itself&lt;/strong&gt;: the host, the port, the username format, the &lt;code&gt;sslmode&lt;/code&gt;, and how special characters in your password are encoded.&lt;/p&gt;

&lt;p&gt;This post is an error-message-indexed troubleshooter. Find the exact text your terminal printed, read the cause, apply the connection-string-level fix first. It pairs with our free &lt;a href="https://codelesssync.com/tools/postgresql-connection-string-validator" rel="noopener noreferrer"&gt;PostgreSQL Connection String Validator&lt;/a&gt;, which parses your string in the browser and flags these issues before you deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of a PostgreSQL connection string
&lt;/h2&gt;

&lt;p&gt;Almost every connection failure is a problem with one specific part of the URI. So before the error catalogue, here's the map. A standard PostgreSQL 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://user:password@host:port/database?sslmode=require
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per the &lt;a href="https://www.postgresql.org/docs/current/libpq-connect.html" rel="noopener noreferrer"&gt;official libpq documentation&lt;/a&gt;, the scheme can be either &lt;code&gt;postgresql://&lt;/code&gt; or &lt;code&gt;postgres://&lt;/code&gt;, and both are accepted and identical. Breaking it down:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scheme&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgresql://&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;postgres://&lt;/code&gt; works too&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgres&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;On Supabase poolers it must be &lt;code&gt;postgres.&amp;lt;project-ref&amp;gt;&lt;/code&gt; (more below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password&lt;/td&gt;
&lt;td&gt;&lt;code&gt;p%40ssw0rd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Special characters must be percent-encoded&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;&lt;code&gt;db.abc.supabase.co&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DNS name or IP; managed hosts often have pooler vs direct hostnames&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Port&lt;/td&gt;
&lt;td&gt;&lt;code&gt;5432&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Default is 5432, but &lt;strong&gt;DigitalOcean uses 25060, Supabase pooler 6543&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgres&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The database name, not the project name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query params&lt;/td&gt;
&lt;td&gt;&lt;code&gt;?sslmode=require&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;TLS mode, plus &lt;code&gt;pgbouncer=true&lt;/code&gt;, &lt;code&gt;channel_binding=require&lt;/code&gt;, etc.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;libpq is explicit that "the connection URI needs to be encoded with percent-encoding if it includes symbols with special meaning in any of its parts." That single rule causes a large share of "wrong password" errors that aren't actually wrong passwords. Keep this table handy, since every fix below maps back to one of these fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  The most common reasons your connection string won't connect
&lt;/h2&gt;

&lt;p&gt;A quick triage trick before the details: the error message tells you &lt;em&gt;which layer&lt;/em&gt; failed. &lt;code&gt;could not translate host name&lt;/code&gt; is DNS, before any TCP. &lt;code&gt;Connection refused&lt;/code&gt; means TCP reached the host but nothing is listening. &lt;code&gt;no pg_hba.conf entry&lt;/code&gt; and &lt;code&gt;password authentication failed&lt;/code&gt; mean you reached Postgres and it's talking to you, so it's auth/config, not network. &lt;code&gt;server does not support SSL&lt;/code&gt; is a TLS-layer mismatch. Matching the message to the layer saves hours. The six sections below cover the most frequent failures; the quick-reference table at the end adds one more (node-postgres self-signed certificates).&lt;/p&gt;

&lt;h3&gt;
  
  
  1. password authentication failed
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL: password authentication failed for user "andym"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; You reached the server and it's willing to talk, but it rejected your credentials. The obvious culprit is a wrong username or password. The non-obvious one, and the reason this error shows up even when you &lt;em&gt;know&lt;/em&gt; the password is right, is &lt;strong&gt;special characters in the password that aren't percent-encoded&lt;/strong&gt; inside the URI. The parser misreads or truncates the password at the first reserved character.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;#&lt;/code&gt; is especially nasty: in URI syntax it begins a fragment. A strict parser rejects the whole string (node-postgres throws &lt;code&gt;Invalid URL&lt;/code&gt;); a lax one drops everything after the &lt;code&gt;#&lt;/code&gt; from the password. Either way the password that reaches the server is wrong, with little hint why, so encode it as &lt;code&gt;%23&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Percent-encode the reserved characters in the password. The ones that actually break parsing are &lt;code&gt;@&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt;, &lt;code&gt;#&lt;/code&gt;, and &lt;code&gt;%&lt;/code&gt; (plus &lt;code&gt;:&lt;/code&gt;, which otherwise splits the username from the password); the others below are optional but harmless to encode:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Char&lt;/th&gt;
&lt;th&gt;Encoded&lt;/th&gt;
&lt;th&gt;Char&lt;/th&gt;
&lt;th&gt;Encoded&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;%40&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;%3F&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;%3A&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;%26&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;%2F&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;space&lt;/td&gt;
&lt;td&gt;&lt;code&gt;%20&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;#&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;%23&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;%24&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;%&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;%25&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;=&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;%3D&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;%5B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;%5D&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A password of &lt;code&gt;p@$$w0rd&lt;/code&gt; needs the &lt;code&gt;@&lt;/code&gt; encoded at minimum (so &lt;code&gt;p%40$$w0rd&lt;/code&gt; already connects, since &lt;code&gt;$&lt;/code&gt; is legal unencoded), and the fully encoded &lt;code&gt;p%40%24%24w0rd&lt;/code&gt; works just as well:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Broken: the second @ is read as the user/host separator, splitting the password
postgresql://postgres:p@$$w0rd@db.abc.supabase.co:5432/postgres

# Fixed: password percent-encoded
postgresql://postgres:p%40%24%24w0rd@db.abc.supabase.co:5432/postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When in doubt, encode the &lt;em&gt;whole&lt;/em&gt; password before pasting it. If this is the error you keep hitting, drop the string into the &lt;a href="https://codelesssync.com/tools/postgresql-connection-string-validator" rel="noopener noreferrer"&gt;connection string validator&lt;/a&gt;, which flags unencoded characters instantly.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. could not translate host name
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;could not translate host name "db.abc.supabase.co" to address: Name or service not known
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; DNS resolution failed: your OS couldn't turn the hostname into any usable IP address. Usually a typo in the host. But there's a managed-host trap: Supabase's &lt;strong&gt;direct&lt;/strong&gt; connection host (&lt;code&gt;db.&amp;lt;ref&amp;gt;.supabase.co&lt;/code&gt;) resolves only to IPv6 by default. On an IPv4-only network or runtime (most serverless platforms like Vercel, Lambda, and Cloudflare, plus plenty of corporate and home networks), no usable address comes back. Depending on your resolver, a pure IPv6 resolution may instead surface later as &lt;code&gt;Network is unreachable&lt;/code&gt;. Either way, the fix is the same. (The &lt;code&gt;Name or service not known&lt;/code&gt; suffix is the Linux/glibc wording; macOS prints &lt;code&gt;nodename nor servname provided, or not known&lt;/code&gt;, and Docker containers often print &lt;code&gt;Temporary failure in name resolution&lt;/code&gt; for the same DNS failure.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; If it's a typo, fix the host. For Supabase on IPv4-only networks, switch to a pooler hostname, which is IPv4 across all tiers, or buy the IPv4 add-on for the direct connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Direct (IPv6-only by default): fails on IPv4-only runtimes
postgresql://postgres:pw@db.abc.supabase.co:5432/postgres

# Session pooler (IPv4): note the postgres.&amp;lt;ref&amp;gt; username
postgresql://postgres.abc:pw@aws-0-eu-west-1.pooler.supabase.com:5432/postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Be careful with &lt;code&gt;nslookup&lt;/code&gt; here: a plain &lt;code&gt;nslookup db.abc.supabase.co&lt;/code&gt; (or &lt;code&gt;ping&lt;/code&gt;) will often return an IPv6 (AAAA) record and look perfectly healthy even though an IPv4-only stack can't use it. Check specifically for an A record (&lt;code&gt;nslookup -type=A db.abc.supabase.co&lt;/code&gt;), and treat "resolves to IPv6 only, no IPv4 answer" as the real signal. For an end-to-end test, &lt;code&gt;psql -h &amp;lt;host&amp;gt;&lt;/code&gt; from the same environment your app runs in. See the pooler section below for the full breakdown.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. no pg_hba.conf entry ... no encryption
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL: no pg_hba.conf entry for host "123.123.123.123", user "andym", database "testdb", no encryption
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; You reached the server, but no matching rule in &lt;code&gt;pg_hba.conf&lt;/code&gt; allows your connection. The trailing &lt;code&gt;no encryption&lt;/code&gt; describes &lt;em&gt;your&lt;/em&gt; side: your client connected in plaintext (that wording is PostgreSQL 12+; pre-12 said &lt;code&gt;SSL off&lt;/code&gt;). The usual reason no rule matches a plaintext attempt is that the rule that &lt;em&gt;would&lt;/em&gt; match is &lt;code&gt;hostssl&lt;/code&gt; (TLS-only), so the practical fix is almost always the same: turn on TLS. The exact trailing wording is server- and version-dependent, and the canonical doc example shows just the bare &lt;code&gt;no pg_hba.conf entry for host …, user …, database …&lt;/code&gt; line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Add &lt;code&gt;?sslmode=require&lt;/code&gt; to the connection string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Broken: no TLS, rejected by an SSL-enforcing host
postgresql://user:pw@host:5432/mydb

# Fixed: request TLS
postgresql://user:pw@host:5432/mydb?sslmode=require
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Managed hosts enforce SSL by default: &lt;a href="https://neon.com/docs/connect/connect-securely" rel="noopener noreferrer"&gt;Neon rejects all non-TLS connections&lt;/a&gt;, DigitalOcean always enforces SSL, and AWS RDS enforces it via the &lt;code&gt;rds.force_ssl&lt;/code&gt; parameter, whose default is version-dependent: &lt;code&gt;1&lt;/code&gt; (on) for RDS for PostgreSQL 15 and later, &lt;code&gt;0&lt;/code&gt; (off) for 14 and older. So a PostgreSQL 15+ instance on a default parameter group rejects plaintext out of the box, while 14-and-older accepts it until you set &lt;code&gt;rds.force_ssl=1&lt;/code&gt; yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. server does not support SSL, but SSL was required
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: server does not support SSL, but SSL was required
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; The mirror image of #3. Your client &lt;em&gt;demanded&lt;/em&gt; SSL (&lt;code&gt;sslmode=require&lt;/code&gt; or higher), but the server doesn't have it enabled. This is classic with a default local PostgreSQL build or a bare &lt;code&gt;docker postgres&lt;/code&gt; image that ships without &lt;code&gt;ssl=on&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; For a trusted local server, just connect without TLS by dropping &lt;code&gt;sslmode=require&lt;/code&gt; or setting &lt;code&gt;sslmode=disable&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Broken against a local server with no SSL configured
postgresql://postgres:pw@localhost:5432/mydb?sslmode=require

# Fixed: local, trusted network, no TLS
postgresql://postgres:pw@localhost:5432/mydb?sslmode=disable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it's not a local box, the real fix is to enable SSL on the server (&lt;code&gt;ssl=on&lt;/code&gt; plus a cert and key) rather than disabling it. Never use &lt;code&gt;sslmode=disable&lt;/code&gt; against a database reachable over the public internet.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. could not connect to server: Connection refused
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;could not connect to server: Connection refused
    Is the server running on host "host" and accepting
    TCP/IP connections on port 5432?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; Nothing is listening at that host and port; the TCP connection was actively rejected. The server is down, listening on a different port, not configured for TCP/IP, or a firewall / security group is blocking the port. On managed hosts, the single most common cause is &lt;strong&gt;the wrong port&lt;/strong&gt;. (This is the PostgreSQL 11-and-earlier wording, still emitted by many third-party drivers; modern libpq prints &lt;code&gt;connection to server at "host" (IP), port 5432 failed: Connection refused&lt;/code&gt; followed by &lt;code&gt;Is the server running on that host and accepting TCP/IP connections?&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Verify the port first, because managed providers don't all use 5432:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# DigitalOcean managed Postgres: port 25060, NOT 5432
postgresql://doadmin:pw@db.ondigitalocean.com:25060/defaultdb?sslmode=require

# Supabase transaction pooler: port 6543
postgresql://postgres.abc:pw@aws-0-eu-west-1.pooler.supabase.com:6543/postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then confirm the server is reachable with &lt;code&gt;pg_isready -h &amp;lt;host&amp;gt; -p &amp;lt;port&amp;gt;&lt;/code&gt;, and on AWS RDS or a self-hosted box, check the firewall / security group allows inbound on that port. If you're on a managed host, also check the project isn't paused: Supabase free-tier projects pause after inactivity and refuse connections until you resume them.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Tenant or user not found (Supabase pooler)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL: Tenant or user not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; You're connecting to the Supabase pooler (&lt;code&gt;aws-&amp;lt;region&amp;gt;.pooler.supabase.com&lt;/code&gt;) with the plain username &lt;code&gt;postgres&lt;/code&gt;. Supabase's pooler (Supavisor) parses your project reference &lt;em&gt;out of the username&lt;/em&gt; to route to the right tenant. Without it, there's no tenant to route to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Use the tenant-qualified username &lt;code&gt;postgres.&amp;lt;project-ref&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Broken: plain "postgres" against the pooler
postgresql://postgres:pw@aws-0-eu-west-1.pooler.supabase.com:6543/postgres

# Fixed: username is postgres.&amp;lt;project-ref&amp;gt;
postgresql://postgres.your-project-ref:pw@aws-0-eu-west-1.pooler.supabase.com:6543/postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://supabase.com/docs/guides/database/connecting-to-postgres" rel="noopener noreferrer"&gt;Supabase docs&lt;/a&gt; put it plainly: "If the username is &lt;code&gt;postgres&lt;/code&gt; the username you use for Supavisor is &lt;code&gt;postgres.[PROJECT_REF]&lt;/code&gt;." The plain &lt;code&gt;postgres&lt;/code&gt; username only works on the &lt;strong&gt;direct&lt;/strong&gt; connection (&lt;code&gt;db.&amp;lt;ref&amp;gt;.supabase.co&lt;/code&gt;), never the pooler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference: error → cause → fix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Error message&lt;/th&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Root cause&lt;/th&gt;
&lt;th&gt;Connection-string fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;password authentication failed for user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;Wrong creds, or unencoded special chars in password&lt;/td&gt;
&lt;td&gt;Percent-encode the password (&lt;code&gt;@&lt;/code&gt;→&lt;code&gt;%40&lt;/code&gt;, &lt;code&gt;#&lt;/code&gt;→&lt;code&gt;%23&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt;→&lt;code&gt;%2F&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;could not translate host name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DNS&lt;/td&gt;
&lt;td&gt;Typo, or Supabase IPv6 direct host on IPv4 network&lt;/td&gt;
&lt;td&gt;Fix host, or use the IPv4 pooler hostname&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no pg_hba.conf entry ... no encryption&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Auth/TLS&lt;/td&gt;
&lt;td&gt;Server requires SSL, client connected plaintext&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;?sslmode=require&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server does not support SSL, but SSL required&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;TLS&lt;/td&gt;
&lt;td&gt;Client demands SSL, server has none (local/Docker)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;?sslmode=disable&lt;/code&gt; for trusted local servers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Connection refused&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;Wrong port, server down, firewall, or paused project&lt;/td&gt;
&lt;td&gt;Fix the port (DO &lt;code&gt;25060&lt;/code&gt;, Supabase pooler &lt;code&gt;6543&lt;/code&gt;); check firewall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Tenant or user not found&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Auth/routing&lt;/td&gt;
&lt;td&gt;Plain &lt;code&gt;postgres&lt;/code&gt; user against Supabase pooler&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;postgres.&amp;lt;project-ref&amp;gt;&lt;/code&gt; as the username&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;self signed certificate in certificate chain&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;TLS&lt;/td&gt;
&lt;td&gt;node-postgres verifies CA strictly by default&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ssl: { rejectUnauthorized: false }&lt;/code&gt; or supply the CA&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Pooler vs direct connection (the Supabase gotcha)
&lt;/h2&gt;

&lt;p&gt;Supabase trips up more developers than any other host here, because it exposes three different endpoints for the same database, and they have different hostnames, ports, IP stacks, and username rules. Choosing the wrong one produces exactly the &lt;code&gt;could not translate host name&lt;/code&gt;, &lt;code&gt;Connection refused&lt;/code&gt;, and &lt;code&gt;Tenant or user not found&lt;/code&gt; errors above.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Hostname &amp;amp; port&lt;/th&gt;
&lt;th&gt;IP stack&lt;/th&gt;
&lt;th&gt;Username&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct&lt;/td&gt;
&lt;td&gt;&lt;code&gt;db.&amp;lt;ref&amp;gt;.supabase.co:5432&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;IPv6 (IPv4 add-on)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgres&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persistent VMs/containers, migrations, &lt;code&gt;pg_dump&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session pooler&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aws-&amp;lt;region&amp;gt;.pooler.supabase.com:5432&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;IPv4 (all tiers)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgres.&amp;lt;ref&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persistent backends on IPv4-only networks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transaction pooler&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aws-&amp;lt;region&amp;gt;.pooler.supabase.com:6543&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;IPv4 (all tiers)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgres.&amp;lt;ref&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Serverless / edge functions, many short-lived connections&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three rules that save you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The pooler username is always &lt;code&gt;postgres.&amp;lt;project-ref&amp;gt;&lt;/code&gt;&lt;/strong&gt;, never plain &lt;code&gt;postgres&lt;/code&gt;. The direct connection is the opposite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ports 5432 (session) and 6543 (transaction) share the same pooler host.&lt;/strong&gt; Connections are pooled across both. If you're serverless, use 6543.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The transaction pooler does not support prepared statements.&lt;/strong&gt; Per the &lt;a href="https://supabase.com/docs/guides/database/connecting-to-postgres" rel="noopener noreferrer"&gt;Supabase docs&lt;/a&gt;, Prisma needs &lt;code&gt;?pgbouncer=true&lt;/code&gt; added to the string (which disables prepared statements), and for serverless you'll usually also set &lt;code&gt;connection_limit=1&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Prisma against the Supabase transaction pooler
postgresql://postgres.abc:pw@aws-0-eu-west-1.pooler.supabase.com:6543/postgres?pgbouncer=true&amp;amp;connection_limit=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more node-specific gotcha worth knowing: with &lt;code&gt;node-postgres&lt;/code&gt;, &lt;code&gt;sslmode=require&lt;/code&gt; is treated as an alias for &lt;code&gt;verify-full&lt;/code&gt;, so it does &lt;em&gt;full&lt;/em&gt; certificate verification (unlike libpq, which only encrypts without verifying the CA). That's still the default in the current release line (&lt;code&gt;pg&lt;/code&gt; v8 / &lt;code&gt;pg-connection-string&lt;/code&gt; v2), not a thing of the past; it only relaxes to libpq's encrypt-only behavior in the unreleased &lt;code&gt;pg&lt;/code&gt; v9. Against managed hosts whose chain Node doesn't trust, it throws &lt;code&gt;self signed certificate in certificate chain&lt;/code&gt; (hyphenated as &lt;code&gt;self-signed …&lt;/code&gt; on Node 17+, which bundles OpenSSL 3). The pragmatic fix is &lt;code&gt;ssl: { rejectUnauthorized: false }&lt;/code&gt; when you're not validating the CA, or supply the provider's CA via &lt;code&gt;ssl: { ca }&lt;/code&gt;; you can also opt into libpq behavior now with &lt;code&gt;?sslmode=require&amp;amp;uselibpqcompat=true&lt;/code&gt;. Also: if you put &lt;code&gt;sslmode&lt;/code&gt; (or &lt;code&gt;sslcert&lt;/code&gt;/&lt;code&gt;sslkey&lt;/code&gt;/&lt;code&gt;sslrootcert&lt;/code&gt;) in the connection string, &lt;code&gt;pg&lt;/code&gt; replaces your entire &lt;code&gt;ssl&lt;/code&gt; config object, so don't mix the two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test your connection string before you deploy
&lt;/h2&gt;

&lt;p&gt;Most of these errors are findable &lt;em&gt;before&lt;/em&gt; you ship, by reading the string carefully, which is exactly the kind of tedious parsing a tool should do for you. Our free &lt;a href="https://codelesssync.com/tools/postgresql-connection-string-validator" rel="noopener noreferrer"&gt;PostgreSQL Connection String Validator&lt;/a&gt; parses your string in the browser, breaks it into its components, and flags the common problems: unencoded special characters in the password, a missing &lt;code&gt;sslmode&lt;/code&gt; on a host that needs it, a Supabase pooler host paired with a plain &lt;code&gt;postgres&lt;/code&gt; username, a non-standard port, and more.&lt;/p&gt;

&lt;p&gt;It runs entirely client-side. Your credentials never leave your machine; nothing is sent to a server, logged, or stored. Validate the string, fix whatever it flags using the sections above, and you'll catch most failures before the first deploy instead of after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skip connection strings entirely
&lt;/h2&gt;

&lt;p&gt;Step back for a second. Every error in this post (encoding, host stack, port, SSL mode, pooler username) exists because you're hand-assembling and pasting a connection string in the first place. Remove that step and the whole class of bugs disappears.&lt;/p&gt;

&lt;p&gt;That's the case for connecting via OAuth instead. With &lt;a href="https://codelesssync.com/blog/sync-supabase-securely-with-oauth" rel="noopener noreferrer"&gt;Supabase OAuth&lt;/a&gt;, &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; authorizes against your Supabase project directly, so there's no string to URL-encode, no pooler-vs-direct decision to get wrong, no &lt;code&gt;sslmode&lt;/code&gt; to remember, and no long-lived password living in an env var. CLS validates and tests the connection at the moment you connect a database, so a misconfiguration surfaces immediately instead of three deploys later. Connect once, and your Stripe, QuickBooks, Xero, or Paddle data keeps syncing to Postgres without you ever re-pasting a string.&lt;/p&gt;

&lt;p&gt;If you're still choosing where to host your Postgres, &lt;a href="https://codelesssync.com/blog/supabase-vs-neon-vs-railway-postgresql-for-saas" rel="noopener noreferrer"&gt;Supabase vs Neon vs Railway for SaaS&lt;/a&gt; compares the connection-string ergonomics alongside pricing and scaling. The full setup walkthrough lives in the &lt;a href="https://codelesssync.com/docs/guides/database-setup" rel="noopener noreferrer"&gt;database setup guide&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How do I fix "password authentication failed for user postgres"?
&lt;/h3&gt;

&lt;p&gt;First confirm the username and password are correct. If they are, the cause is almost always &lt;strong&gt;special characters in the password that aren't percent-encoded&lt;/strong&gt; inside the connection string. Characters like &lt;code&gt;@&lt;/code&gt;, &lt;code&gt;:&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt;, and &lt;code&gt;#&lt;/code&gt; have special meaning in a URI, so the parser misreads or splits the password at them; a &lt;code&gt;#&lt;/code&gt; in particular makes a strict parser reject the string and a lax one drop everything after it. Encode them: &lt;code&gt;@&lt;/code&gt;→&lt;code&gt;%40&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt;→&lt;code&gt;%2F&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt;→&lt;code&gt;%3F&lt;/code&gt;, &lt;code&gt;#&lt;/code&gt;→&lt;code&gt;%23&lt;/code&gt;. So &lt;code&gt;p@$$w0rd&lt;/code&gt; becomes &lt;code&gt;p%40%24%24w0rd&lt;/code&gt; (encoding the &lt;code&gt;@&lt;/code&gt; is what matters; encoding the &lt;code&gt;$&lt;/code&gt; too is just harmless). The &lt;a href="https://codelesssync.com/tools/postgresql-connection-string-validator" rel="noopener noreferrer"&gt;Codeless Sync connection string validator&lt;/a&gt; flags unencoded characters automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why can't I connect to my Supabase database?
&lt;/h3&gt;

&lt;p&gt;The three usual causes are all in the connection string. &lt;strong&gt;&lt;code&gt;Tenant or user not found&lt;/code&gt;&lt;/strong&gt; means you used plain &lt;code&gt;postgres&lt;/code&gt; against the pooler, so switch the username to &lt;code&gt;postgres.&amp;lt;project-ref&amp;gt;&lt;/code&gt;. &lt;strong&gt;&lt;code&gt;could not translate host name&lt;/code&gt;&lt;/strong&gt; on a serverless or IPv4-only network means you're using the IPv6-only direct host (&lt;code&gt;db.&amp;lt;ref&amp;gt;.supabase.co&lt;/code&gt;), so switch to the IPv4 pooler (&lt;code&gt;aws-&amp;lt;region&amp;gt;.pooler.supabase.com&lt;/code&gt;). And &lt;strong&gt;&lt;code&gt;Connection refused&lt;/code&gt;&lt;/strong&gt; often means your free-tier project is paused (resume it in the dashboard) or you used the wrong port.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does "no pg_hba.conf entry for host" mean and how do I fix it?
&lt;/h3&gt;

&lt;p&gt;It means you reached the Postgres server, but no rule in its host-based authentication config (&lt;code&gt;pg_hba.conf&lt;/code&gt;) allows your connection. When the message ends in &lt;code&gt;no encryption&lt;/code&gt; or &lt;code&gt;SSL off&lt;/code&gt;, that part describes your connection (it was plaintext), and the rule that would have matched is almost always &lt;code&gt;hostssl&lt;/code&gt; (TLS-only). On a managed host you can't edit &lt;code&gt;pg_hba.conf&lt;/code&gt;, but you don't need to: just add &lt;code&gt;?sslmode=require&lt;/code&gt; to your connection string. Neon, DigitalOcean, and SSL-enforcing AWS RDS instances all require this.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I fix "could not connect to server: connection refused" in PostgreSQL?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Connection refused&lt;/code&gt; means the TCP connection reached the host but nothing was listening on that port. On managed hosts the most common cause is the &lt;strong&gt;wrong port&lt;/strong&gt;: DigitalOcean uses &lt;code&gt;25060&lt;/code&gt;, the Supabase transaction pooler uses &lt;code&gt;6543&lt;/code&gt;, not the default &lt;code&gt;5432&lt;/code&gt;. Check the exact port in your provider's dashboard. Other causes are a paused project, a firewall or security group blocking the port, or the server being down. Verify reachability with &lt;code&gt;pg_isready -h &amp;lt;host&amp;gt; -p &amp;lt;port&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What causes the Supabase "Tenant or user not found" error?
&lt;/h3&gt;

&lt;p&gt;It's caused by connecting to the Supabase pooler with the plain username &lt;code&gt;postgres&lt;/code&gt;. The pooler (Supavisor) reads your project reference from the username to route to the correct tenant, so it needs the form &lt;code&gt;postgres.&amp;lt;project-ref&amp;gt;&lt;/code&gt;, for example &lt;code&gt;postgres.your-project-ref&lt;/code&gt;. Plain &lt;code&gt;postgres&lt;/code&gt; only works on the direct connection (&lt;code&gt;db.&amp;lt;ref&amp;gt;.supabase.co&lt;/code&gt;), not the pooler host (&lt;code&gt;aws-&amp;lt;region&amp;gt;.pooler.supabase.com&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need sslmode=require in my PostgreSQL connection string?
&lt;/h3&gt;

&lt;p&gt;For most managed hosts, yes: Neon, DigitalOcean, and SSL-enforcing AWS RDS reject plaintext connections, so omitting it gives &lt;code&gt;no pg_hba.conf entry ... no encryption&lt;/code&gt;. (Supabase is the exception: it accepts non-SSL connections by default for client compatibility and makes SSL enforcement opt-in, though &lt;code&gt;sslmode=require&lt;/code&gt; is still good practice there.) For a trusted local server with no SSL configured, do the opposite and use &lt;code&gt;sslmode=disable&lt;/code&gt;, or you'll get &lt;code&gt;server does not support SSL, but SSL was required&lt;/code&gt;. Be aware that in libpq, &lt;code&gt;require&lt;/code&gt; encrypts but does not verify the server's certificate; for genuine protection against man-in-the-middle attacks, use &lt;code&gt;verify-full&lt;/code&gt; with the provider's CA certificate. (Some drivers differ: node-postgres treats &lt;code&gt;require&lt;/code&gt; as full verification, as noted above.)&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I put a special-character password in a PostgreSQL connection string?
&lt;/h3&gt;

&lt;p&gt;Percent-encode every reserved character in the password before placing it in the URI. The key ones: &lt;code&gt;@&lt;/code&gt;→&lt;code&gt;%40&lt;/code&gt;, &lt;code&gt;:&lt;/code&gt;→&lt;code&gt;%3A&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt;→&lt;code&gt;%2F&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt;→&lt;code&gt;%3F&lt;/code&gt;, &lt;code&gt;#&lt;/code&gt;→&lt;code&gt;%23&lt;/code&gt;, &lt;code&gt;&amp;amp;&lt;/code&gt;→&lt;code&gt;%26&lt;/code&gt;, &lt;code&gt;=&lt;/code&gt;→&lt;code&gt;%3D&lt;/code&gt;, space→&lt;code&gt;%20&lt;/code&gt;, &lt;code&gt;$&lt;/code&gt;→&lt;code&gt;%24&lt;/code&gt;, &lt;code&gt;%&lt;/code&gt;→&lt;code&gt;%25&lt;/code&gt;, &lt;code&gt;[&lt;/code&gt;→&lt;code&gt;%5B&lt;/code&gt;, &lt;code&gt;]&lt;/code&gt;→&lt;code&gt;%5D&lt;/code&gt;. When unsure, encode the entire password. The alternative is to avoid the URI form entirely and pass &lt;code&gt;host&lt;/code&gt;, &lt;code&gt;port&lt;/code&gt;, &lt;code&gt;user&lt;/code&gt;, and &lt;code&gt;password&lt;/code&gt; as separate parameters, which skips URI parsing rules altogether.&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/sync-supabase-securely-with-oauth" rel="noopener noreferrer"&gt;Sync Supabase Securely with OAuth: No Connection String Needed&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: Best PostgreSQL for SaaS&lt;/a&gt;&lt;/li&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/tools/postgresql-connection-string-validator" rel="noopener noreferrer"&gt;PostgreSQL Connection String Validator (free tool)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>api</category>
      <category>software</category>
    </item>
    <item>
      <title>Best Tools to Sync Stripe Data to a Database (2026)</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Mon, 22 Jun 2026 11:05:22 +0000</pubDate>
      <link>https://dev.to/ilshadyx/best-tools-to-sync-stripe-data-to-a-database-2026-50hm</link>
      <guid>https://dev.to/ilshadyx/best-tools-to-sync-stripe-data-to-a-database-2026-50hm</guid>
      <description>&lt;p&gt;&lt;em&gt;The best tools to sync Stripe data to a database in 2026, compared. Honest pros, cons and pricing for Codeless Sync, Airbyte, Fivetran, Stitch, Hevo and more.&lt;/em&gt;&lt;/p&gt;

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




&lt;p&gt;You've already decided you want your Stripe data in a real database, joinable, queryable, and sitting next to your application data. The only question left is which tool to use to get it there.&lt;/p&gt;

&lt;p&gt;This is a 2026 roundup of the best tools to sync Stripe data to a database, with honest pros, cons, and pricing for each. It's not a "how to think about it" piece, if you're still weighing the broad approaches (custom scripts, webhooks, ETL, no-code), start with &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; first. This post assumes you want a tool you can sign up for today and have syncing by this afternoon.&lt;/p&gt;

&lt;p&gt;We'll focus on getting Stripe data into PostgreSQL specifically (Supabase, Neon, AWS RDS, Railway, or any Postgres host), since that's where most SaaS teams keep their source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Look For in a Stripe Sync Tool
&lt;/h2&gt;

&lt;p&gt;Before the list, here's the criteria that actually matters when you're picking a tool to sync Stripe into a database:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Destination support.&lt;/strong&gt; Does it write to PostgreSQL, and specifically to &lt;em&gt;your&lt;/em&gt; Postgres host (Supabase, Neon, RDS, Railway)? Some tools only target warehouses like Snowflake or BigQuery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No-code vs. configuration.&lt;/strong&gt; Some tools are genuinely click-and-go; others ("no-code" on the box) still need you to model sources, destinations, streams, and sync schedules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incremental sync + historical backfill.&lt;/strong&gt; You want both: a full backfill of existing Stripe customers, invoices, and subscriptions, plus efficient incremental updates afterwards so you're not re-pulling everything each run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing model.&lt;/strong&gt; Flat tiers are predictable. Per-row or "monthly active rows" (MAR) pricing can be cheap at first and expensive once your Stripe volume grows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintenance and schema drift.&lt;/strong&gt; Stripe changes its API and adds fields. A good tool handles &lt;a href="https://codelesssync.com/blog/why-stripe-postgresql-sync-keeps-breaking" rel="noopener noreferrer"&gt;schema changes and broken syncs&lt;/a&gt; for you instead of failing silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security.&lt;/strong&gt; Read-only Stripe keys, encrypted credentials, and ideally no need to paste a full database superuser connection string.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep those six in mind as you read, they're what separate "works for a weekend project" from "set it and forget it."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Best Tools to Sync Stripe Data to a Database
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Codeless Sync
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; is purpose-built for exactly this problem: getting Stripe (and QuickBooks, Xero, or Paddle) data into PostgreSQL with no code. You connect a database, Supabase via one-click OAuth, or any Postgres connection string for Neon, Railway, AWS RDS, and others, add a read-only Stripe key, pick what to sync, and it auto-creates the tables and keeps them up to date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Solo developers, startup founders, freelancers, and agencies who want Stripe data in their own Postgres without building or babysitting a pipeline.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Built specifically for the Stripe-to-PostgreSQL use case, not a generic warehouse tool&lt;/li&gt;
&lt;li&gt;Auto-creates tables with the right schema and handles incremental syncs and schema drift&lt;/li&gt;
&lt;li&gt;Works with any PostgreSQL host (Supabase, Neon, Railway, AWS RDS, Heroku Postgres)&lt;/li&gt;
&lt;li&gt;One-click Supabase OAuth, no need to paste a full connection string&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://codelesssync.com/pricing" rel="noopener noreferrer"&gt;Free tier&lt;/a&gt;, no credit card required; ~5-minute setup&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;Focused on its supported providers (Stripe, QuickBooks, Xero, Paddle), not a fit if you also need GitHub, HubSpot, or Salesforce data&lt;/li&gt;
&lt;li&gt;Scheduled batch sync rather than millisecond-level real-time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see the full flow end to end, the &lt;a href="https://codelesssync.com/blog/how-to-sync-stripe-data-to-postgresql" rel="noopener noreferrer"&gt;Stripe to PostgreSQL guide&lt;/a&gt; walks through setup step by step.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Airbyte
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://airbyte.com" rel="noopener noreferrer"&gt;Airbyte&lt;/a&gt; is an open-source ETL platform with a huge connector library, including a mature Stripe source and a first-class PostgreSQL destination. It's the right call when Stripe is just one of many sources you need to consolidate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams already running data infrastructure who want dozens of sources in one place.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Hundreds of connectors beyond Stripe (GitHub, HubSpot, Salesforce, and more)&lt;/li&gt;
&lt;li&gt;PostgreSQL is a first-class destination; incremental sync supported&lt;/li&gt;
&lt;li&gt;Open-source and self-hostable, so no per-row pricing if you run it yourself&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;Self-hosting is non-trivial, Airbyte's docs recommend a meaningful VM (4+ CPUs, 8GB RAM) plus monitoring and updates&lt;/li&gt;
&lt;li&gt;Airbyte Cloud removes the hosting burden but moves you to usage-based pricing that climbs with volume&lt;/li&gt;
&lt;li&gt;The source/destination/connection model is heavier than a single "sync Stripe here" flow, overkill if Stripe is all you need&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Fivetran
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.fivetran.com" rel="noopener noreferrer"&gt;Fivetran&lt;/a&gt; is the enterprise-grade, fully managed option. Polished connectors, strong schema-drift handling, and good monitoring, at an enterprise price.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Larger teams with a data function and a budget, syncing many sources into a warehouse or Postgres.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Fully managed, no infrastructure to run&lt;/li&gt;
&lt;li&gt;Reliable Stripe connector with automatic schema handling&lt;/li&gt;
&lt;li&gt;Direct PostgreSQL destination plus solid alerting out of the box&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;Pricing is based on Monthly Active Rows (MAR) on a sliding scale, the free plan covers up to 500K MAR, then costs scale with volume&lt;/li&gt;
&lt;li&gt;Fivetran doesn't publish a flat per-row rate; you'll want their estimator for a real quote&lt;/li&gt;
&lt;li&gt;A lot of platform for a single "get Stripe into Postgres" job&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Stitch Data
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.stitchdata.com" rel="noopener noreferrer"&gt;Stitch&lt;/a&gt; is a simpler, older managed ETL service with a Stripe integration and PostgreSQL destination. It's lighter than Fivetran but now sits inside a much larger enterprise suite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Small-to-mid teams who want managed ETL without Fivetran's complexity.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Managed, no infrastructure&lt;/li&gt;
&lt;li&gt;PostgreSQL supported as a destination&lt;/li&gt;
&lt;li&gt;More approachable than Fivetran for straightforward pipelines&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;Standard plan starts around $100/month, so there's no meaningful free path for ongoing use&lt;/li&gt;
&lt;li&gt;Now a Qlik product (via Talend), so it evolves inside a big platform rather than as a focused standalone tool&lt;/li&gt;
&lt;li&gt;Still warehouse-oriented, more than you need for one or two SaaS sources&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Hevo Data
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://hevodata.com" rel="noopener noreferrer"&gt;Hevo&lt;/a&gt; is a no-code data pipeline platform with a Stripe source and PostgreSQL destination. It sits between the simplicity of a focused sync tool and the breadth of Fivetran.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams that want a managed, no-code pipeline across several sources and don't mind a learning curve.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;No-code pipeline builder with a wide connector range&lt;/li&gt;
&lt;li&gt;PostgreSQL destination with incremental loads&lt;/li&gt;
&lt;li&gt;Free tier for low volume, with paid plans for scale&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;Paid plans start around $239/month once you outgrow the free tier&lt;/li&gt;
&lt;li&gt;Aimed at warehouse-style workloads, heavier than needed for just Stripe&lt;/li&gt;
&lt;li&gt;Configuration and monitoring closer to Fivetran than to a click-and-go tool&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6. Supabase Stripe Wrapper (Stripe FDW)
&lt;/h3&gt;

&lt;p&gt;If your database is Supabase, the &lt;a href="https://fdw.dev/catalog/stripe/" rel="noopener noreferrer"&gt;Stripe Foreign Data Wrapper&lt;/a&gt; is a genuinely useful, Postgres-native option. Using the &lt;code&gt;wrappers&lt;/code&gt; extension, it exposes Stripe objects as foreign tables you can query with plain SQL, no separate pipeline at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Supabase users who want to query live Stripe data from SQL without running any sync job.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Native to Postgres/Supabase, query Stripe customers, invoices, and subscriptions as if they were tables&lt;/li&gt;
&lt;li&gt;No extra tool or cost beyond your Supabase plan&lt;/li&gt;
&lt;li&gt;Always reflects current Stripe data (it reads live on query)&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;Supabase only, not a general solution for Neon, RDS, or Railway&lt;/li&gt;
&lt;li&gt;Foreign tables read from the Stripe API on each query, so they're subject to Stripe rate limits and aren't a persisted local copy by default, and Supabase's own docs warn that materialized views over these tables can fail during logical backups, so a real sync is the more dependable way to keep a cached copy&lt;/li&gt;
&lt;li&gt;Mostly read-only, a few objects (customers, products, subscriptions) support writes, and limited to the Stripe objects the wrapper exposes&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  7. Stripe Sigma
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://stripe.com/sigma/pricing" rel="noopener noreferrer"&gt;Stripe Sigma&lt;/a&gt; is Stripe's own SQL analytics product. It's worth addressing because it's the first thing many people find, but it's important to be clear: &lt;strong&gt;Sigma does not sync data into your database.&lt;/strong&gt; It lets you run SQL against your Stripe data &lt;em&gt;inside the Stripe Dashboard&lt;/em&gt;, and the data never leaves Stripe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Occasional SQL queries and scheduled reports on Stripe data, when you don't need to join it with your own tables.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Zero setup, built into Stripe, always current&lt;/li&gt;
&lt;li&gt;Familiar SQL interface with scheduled reports&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;Data stays in Stripe, you can't join it with your users table or use it in your own app/dashboards&lt;/li&gt;
&lt;li&gt;Paid add-on with tiered pricing (a monthly fee plus a per-charge fee that grows with volume)&lt;/li&gt;
&lt;li&gt;Not standard PostgreSQL, and export is manual (CSV)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stripe also offers &lt;strong&gt;Data Pipeline&lt;/strong&gt;, a separate paid product that syncs Stripe data to data warehouses (Snowflake, Amazon Redshift, Databricks) and cloud storage (S3, Google Cloud Storage, Azure), but not to PostgreSQL or other transactional databases. So if Postgres is your destination, neither Sigma nor Data Pipeline gets you there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Tools to Sync Stripe Data Compared (2026)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;PostgreSQL destination&lt;/th&gt;
&lt;th&gt;Code required&lt;/th&gt;
&lt;th&gt;Pricing (2026)&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Codeless Sync&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No-code sync&lt;/td&gt;
&lt;td&gt;Yes (any host)&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Free tier, scales by syncs&lt;/td&gt;
&lt;td&gt;Stripe → Postgres, fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Airbyte&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Open-source ETL&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;None (config-heavy)&lt;/td&gt;
&lt;td&gt;Free self-host / usage cloud&lt;/td&gt;
&lt;td&gt;Many sources, self-host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fivetran&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Managed ELT&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Free ≤500K MAR / tiered MAR&lt;/td&gt;
&lt;td&gt;Enterprise pipelines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Stitch Data&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Managed ETL&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;From ~$100/month&lt;/td&gt;
&lt;td&gt;Mid-market managed ETL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hevo Data&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No-code ETL&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Free tier / from ~$239/month&lt;/td&gt;
&lt;td&gt;Multi-source pipelines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Supabase Stripe Wrapper&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Postgres FDW&lt;/td&gt;
&lt;td&gt;Supabase only (live)&lt;/td&gt;
&lt;td&gt;SQL setup&lt;/td&gt;
&lt;td&gt;Free (Postgres extension)&lt;/td&gt;
&lt;td&gt;Live Stripe queries on Supabase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Stripe Sigma&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;In-Stripe analytics&lt;/td&gt;
&lt;td&gt;No (stays in Stripe)&lt;/td&gt;
&lt;td&gt;SQL only&lt;/td&gt;
&lt;td&gt;Paid add-on (tiered)&lt;/td&gt;
&lt;td&gt;Quick queries inside Stripe&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How to Choose the Right Stripe Sync Tool
&lt;/h2&gt;

&lt;p&gt;The honest answer is that most people reading this don't need a warehouse-grade ETL platform, they need Stripe data in Postgres without a maintenance burden.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Just want Stripe in your own Postgres, fast?&lt;/strong&gt; A purpose-built no-code sync like &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; is the shortest path — connect, sync, done, with a free tier to start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consolidating ten-plus sources and already run infrastructure?&lt;/strong&gt; Airbyte (self-hosted) earns its keep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise team with a data function and budget?&lt;/strong&gt; Fivetran is the polished managed option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want managed ETL but Fivetran feels heavy?&lt;/strong&gt; Stitch or Hevo sit in the middle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On Supabase and just want to query Stripe from SQL?&lt;/strong&gt; The Stripe FDW is the lightest possible option, though for a persisted, queryable copy at scale you'll still want a real sync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only need occasional reports and don't care about owning the data?&lt;/strong&gt; Stripe Sigma will do, but it isn't a database sync.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What You Can Do Once Stripe Data Is in Your Database
&lt;/h2&gt;

&lt;p&gt;The reason to sync at all is what becomes possible afterwards: real SQL across your billing data joined with everything else you store. For example, monthly revenue straight from a synced &lt;code&gt;stripe_invoices&lt;/code&gt; table:&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;created&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="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;customer&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;paying_customers&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_paid&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;stripe_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="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="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="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;From here you can build revenue dashboards, join Stripe customers to your own users table, or calculate SaaS metrics, 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; for ready-made queries. None of that is possible while your data is locked inside Stripe.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What is the best tool to sync Stripe data to a database in 2026?
&lt;/h3&gt;

&lt;p&gt;For most developers and small teams, a purpose-built no-code tool like &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; is the best fit, it syncs Stripe straight into your own PostgreSQL (Supabase, Neon, Railway, AWS RDS) in about five minutes with a free tier. If you're consolidating many sources and already run data infrastructure, Airbyte or Fivetran make more sense, but they're more tool than a single Stripe sync needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the best free tool to sync Stripe to PostgreSQL?
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://codelesssync.com/pricing" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; has a free tier with no credit card required, which is the simplest free path for ongoing Stripe-to-Postgres sync. Self-hosted Airbyte is also free in licensing terms, but you pay in the VM and maintenance it requires. On Supabase specifically, the Stripe Foreign Data Wrapper is free as part of your existing plan, though it queries Stripe live rather than keeping a local copy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need an ETL tool like Fivetran or Airbyte just for Stripe?
&lt;/h3&gt;

&lt;p&gt;Usually not. Fivetran and Airbyte are excellent when you're loading many sources into a warehouse, but for a single "get Stripe into PostgreSQL" job they add cost, configuration, and overhead you don't need. A focused sync tool is faster to set up and cheaper to run for one or two providers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Stripe Sigma a Stripe sync tool?
&lt;/h3&gt;

&lt;p&gt;No. Stripe Sigma runs SQL against your Stripe data inside the Stripe Dashboard, but the data never leaves Stripe, you can't join it with your own tables or use it in your app. If the goal is getting Stripe data into your own database, you need a sync tool, not Sigma. See &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;/p&gt;

&lt;h3&gt;
  
  
  Which tool is best for syncing Stripe to Supabase or Neon specifically?
&lt;/h3&gt;

&lt;p&gt;For &lt;a href="https://codelesssync.com/stripe-to-supabase" rel="noopener noreferrer"&gt;Supabase&lt;/a&gt;, Codeless Sync supports one-click OAuth so you don't paste a connection string, and the native Stripe FDW is an option for live queries. For &lt;a href="https://codelesssync.com/stripe-to-neon" rel="noopener noreferrer"&gt;Neon&lt;/a&gt; and other hosts, any tool with a PostgreSQL destination works, Codeless Sync, Airbyte, Fivetran, Stitch, or Hevo, but a no-code sync is the quickest to get running.&lt;/p&gt;




&lt;p&gt;Want the fastest path? &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; syncs Stripe to your PostgreSQL database with no code and a free tier, no credit card required. For the full walkthrough, see &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;/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/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/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/best-datafetcher-alternative-for-postgresql" rel="noopener noreferrer"&gt;Best Datafetcher Alternative for PostgreSQL&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/stripe-to-postgresql" rel="noopener noreferrer"&gt;Sync Stripe Data to PostgreSQL, No Code, Auto-Create Tables&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>stripe</category>
      <category>postgres</category>
      <category>database</category>
      <category>api</category>
    </item>
    <item>
      <title>QuickBooks API Integration Guide for Developers</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Mon, 15 Jun 2026 12:14:12 +0000</pubDate>
      <link>https://dev.to/ilshadyx/quickbooks-api-integration-guide-for-developers-11m5</link>
      <guid>https://dev.to/ilshadyx/quickbooks-api-integration-guide-for-developers-11m5</guid>
      <description>&lt;p&gt;&lt;em&gt;A developer guide to the QuickBooks Online API: OAuth 2.0 setup, querying data, pagination, rate limits, and syncing to PostgreSQL without the plumbing.&lt;/em&gt;&lt;/p&gt;

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




&lt;p&gt;If you're integrating with QuickBooks Online, the Intuit API is powerful but it has a learning curve: OAuth 2.0 with rotating refresh tokens, a SQL-like query language, per-company rate limits, and sandbox/production environments that behave differently. This guide walks through the whole flow end to end, so you can go from zero to reading live company data, then shows the shortcut if you'd rather skip the plumbing entirely.&lt;/p&gt;

&lt;p&gt;Everything below targets the &lt;strong&gt;QuickBooks Online Accounting API&lt;/strong&gt; (the cloud product), not the older QuickBooks Desktop SDK.&lt;/p&gt;

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

&lt;p&gt;The QuickBooks Online API is a REST API hosted by Intuit. You authenticate against a specific company (Intuit calls it a &lt;strong&gt;realm&lt;/strong&gt;, identified by a &lt;code&gt;realmId&lt;/code&gt;) and read or write accounting entities: &lt;code&gt;Customer&lt;/code&gt;, &lt;code&gt;Invoice&lt;/code&gt;, &lt;code&gt;Payment&lt;/code&gt;, &lt;code&gt;Bill&lt;/code&gt;, &lt;code&gt;Vendor&lt;/code&gt;, &lt;code&gt;Item&lt;/code&gt;, &lt;code&gt;Account&lt;/code&gt;, and around 30 others.&lt;/p&gt;

&lt;p&gt;Two things make it different from a typical REST API:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every request is scoped to a realm.&lt;/strong&gt; The company ID is part of the URL, so a single access token can only touch the company that authorised it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reads use a query language, not REST filters.&lt;/strong&gt; Instead of &lt;code&gt;GET /customers?active=true&lt;/code&gt;, you send a SQL-like string to a single &lt;code&gt;/query&lt;/code&gt; endpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1: Create an Intuit Developer App
&lt;/h2&gt;

&lt;p&gt;Before any code, you need credentials:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sign up at the &lt;a href="https://developer.intuit.com" rel="noopener noreferrer"&gt;Intuit Developer portal&lt;/a&gt; and create an app under the &lt;strong&gt;QuickBooks Online and Payments&lt;/strong&gt; platform.&lt;/li&gt;
&lt;li&gt;Grab your &lt;strong&gt;Client ID&lt;/strong&gt; and &lt;strong&gt;Client Secret&lt;/strong&gt; from the app's &lt;strong&gt;Keys &amp;amp; credentials&lt;/strong&gt; section. There's a separate pair for &lt;strong&gt;Development&lt;/strong&gt; (sandbox) and &lt;strong&gt;Production&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Add a &lt;strong&gt;Redirect URI&lt;/strong&gt; (e.g. &lt;code&gt;https://yourapp.com/callback&lt;/code&gt;). It must match exactly what you send during OAuth.&lt;/li&gt;
&lt;li&gt;Note the scope you need: &lt;code&gt;com.intuit.quickbooks.accounting&lt;/code&gt; for accounting data.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Intuit also provisions a free &lt;strong&gt;sandbox company&lt;/strong&gt; so you can develop against realistic data without touching a real business.&lt;/p&gt;

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

&lt;p&gt;QuickBooks uses the OAuth 2.0 &lt;strong&gt;authorization code&lt;/strong&gt; flow. The user is redirected to Intuit, approves access, 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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OAuthClient&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;intuit-oauth&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;oauthClient&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;OAuthClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;clientId&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;QB_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;clientSecret&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;QB_CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sandbox&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// or 'production'&lt;/span&gt;
  &lt;span class="na"&gt;redirectUri&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;QB_REDIRECT_URI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 1. Send the user here to authorise&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authUri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;oauthClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authorizeUri&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;OAuthClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Accounting&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a-random-csrf-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="c1"&gt;// 2. In your redirect handler, exchange the code for tokens&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;authResponse&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;oauthClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createToken&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;url&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="nx"&gt;authResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getJson&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// token.access_token   -&amp;gt; use for API calls (valid ~1 hour)&lt;/span&gt;
  &lt;span class="c1"&gt;// token.refresh_token  -&amp;gt; use to get new access tokens (valid ~100 days)&lt;/span&gt;
  &lt;span class="c1"&gt;// realmId comes in as a query param on the redirect&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;realmId&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;realmId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Persist refresh_token + realmId securely (you'll need both later)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things trip people up here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;realmId&lt;/code&gt; is not in the token.&lt;/strong&gt; It arrives as a separate query parameter on the redirect. Store it alongside the tokens — you need it in every API URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access tokens expire in ~1 hour.&lt;/strong&gt; Short-lived by design. You refresh them with the refresh token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh tokens rotate.&lt;/strong&gt; Each refresh can return a &lt;em&gt;new&lt;/em&gt; refresh token and the old one eventually stops working. Always persist the latest one you receive, or you'll get locked out after ~100 days of inactivity.
&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="c1"&gt;// Refresh an expired access token&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refreshResponse&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;oauthClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refreshUsingToken&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;refreshResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getJson&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// Save newToken.refresh_token — it may have changed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;API URLs follow the pattern &lt;code&gt;/v3/company/{realmId}/{resource}&lt;/code&gt;, against different base hosts for sandbox and production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sandbox: &lt;code&gt;https://sandbox-quickbooks.api.intuit.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Production: &lt;code&gt;https://quickbooks.api.intuit.com&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's reading a single customer by ID:&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;baseUrl&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://sandbox-quickbooks.api.intuit.com&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;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v3/company/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;realmId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/customer/1?minorversion=75`&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="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;data&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DisplayName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always pin a &lt;code&gt;minorversion&lt;/code&gt; — Intuit uses it to version response payloads. As of 2026 the minimum supported (and default) is &lt;strong&gt;75&lt;/strong&gt;; Intuit deprecated versions 1–74 in 2025, so pin an explicit current version rather than relying on "latest".&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Query Data with the QuickBooks Query Language
&lt;/h2&gt;

&lt;p&gt;For anything beyond fetching by ID, you use the &lt;code&gt;/query&lt;/code&gt; endpoint with a SQL-like statement. This is how you list, filter, and page through records.&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;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT * FROM Invoice WHERE TxnDate &amp;gt; '2026-01-01'&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;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v3/company/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;realmId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/query?query=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&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="s2"&gt;&amp;amp;minorversion=75`&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="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;data&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="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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;QueryResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Invoice&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pagination is manual. The API returns up to &lt;strong&gt;1,000 rows&lt;/strong&gt; per call, and you walk the result set with &lt;code&gt;STARTPOSITION&lt;/code&gt; and &lt;code&gt;MAXRESULTS&lt;/code&gt;:&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;let&lt;/span&gt; &lt;span class="nx"&gt;startPosition&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pageSize&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allInvoices&lt;/span&gt; &lt;span class="o"&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="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;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`SELECT * FROM Invoice STARTPOSITION &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;startPosition&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; MAXRESULTS &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pageSize&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="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;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v3/company/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;realmId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/query?query=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;minorversion=75`&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="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="kd"&gt;const&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="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;QueryResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Invoice&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nx"&gt;allInvoices&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;page&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;page&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="nx"&gt;pageSize&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="c1"&gt;// last page&lt;/span&gt;
  &lt;span class="nx"&gt;startPosition&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;pageSize&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 query language is a subset of SQL — no &lt;code&gt;JOIN&lt;/code&gt;s, limited functions, and each entity is queried separately.&lt;/p&gt;

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

&lt;p&gt;QuickBooks throttles per company (realm). As of 2026, Intuit documents &lt;strong&gt;500 requests per minute per realm&lt;/strong&gt; and a maximum of &lt;strong&gt;10 concurrent requests&lt;/strong&gt; in production, with the &lt;strong&gt;batch endpoint throttled separately at 40 requests per minute per realm&lt;/strong&gt;. These limits change over time, so check the &lt;a href="https://developer.intuit.com/app/developer/qbo/docs/learn/rest-api-features#limits-and-throttles" rel="noopener noreferrer"&gt;current limits in Intuit's docs&lt;/a&gt;. Exceed them and you get HTTP &lt;code&gt;429&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Errors come back in a &lt;code&gt;Fault&lt;/code&gt; object, not as plain HTTP status text:&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="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;ok&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fault&lt;/span&gt; &lt;span class="o"&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;Fault&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nb"&gt;Error&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="c1"&gt;// e.g. { Message: 'message', Detail: '...', code: '3200' }&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`QuickBooks error &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fault&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&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;fault&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;Message&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build in exponential backoff on &lt;code&gt;429&lt;/code&gt; and &lt;code&gt;5xx&lt;/code&gt;, and use the &lt;strong&gt;batch endpoint&lt;/strong&gt; (&lt;code&gt;/batch&lt;/code&gt;, up to 30 operations per call) to cut request volume when you're reading or writing many records — though note the batch endpoint has its own, tighter per-minute throttle, so it reduces total calls rather than letting you burst past the realm limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Keep Data in Sync with Change Data Capture
&lt;/h2&gt;

&lt;p&gt;Polling everything on a schedule wastes calls. For incremental updates, QuickBooks offers a &lt;strong&gt;Change Data Capture (CDC)&lt;/strong&gt; endpoint that returns only entities changed since a timestamp:&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;since&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-06-01T00:00:00-00:00&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;entities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Customer,Invoice,Payment&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;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v3/company/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;realmId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/cdc?entities=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;entities&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;changedSince=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;since&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;minorversion=75`&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CDC is the backbone of any efficient sync: pull a full snapshot once, then poll CDC for deltas.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hard Parts (and Why Many Teams Don't Build This Themselves)
&lt;/h2&gt;

&lt;p&gt;A working integration is achievable, but keeping it running is the real cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Token lifecycle.&lt;/strong&gt; Refreshing every hour, persisting rotating refresh tokens, and recovering when a refresh fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sandbox vs production.&lt;/strong&gt; Separate credentials, separate base URLs, separate company data — easy to misconfigure on go-live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pagination and rate limits.&lt;/strong&gt; Every entity paginated separately, backoff on throttling, batching to stay under caps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema mapping.&lt;/strong&gt; QuickBooks objects are deeply nested; flattening &lt;code&gt;Invoice.Line[]&lt;/code&gt;, &lt;code&gt;LinkedTxn&lt;/code&gt;, and custom fields into clean relational tables is real work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ongoing maintenance.&lt;/strong&gt; Minor-version bumps, new fields, and deprecations mean the integration is never truly "done."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your end goal is simply &lt;strong&gt;getting QuickBooks data into your own database to query and report on&lt;/strong&gt;, all of the above is plumbing that doesn't differentiate your product.&lt;/p&gt;

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

&lt;p&gt;If you'd rather skip the OAuth dance, pagination, and schema mapping, &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; connects QuickBooks to your PostgreSQL database (Supabase, Neon, Railway, AWS RDS, or any Postgres host) in about 5 minutes. You authorise QuickBooks via OAuth once, and CLS handles token refresh, CDC-based incremental sync, pagination, rate limits, and table creation for you. Your data lands as clean relational tables, ready for 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;-- Top 10 customers by invoiced revenue this year&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;display_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;total_amt&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;invoiced&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;quickbooks_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;quickbooks_customers&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;customer_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;txn_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-01-01'&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;display_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;invoiced&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;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same model works for Stripe, Xero, and Paddle too, so multi-provider billing all lands in one database. There's a free tier, no credit card required.&lt;/p&gt;

&lt;p&gt;For a step-by-step walkthrough, see &lt;a href="https://codelesssync.com/blog/how-to-sync-quickbooks-data-to-postgresql" rel="noopener noreferrer"&gt;How to Sync QuickBooks Data to PostgreSQL Automatically&lt;/a&gt;. If you're weighing export options more broadly, &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; compares five methods side by side.&lt;/p&gt;

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

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

&lt;p&gt;Yes. Access to the QuickBooks Online Accounting API is free for developers — there's no per-call charge from Intuit. You do need a QuickBooks Online subscription (or the free sandbox company) for the data, and your own infrastructure to run the integration.&lt;/p&gt;

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

&lt;p&gt;Access tokens are valid for about one hour. Refresh tokens last around 100 days but rotate — each refresh can return a new refresh token, and you must persist the latest one or you'll lose access after the window expires.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does QuickBooks have webhooks?
&lt;/h3&gt;

&lt;p&gt;Yes. QuickBooks Online offers Event Notifications (webhooks) covering most major entities — including Customer, Invoice, Payment, Bill, Item, Account, and around two dozen others — for create, update, delete, void, and merge events. They're genuinely useful for reacting to changes in real time. What they don't give you is historical backfill or a guaranteed gap-free feed, so for keeping a database fully in sync, teams typically combine a one-time snapshot with the Change Data Capture (CDC) endpoint and scheduled polling. Note Intuit is migrating webhook payloads to the CloudEvents format, with all apps required to move by July 31, 2026; both the old and new formats are supported during the transition, so check your payload parsing against the current spec.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the QuickBooks API rate limit?
&lt;/h3&gt;

&lt;p&gt;As of 2026, Intuit throttles per company (realm) at 500 requests per minute, with a maximum of 10 concurrent requests in production. The batch endpoint is throttled separately at 40 requests per minute per realm. These limits change over time, so check Intuit's official limits documentation and implement exponential backoff on HTTP 429 responses.&lt;/p&gt;

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

&lt;p&gt;Yes. Tools like &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; handle the OAuth flow, token refresh, pagination, and schema mapping for you, writing QuickBooks data straight into PostgreSQL tables. You authorise once and the data 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-quickbooks-data-to-postgresql" rel="noopener noreferrer"&gt;How to Sync QuickBooks Data to PostgreSQL Automatically&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-sync-xero-data-to-postgresql" rel="noopener noreferrer"&gt;How to Sync Xero Data to PostgreSQL Automatically in 5 Minutes&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;/ul&gt;

</description>
      <category>quickbooks</category>
      <category>postgres</category>
      <category>database</category>
      <category>api</category>
    </item>
  </channel>
</rss>
