<?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 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>
    <item>
      <title>Paddle Webhooks vs Database Sync: Which is Better?</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Tue, 09 Jun 2026 12:06:43 +0000</pubDate>
      <link>https://dev.to/ilshadyx/paddle-webhooks-vs-database-sync-which-is-better-53o</link>
      <guid>https://dev.to/ilshadyx/paddle-webhooks-vs-database-sync-which-is-better-53o</guid>
      <description>&lt;p&gt;&lt;em&gt;Comparing Paddle webhooks and database sync for getting billing data into PostgreSQL. Learn when to use each approach, and when to use both.&lt;/em&gt;&lt;/p&gt;

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




&lt;p&gt;If you're building on top of Paddle Billing, you'll eventually need that data in your own database, to power dashboards, reconcile revenue, or join billing data against your product tables. There are two main ways to get it there: webhooks and database sync. Both work, but they solve different problems.&lt;/p&gt;

&lt;p&gt;This post breaks down how each approach works with Paddle specifically, what tends to go wrong, and when you should reach for one over the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Paddle Webhooks Work
&lt;/h2&gt;

&lt;p&gt;Paddle webhooks (Paddle calls them &lt;strong&gt;notifications&lt;/strong&gt;) are push-based. You create a notification destination in the Paddle dashboard, and when something happens, a transaction completes, a subscription is cancelled, a customer is created, Paddle sends an HTTP POST to your endpoint with the event payload.&lt;/p&gt;

&lt;p&gt;Here's a typical handler in Express using the official Paddle Node SDK:&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;express&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;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Paddle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;EventName&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;@paddle/paddle-node-sdk&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;paddle&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;Paddle&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;webhookSecret&lt;/span&gt; &lt;span class="o"&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_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;;&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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/paddle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;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;application/json&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;signature&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;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paddle-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawBody&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;body&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Verifies the Paddle-Signature header and parses the payload&lt;/span&gt;
      &lt;span class="nx"&gt;event&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;paddle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;webhookSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;signature&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;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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="s2"&gt;`Webhook Error: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&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;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nx"&gt;EventName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TransactionCompleted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;// Insert/update your transactions table&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nx"&gt;EventName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SubscriptionUpdated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;// Update your subscriptions table&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nx"&gt;EventName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CustomerCreated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;// Insert into your customers table&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;// ... handle dozens more event types&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="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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;ok&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;That's the gist. Paddle pushes events to you in near real-time, and your server processes them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problems with Paddle Webhooks
&lt;/h2&gt;

&lt;p&gt;Webhooks are great in theory. In practice, Paddle's push model comes with a familiar list of operational headaches:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signature verification with a timestamp window.&lt;/strong&gt; Paddle signs each request with the &lt;code&gt;Paddle-Signature&lt;/code&gt; header, which contains a timestamp (&lt;code&gt;ts&lt;/code&gt;) and an HMAC-SHA256 hash (&lt;code&gt;h1&lt;/code&gt;). You have to rebuild the signed payload as &lt;code&gt;ts:body&lt;/code&gt;, hash it with your endpoint secret, and compare — while also rejecting stale timestamps to prevent replay attacks. Get the raw-body handling wrong (parse the JSON too early and the bytes no longer match) and every signature fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sandbox and live are completely separate.&lt;/strong&gt; Paddle runs sandbox and production as isolated environments with different API keys &lt;em&gt;and&lt;/em&gt; different webhook secrets. The classic outage: everything works in sandbox, you go live, and prod silently drops every event because the endpoint is still pointed at the sandbox secret.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missed events during downtime.&lt;/strong&gt; If your endpoint is down or returns a non-2xx, Paddle &lt;a href="https://developer.paddle.com/webhooks/overview" rel="noopener noreferrer"&gt;retries notifications with backoff for up to 3 days&lt;/a&gt;. Survive a longer incident, a bad deploy, or a changed endpoint URL, and those events are gone unless you backfill from the API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No historical backfill.&lt;/strong&gt; Notifications only capture events from the moment the destination is created. Want last year's transactions? Webhooks can't help, you'll write a separate backfill script against the Paddle API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema management across event types.&lt;/strong&gt; &lt;code&gt;transaction.completed&lt;/code&gt;, &lt;code&gt;subscription.updated&lt;/code&gt;, &lt;code&gt;adjustment.created&lt;/code&gt;, &lt;code&gt;customer.updated&lt;/code&gt; and friends each carry a different payload shape. Supporting them means a lot of mapping logic to write and keep current as Paddle evolves the API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Replay complexity.&lt;/strong&gt; Need to rebuild your data after a bug? Paddle's dashboard lets you replay notifications, but doing it at scale is fiddly, and there's no native "resync everything from scratch" button.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Database Sync Works
&lt;/h2&gt;

&lt;p&gt;Database sync takes the opposite approach. Instead of Paddle pushing events to you, a sync service periodically &lt;strong&gt;pulls&lt;/strong&gt; data from the Paddle API and writes it directly 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;Provide your Paddle API key (read access)&lt;/li&gt;
&lt;li&gt;The sync service calls the Paddle API, fetches your data, and writes it to structured tables&lt;/li&gt;
&lt;li&gt;On subsequent runs, it pulls what changed and upserts, no duplicates, no gaps&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's no notification endpoint to expose, no &lt;code&gt;Paddle-Signature&lt;/code&gt; to verify, no sandbox/live secret mismatch to debug. The data just 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;Paddle Webhooks&lt;/th&gt;
&lt;th&gt;Database Sync&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data freshness&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Near real-time (seconds)&lt;/td&gt;
&lt;td&gt;Batch (e.g. every 6 hours / daily)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Setup complexity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;High — endpoint, signature + timestamp checks, event handling&lt;/td&gt;
&lt;td&gt;Low — connect database and API key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Historical data&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No — only captures new events&lt;/td&gt;
&lt;td&gt;Yes — full backfill on first sync&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sandbox vs live&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Separate secrets, easy to misconfigure&lt;/td&gt;
&lt;td&gt;One API key per environment, no endpoint to mismatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reliability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You handle retries, idempotency, failures&lt;/td&gt;
&lt;td&gt;Managed by the sync service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Maintenance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ongoing — new event types, API changes&lt;/td&gt;
&lt;td&gt;Minimal — schema handled for you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Code required&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Significant — handler, mapping, verification&lt;/td&gt;
&lt;td&gt;None (no-code) or minimal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Triggering actions in real-time&lt;/td&gt;
&lt;td&gt;Querying and analysing data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When to Use Paddle Webhooks
&lt;/h2&gt;

&lt;p&gt;Webhooks are the right choice when you need to &lt;strong&gt;react to events in real-time&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provision access&lt;/strong&gt; the moment &lt;code&gt;subscription.activated&lt;/code&gt; fires&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revoke access&lt;/strong&gt; when &lt;code&gt;subscription.canceled&lt;/code&gt; or &lt;code&gt;transaction.payment_failed&lt;/code&gt; lands&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send a receipt or welcome email&lt;/strong&gt; when a transaction completes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger a churn survey&lt;/strong&gt; when a subscription is cancelled&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update a UI&lt;/strong&gt; instantly when a payment succeeds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your use case is "when X happens in Paddle, do Y immediately," webhooks are what you want. The real-time push model is built for exactly this.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use Database Sync
&lt;/h2&gt;

&lt;p&gt;Database sync is the right choice when you need to &lt;strong&gt;query, analyse, or join Paddle data&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build dashboards&lt;/strong&gt; showing revenue trends, MRR, churn, or customer growth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run ad-hoc queries&lt;/strong&gt; like "which customers have spent over £1,000 this quarter?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Join billing data with your app data&lt;/strong&gt;, combine Paddle customers with your users table&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate reports&lt;/strong&gt; for accounting or investor updates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Power internal tools&lt;/strong&gt; where teams need to look up billing history&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once your Paddle data is synced to PostgreSQL, you can run queries like this:&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;-- Monthly completed-transaction revenue for the last 6 months&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;transaction_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;grand_total&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;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;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'6 months'&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Active subscriptions joined to your own users table&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;u&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;ps&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;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_billed_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;paddle_customers&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&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;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;paddle_subscriptions&lt;/span&gt; &lt;span class="n"&gt;ps&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ps&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;'active'&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;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_billed_at&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 Paddle API directly. You'd need multiple paginated calls, client-side filtering, and careful rate-limit handling. With synced data, it's just SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use Both
&lt;/h2&gt;

&lt;p&gt;Here's the thing, webhooks and database sync aren't mutually exclusive. The best setups often run both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks&lt;/strong&gt; handle real-time events: provision access, send emails, trigger workflows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database sync&lt;/strong&gt; keeps a queryable, reconciled copy of your Paddle data for reporting and analysis&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your app reacts to events instantly through webhooks, while your team runs any query it wants against the synced database. The common mistake is using webhooks for &lt;em&gt;everything&lt;/em&gt;, including analytics workloads that don't need sub-second freshness and pay for it in maintenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started with Database Sync
&lt;/h2&gt;

&lt;p&gt;If you want to try the sync approach, &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; connects your PostgreSQL database and syncs Paddle data, customers, subscriptions, transactions, products, prices, adjustments, and discounts, in about 5 minutes. It auto-creates the destination tables, upserts on every run so there are no duplicates, and recovers from downtime automatically 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, QuickBooks, and Xero too, so if you bill across 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;
  
  
  Does Paddle have a built-in PostgreSQL integration?
&lt;/h3&gt;

&lt;p&gt;No, Paddle doesn't ship a native sync to PostgreSQL or any other database. The two official ways to get data out are webhooks (push, real-time, you build the handler) and the Paddle API (pull, on-demand, you build the polling logic). Everything else is third-party. To get Paddle data into your own Postgres for analytics or accounting, you either write your own pipeline or use 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;
  
  
  Why does my Paddle webhook keep failing?
&lt;/h3&gt;

&lt;p&gt;The most common causes are signature verification failures (the raw request body was modified or parsed before hashing, so the &lt;code&gt;Paddle-Signature&lt;/code&gt; no longer matches), a sandbox/live secret mismatch after going to production, or an endpoint that returned a non-2xx long enough for Paddle to exhaust its retry window. Paddle's dashboard shows the delivery log and response code for each notification, start there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I replace Paddle webhooks entirely with a scheduled sync?
&lt;/h3&gt;

&lt;p&gt;For analytics, MRR dashboards, accounting, reporting, and most CRM workflows, yes, a scheduled sync against the Paddle API is more reliable than a custom webhook handler and needs no public endpoint. For real-time use cases (instant access provisioning, immediate payment confirmation UX), you'll still want webhooks for those specific events. Many teams run both: webhooks for the few events that need real-time response, scheduled sync for everything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often should I sync Paddle to PostgreSQL?
&lt;/h3&gt;

&lt;p&gt;For analytics and reporting, a 6-hourly or daily sync is usually plenty. Accounting workflows that close books daily are well served by a daily sync. Real-time freshness is rarely needed for these workloads and just means more API calls. Codeless Sync supports manual syncs on the free tier; paid tiers add scheduled cadences.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which Paddle data can I sync to my database?
&lt;/h3&gt;

&lt;p&gt;Codeless Sync supports seven Paddle Billing data types: customers, subscriptions, transactions, products, prices, adjustments (refunds, credits, chargebacks), and discounts. Transactions support incremental sync, so each run only pulls what changed since the last one.&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/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/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/best-datafetcher-alternative-for-postgresql" rel="noopener noreferrer"&gt;Best Datafetcher Alternative for PostgreSQL&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>paddle</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Your Stripe to PostgreSQL Sync Keeps Breaking</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Mon, 01 Jun 2026 14:51:29 +0000</pubDate>
      <link>https://dev.to/ilshadyx/why-your-stripe-to-postgresql-sync-keeps-breaking-3dia</link>
      <guid>https://dev.to/ilshadyx/why-your-stripe-to-postgresql-sync-keeps-breaking-3dia</guid>
      <description>&lt;p&gt;&lt;em&gt;Stripe to PostgreSQL syncs break for the same reasons: missed webhooks, signature rotations, schema drift, bad retries. Here's the set-and-forget fix.&lt;/em&gt;&lt;/p&gt;

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




&lt;p&gt;If you've ever shipped a Stripe to PostgreSQL pipeline yourself, you already know the rhythm: it works for weeks, then quietly stops. A customer's &lt;code&gt;subscription.updated&lt;/code&gt; never lands, MRR in your dashboard drifts away from MRR in Stripe, and the only signal that anything is wrong is a support ticket from someone whose plan didn't downgrade.&lt;/p&gt;

&lt;p&gt;Stripe to PostgreSQL syncs break for a short list of repeat offenders, and almost all of them come from the same root cause: a homegrown pipeline that has to be perfect to be correct. Webhooks have to be received, verified, parsed, retried on failure, deduplicated, and translated into the right SQL, every time, for years, across every event type Stripe ever decides to add. That is a lot of perfect.&lt;/p&gt;

&lt;p&gt;This post walks through why your Stripe → PostgreSQL sync keeps breaking, what those failures actually look like in production, and the &lt;strong&gt;set-and-forget alternative&lt;/strong&gt; that sidesteps most of them entirely. Whether you're running your own webhook handler today or evaluating a tool like &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt;, the failure modes below are the ones to plan for. If you haven't already weighed webhook handlers against scheduled sync jobs at a higher level, the companion post &lt;a href="https://codelesssync.com/blog/stripe-webhooks-vs-database-sync" rel="noopener noreferrer"&gt;Stripe Webhooks vs Database Sync&lt;/a&gt; covers that trade-off head-to-head.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Stripe → PostgreSQL Pipelines Break in Production
&lt;/h2&gt;

&lt;p&gt;Stripe's webhook system is genuinely well-built. The reliability problems almost always live in the code &lt;em&gt;around&lt;/em&gt; it: the handler you wrote, the database it talks to, the assumptions baked into both. Five failure modes account for the majority of broken syncs.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Missed Webhooks During Downtime
&lt;/h3&gt;

&lt;p&gt;When your endpoint is down, Stripe &lt;a href="https://docs.stripe.com/webhooks#retries" rel="noopener noreferrer"&gt;retries failed webhooks for up to 3 days with exponential backoff&lt;/a&gt;. That sounds generous, until you have a 4-day incident, a misconfigured ALB, or a regional outage that quietly drops a chunk of events. Anything older than 3 days is gone unless you backfill it manually from the API.&lt;/p&gt;

&lt;p&gt;The painful part: you usually don't notice immediately. The webhook handler logs look clean (Stripe never re-delivered), and your database just has a hole.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Signature Verification Breaks on Secret Rotation
&lt;/h3&gt;

&lt;p&gt;Webhook signature verification depends on a signing secret that you store as an env var. Rotate the secret in Stripe, forget to update production, and every incoming event fails the signature check. Your handler returns 400, Stripe retries for 3 days, and you're back to scenario #1.&lt;/p&gt;

&lt;p&gt;The same thing happens silently when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You promote a new endpoint without copying the secret across&lt;/li&gt;
&lt;li&gt;A team member rotates the dev secret thinking it's prod&lt;/li&gt;
&lt;li&gt;An infrastructure script rebuilds env vars from a stale source&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Schema Drift on Stripe's Side
&lt;/h3&gt;

&lt;p&gt;Stripe versions its API, which protects you from breaking changes, but the &lt;a href="https://docs.stripe.com/api/events/object" rel="noopener noreferrer"&gt;webhook event objects&lt;/a&gt; reflect whatever API version your account or endpoint is pinned to. When you upgrade — or when Stripe adds a new field you start relying on — your &lt;code&gt;INSERT&lt;/code&gt; statements have to keep up.&lt;/p&gt;

&lt;p&gt;Common symptoms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A new field appears in the event payload that your &lt;code&gt;INSERT&lt;/code&gt; ignores, so your local table is missing data Stripe has&lt;/li&gt;
&lt;li&gt;A field type changes (an &lt;code&gt;amount&lt;/code&gt; moves from integer to a nested object on a newer version) and your parser throws&lt;/li&gt;
&lt;li&gt;A new event type ships and your handler returns 200 but does nothing with it&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Retry Logic That Isn't Idempotent
&lt;/h3&gt;

&lt;p&gt;Stripe will deliver the same webhook more than once. That's by design: at-least-once delivery is what makes the retry system reliable. Your handler has to be &lt;strong&gt;idempotent&lt;/strong&gt; — receiving the same event twice should produce the same database state as receiving it once.&lt;/p&gt;

&lt;p&gt;A naive handler like this is the classic bug:&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;// Brittle: duplicate webhook delivery = duplicate row&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO stripe_customers (id, email, created) VALUES ($1, $2, $3)&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="nx"&gt;event&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;object&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;event&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;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&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;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that twice on the same event and you get a constraint violation or a duplicate row, depending on your schema. The fix is &lt;code&gt;ON CONFLICT&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="c1"&gt;// Idempotent: safe to retry forever&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`INSERT INTO stripe_customers (id, email, created, updated_at)
   VALUES ($1, $2, $3, NOW())
   ON CONFLICT (id) DO UPDATE
     SET email = EXCLUDED.email,
         updated_at = NOW()`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;event&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;object&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;event&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;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&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;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Easy in isolation, easy to forget when you're juggling 12 event types under deadline pressure.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Backfill and Reality Drift Apart
&lt;/h3&gt;

&lt;p&gt;Even if every webhook lands, your database can still drift from Stripe over time. Disputes, refunds processed via the dashboard, manual edits, test events in prod — anything that mutates state outside your event stream creates a gap. Without a periodic reconciliation pass against the Stripe API, you can't tell whether the row you're looking at is current.&lt;/p&gt;

&lt;p&gt;This is the bug that quietly poisons MRR dashboards: each individual event was handled correctly, but the cumulative state has drifted by a few hundred dollars.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhooks vs Sync Jobs: At a Glance
&lt;/h2&gt;

&lt;p&gt;Most teams' first instinct is to fix a broken webhook pipeline with better webhook handling — more retries, dead-letter queues, alerting. That works, but it adds infrastructure to maintain. A scheduled sync job (poll the Stripe API on a cadence, upsert into Postgres) trades real-time freshness for a much smaller surface area to break.&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;Custom Webhook Handler&lt;/th&gt;
&lt;th&gt;Scheduled Sync Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Freshness&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Real-time (sub-second under normal conditions)&lt;/td&gt;
&lt;td&gt;As fresh as your schedule (1 min to 24 hr)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Recovery from downtime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Limited to Stripe's 3-day retry window&lt;/td&gt;
&lt;td&gt;Next scheduled run catches everything missed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Signature handling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Required, plus rotation pain&lt;/td&gt;
&lt;td&gt;Not applicable — no inbound webhook&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Idempotency&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You write it, you debug it&lt;/td&gt;
&lt;td&gt;Built into the sync's upsert pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Schema drift&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Surfaces as silent bugs in the handler&lt;/td&gt;
&lt;td&gt;Surfaces as a sync error you can act on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Backfill story&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Separate script, often built reactively&lt;/td&gt;
&lt;td&gt;Same code path as live sync&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ongoing maintenance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;High — every Stripe event type is a code branch&lt;/td&gt;
&lt;td&gt;Low — Stripe API contract changes, the sync adapts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Real-time fraud signals, instant payment UX&lt;/td&gt;
&lt;td&gt;Analytics, MRR dashboards, accounting, reporting&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two aren't mutually exclusive. Plenty of mature SaaS setups run webhooks for the handful of events that genuinely need real-time response, and a scheduled sync for everything else. The mistake is using webhooks for &lt;em&gt;everything&lt;/em&gt;, including analytics workloads that don't need sub-second freshness.&lt;/p&gt;

&lt;p&gt;We covered the head-to-head trade-offs in more depth in &lt;a href="https://codelesssync.com/blog/stripe-webhooks-vs-database-sync" rel="noopener noreferrer"&gt;Stripe Webhooks vs Database Sync: Which Should You Use?&lt;/a&gt; — worth a read if you're still deciding which side of the line your project sits on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Set-and-Forget Approach
&lt;/h2&gt;

&lt;p&gt;"Set-and-forget" isn't a marketing slogan, it's a design constraint. The pipeline should:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Recover from downtime automatically.&lt;/strong&gt; A 4-hour outage on your end shouldn't leave a permanent gap. The next scheduled run pulls anything that changed in the meantime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be idempotent end-to-end.&lt;/strong&gt; Running the sync twice in a row produces the same database state as running it once. No duplicate rows, no constraint violations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Survive schema changes without silent loss.&lt;/strong&gt; When Stripe adds a field, the sync either captures it or fails loudly — never both ignores it and reports success.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not require a webhook endpoint.&lt;/strong&gt; No signing secrets to rotate, no public HTTPS endpoint to maintain, no 3-day retry window to worry about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconcile on every run.&lt;/strong&gt; Each scheduled sync is effectively a backfill — it queries the Stripe API for what changed and upserts the truth, rather than relying on a stream of events to be lossless.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A scheduled sync gets you all five almost by definition. You poll the Stripe API for objects modified since the last run, upsert them with &lt;code&gt;ON CONFLICT&lt;/code&gt;, and store the run cursor for next time. If a run fails, the next one picks up from the same cursor and catches up.&lt;/p&gt;

&lt;p&gt;This is exactly the pattern &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; implements out of the box — idempotent upserts against a fixed schema, scheduled on the cadence you choose, with reconciliation built into every run.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Set-and-Forget" Looks Like in Codeless Sync
&lt;/h2&gt;

&lt;p&gt;You don't need to write any of the above. The setup is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Connect your PostgreSQL database&lt;/strong&gt; — Supabase, Neon, Railway, AWS RDS, or any standard Postgres connection. See the &lt;a href="https://codelesssync.com/docs/guides/database-setup" rel="noopener noreferrer"&gt;database setup guide&lt;/a&gt; for connection options including &lt;a href="https://codelesssync.com/blog/sync-supabase-securely-with-oauth" rel="noopener noreferrer"&gt;Supabase OAuth&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connect Stripe&lt;/strong&gt; — paste a Stripe secret key (&lt;code&gt;sk_live_*&lt;/code&gt; / &lt;code&gt;sk_test_*&lt;/code&gt;) or, for tighter scope, a restricted key (&lt;code&gt;rk_live_*&lt;/code&gt; / &lt;code&gt;rk_test_*&lt;/code&gt;) with read access on the objects you want to sync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick what to sync and how often&lt;/strong&gt; — choose the data types (customers, invoices, subscriptions, payment intents, invoice line items, subscription items, products, prices, refunds) and a schedule. The free tier is manual-sync only; paid tiers unlock scheduled cadences from every 6 hours down to monthly, depending on plan. CLS auto-creates the destination tables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walk away.&lt;/strong&gt; Each scheduled run pulls the diff from Stripe, upserts into your Postgres tables, and logs the result. Failed runs retry automatically. Downtime on your end is recovered by the next run.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full 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;.&lt;/p&gt;

&lt;p&gt;You still own the database. You still write whatever SQL or analytics you want on top of it. The part you offload is the brittle bit — the webhook handler, the idempotency logic, the schema migrations, the reconciliation pass — that nobody enjoys maintaining.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Webhooks Are Still the Right Call
&lt;/h2&gt;

&lt;p&gt;Sync jobs aren't a universal replacement for webhooks. There are a few legitimate cases where you want real-time delivery and the maintenance cost is worth it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real-time fraud signals.&lt;/strong&gt; A &lt;code&gt;radar.early_fraud_warning.created&lt;/code&gt; event needs to land in your fraud system within seconds, not minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instant payment UX.&lt;/strong&gt; If your app shows "payment confirmed" the moment a customer's card is charged, you're listening for &lt;code&gt;payment_intent.succeeded&lt;/code&gt; in real time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational alerts.&lt;/strong&gt; Failed payments, disputes, and chargebacks often need to ping a Slack channel or email immediately, not on the next sync run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workflows that branch on event type.&lt;/strong&gt; Subscription cancellations that trigger a churn survey, invoices that route to a finance approval flow — these are event-driven by design.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A common mature pattern: keep webhooks for the 3 to 5 events that genuinely need real-time response, and let a scheduled sync handle everything else. You get the right tool for each job and a much smaller webhook surface to maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliability Comes From Boring Pipelines
&lt;/h2&gt;

&lt;p&gt;The reason your Stripe → PostgreSQL sync keeps breaking isn't that you're a bad engineer. It's that custom webhook pipelines have a lot of correctness conditions, and every one of them is a place where a future change can quietly violate the contract. The fix isn't more retries or a smarter handler — it's removing as much of the bespoke machinery as possible and letting a boring scheduled job do the work.&lt;/p&gt;

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

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

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

&lt;p&gt;For most teams, a scheduled sync job that calls the Stripe API on a cadence and upserts into Postgres is more reliable than a hand-rolled webhook handler. You get automatic recovery from downtime, built-in idempotency, no public webhook endpoint to maintain, and reconciliation on every run. Custom webhook handlers are still the right answer when you need real-time response for a specific event (fraud signals, instant payment UX), but for analytics, MRR dashboards, and accounting workloads, a scheduled sync is the safer default. Tools like Codeless Sync run this scheduled-sync pattern for you, so you don't have to build and maintain it yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Stripe have a built-in PostgreSQL integration?
&lt;/h3&gt;

&lt;p&gt;No — Stripe doesn't ship a native sync to PostgreSQL or any other database. The two official paths for getting Stripe data out are webhooks (push, real-time, you build the handler) and the Stripe API (pull, on-demand, you build the polling logic). Everything else is third-party. Stripe Sigma offers SQL queries &lt;em&gt;inside Stripe&lt;/em&gt; but isn't a database you can join against your own tables. To get Stripe data into your own Postgres for analytics, accounting, or product use, you either write your own pipeline or use a sync tool like Codeless Sync.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my Stripe webhook keep failing?
&lt;/h3&gt;

&lt;p&gt;The most common causes are: an endpoint that returned a non-2xx status long enough for Stripe to exhaust its 3-day retry window, a signing secret that's out of sync between Stripe and your env vars, or a handler that throws on an event type or field it doesn't know how to parse. Stripe's dashboard shows the last delivery attempt and the response code for each webhook endpoint — start there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I replace Stripe webhooks entirely with a scheduled sync?
&lt;/h3&gt;

&lt;p&gt;For analytics, MRR dashboards, accounting, reporting, and most CRM workflows, yes — a scheduled sync against the Stripe API is more reliable than a custom webhook handler and requires no public endpoint. For real-time use cases (fraud signals, instant payment confirmations, immediate user-facing UX), you'll still want webhooks for those specific event types. Many teams run both: webhooks for the few events that need real-time response, scheduled sync for everything else.&lt;/p&gt;

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

&lt;p&gt;For analytics and reporting, a 6-hourly or daily sync is usually plenty. For accounting workflows that close books daily, a daily sync is often enough. Real-time freshness (sub-minute) is rarely needed for these workloads, and the trade-off is more API calls and higher cost. Codeless Sync supports manual syncs on the free tier; paid tiers add scheduled cadences from every 6 hours down to monthly, depending on plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens to my data if a scheduled sync run fails?
&lt;/h3&gt;

&lt;p&gt;A scheduled sync is naturally self-healing because each run reconciles against the Stripe API rather than depending on a stream of events. If a run fails, the next scheduled run picks up everything that changed since the last successful cursor. There's no equivalent of the 3-day webhook retry window — even a multi-day outage on your end recovers automatically on the next successful run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a scheduled sync idempotent?
&lt;/h3&gt;

&lt;p&gt;Yes, when built correctly. Each row is upserted with &lt;code&gt;ON CONFLICT (id) DO UPDATE&lt;/code&gt;, so running the same sync twice produces the same database state as running it once. Codeless Sync uses this pattern out of the box for every Stripe object type, so you don't need to write the upsert logic yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I handle Stripe API schema changes?
&lt;/h3&gt;

&lt;p&gt;With a scheduled sync, schema changes surface as either captured-new-fields (if the sync adapts) or loud sync errors (if a type breaks). With custom webhook handlers, the same changes often surface as silent bugs — the handler returns 200 but ignores the new field, or parses an old type into the wrong column. The scheduled-sync model fails loudly, which is usually what you want.&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/stripe-webhooks-vs-database-sync" rel="noopener noreferrer"&gt;Stripe Webhooks vs Database Sync: Which Should You Use?&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/blog/best-stripe-sigma-alternative-for-postgresql" rel="noopener noreferrer"&gt;Best Stripe Sigma Alternative for PostgreSQL&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/sync-supabase-securely-with-oauth" rel="noopener noreferrer"&gt;Sync Supabase via OAuth: No Connection String Needed&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>stripe</category>
      <category>postgres</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Sync Supabase via OAuth: No Connection String Needed</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Mon, 25 May 2026 15:10:17 +0000</pubDate>
      <link>https://dev.to/ilshadyx/sync-supabase-via-oauth-no-connection-string-needed-3n9g</link>
      <guid>https://dev.to/ilshadyx/sync-supabase-via-oauth-no-connection-string-needed-3n9g</guid>
      <description>&lt;p&gt;&lt;em&gt;Sync Supabase via OAuth with Codeless Sync, no full PostgreSQL connection string to paste, no database password on your clipboard. Here's how it works.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;By Ilshaad Kheerdali · 25 May 2026&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;If you want to sync data into Supabase without handing a third-party tool your full PostgreSQL connection string, &lt;strong&gt;Supabase OAuth&lt;/strong&gt; is now the safer default. Almost every "connect your database" form on the internet asks for the same thing, a single connection string with the username, host, port, database name, and password mashed together, and you paste it in and hope for the best.&lt;/p&gt;

&lt;p&gt;That string is your database. Anyone who reads it has full access, there's no scope, no expiry, and the only way to invalidate it is to rotate the database password (which immediately breaks every other place that string was being used). For most developers it's not a deal-breaker, but it's the part of the setup that tends to feel wrong, especially when the target is a production Supabase project sitting behind everything else you've built.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; now supports a Supabase OAuth flow that skips the full connection string altogether. You sign in to Supabase, pick the project you want to sync into, and paste your database password separately, never alongside the rest of your credentials. This guide walks through why Supabase OAuth matters, how the flow works step by step, and exactly what Codeless Sync can and can't see on your Supabase account.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Actually in a Supabase Connection String
&lt;/h2&gt;

&lt;p&gt;A typical Supabase pooler connection string looks like this:&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="n"&gt;postgresql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;//&lt;/span&gt;&lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;abcxyz123&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;Sup3r&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;ecretP4ss&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;eu&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;west&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="n"&gt;pooler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;6543&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;postgres&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single line bundles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Username&lt;/strong&gt; — your Postgres role&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database password&lt;/strong&gt; — the one you set when you created the project&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pooler host and port&lt;/strong&gt; — your region's pooler endpoint&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database name&lt;/strong&gt; — usually &lt;code&gt;postgres&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whoever holds that string can run arbitrary SQL against your project. There's no fine-grained scope ("read invoices only"), no per-app permission, no expiry. If it leaks into a log file or a misconfigured screenshot, the only way to invalidate it is to rotate the database password, which immediately breaks everywhere else that string is in use.&lt;/p&gt;

&lt;p&gt;For most developers, pasting it into a trusted SaaS isn't the end of the world. But there's a small wince every time you do it, especially when most of the string (host, port, user, database name) is non-sensitive and could be looked up automatically. The password is the only secret bit. The OAuth flow leans into that distinction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Supabase OAuth vs Connection String: At a Glance
&lt;/h2&gt;

&lt;p&gt;Before walking through the flow, here's how the two paths compare on the things that usually matter when you're deciding which way to connect:&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;Supabase OAuth (Codeless Sync)&lt;/th&gt;
&lt;th&gt;Manual Connection String&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;What you paste&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database password only&lt;/td&gt;
&lt;td&gt;Full connection string (user + host + port + password)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Where credentials come from&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Supabase fills host, port, user, database via OAuth&lt;/td&gt;
&lt;td&gt;You copy and paste every part yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Project picker&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dropdown of your authorised projects&lt;/td&gt;
&lt;td&gt;None — you build the string per project manually&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scope of OAuth grant&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Read project list + pooler config only&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sync-time dependency&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None — sync uses stored connection string, not OAuth tokens&lt;/td&gt;
&lt;td&gt;None — sync uses the string you pasted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Revoke without re-syncing?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — revoking the grant doesn't stop existing syncs&lt;/td&gt;
&lt;td&gt;Same — rotate the password to revoke&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Works with self-hosted?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No — Supabase OAuth is hosted-only&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hosted Supabase users who want minimal credential surface area&lt;/td&gt;
&lt;td&gt;Self-hosted Supabase or teams that block OAuth apps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both produce the same end state: an encrypted PostgreSQL connection string Codeless Sync uses to run syncs. The OAuth path just narrows what you have to type and where each piece of the credential comes from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The OAuth Alternative: What Codeless Sync Pulls from Supabase
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://supabase.com/docs/guides/integrations/build-a-supabase-integration" rel="noopener noreferrer"&gt;Supabase exposes a Management API and an OAuth flow&lt;/a&gt; that lets approved third-party apps act on a user's behalf — the same way you'd authorise a GitHub app or a Google Workspace integration. Codeless Sync uses that API to handle everything except the database password.&lt;/p&gt;

&lt;p&gt;When you click &lt;strong&gt;Connect Supabase&lt;/strong&gt;, you're redirected to Supabase's authorisation page (not ours). You approve the integration once, against the specific organisation you choose. Supabase returns Codeless Sync to your wizard with a short-lived access token plus a refresh token.&lt;/p&gt;

&lt;p&gt;From there, Codeless Sync uses the OAuth token to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fetch your &lt;strong&gt;list of Supabase projects&lt;/strong&gt; so you can pick one from a dropdown&lt;/li&gt;
&lt;li&gt;Read the &lt;strong&gt;pooler config&lt;/strong&gt; for that project — region, host, port, pool mode&lt;/li&gt;
&lt;li&gt;Auto-fill the username and database name from the project's metadata&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one thing the OAuth flow does &lt;strong&gt;not&lt;/strong&gt; give Codeless Sync is your database password. That stays your responsibility, and you paste it into a separate password field — not alongside the rest of the credentials in a single string.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3-Step Flow in Practice
&lt;/h2&gt;

&lt;p&gt;Here's what the setup actually looks like from your side once you're in the Codeless Sync project wizard:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Click "Connect Supabase".&lt;/strong&gt; You're sent to Supabase's standard OAuth screen. Sign in if you aren't already, then approve the integration for the organisation you want to grant access to. Supabase shows you exactly what scopes are being requested before you confirm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Pick your project.&lt;/strong&gt; Codeless Sync now has read access to your project list. You'll see a dropdown of every project in the organisation you authorised. Choose the one you want to sync data into. Pooler host, port, user, database, and pool mode auto-fill from the project's metadata.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Paste your database password and connect.&lt;/strong&gt; This is the only credential you type in. Find it under &lt;strong&gt;Project Settings → Database&lt;/strong&gt; in your Supabase dashboard. Paste it into the password field, click &lt;strong&gt;Test &amp;amp; Connect&lt;/strong&gt;, and Codeless Sync builds, encrypts, and stores the resulting connection string. From here on out, the wizard hands you off to the rest of the configuration flow — picking a provider (Stripe, QuickBooks, Xero, Paddle), auto-creating the destination table, and scheduling syncs. The full step-by-step walkthrough with screenshots 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;p&gt;If you ever switch organisations or revoke access on Supabase's side, the next time you open the wizard Codeless Sync detects the expired token and surfaces a reconnect prompt — no silent failures during setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Codeless Sync Does With the OAuth Access
&lt;/h2&gt;

&lt;p&gt;Honest, point-by-point:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What CLS uses the OAuth token for:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fetching your &lt;strong&gt;project list&lt;/strong&gt; so you can pick one from a dropdown&lt;/li&gt;
&lt;li&gt;Fetching the &lt;strong&gt;pooler configuration&lt;/strong&gt; for the project you pick (host, port, user, database name, pool mode)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it. The OAuth token isn't used during sync runs at all — once your connection string is built and saved, syncs talk to Postgres directly. The OAuth side of the integration is a setup-time convenience, not a sync-time dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How the database password is handled:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You paste it into a password field in the wizard&lt;/li&gt;
&lt;li&gt;The password is combined with the pooler details to form a connection string &lt;strong&gt;in your browser&lt;/strong&gt;, before anything is sent to CLS's API&lt;/li&gt;
&lt;li&gt;The resulting connection string is then sent over HTTPS to CLS, where it's encrypted at rest&lt;/li&gt;
&lt;li&gt;The raw password is not stored as a separate field, not logged, and never travels to CLS on its own&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Revoking access:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open the &lt;strong&gt;authorised applications&lt;/strong&gt; area of your Supabase dashboard&lt;/li&gt;
&lt;li&gt;Remove the Codeless Sync integration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's a useful property of this design worth knowing: revoking the OAuth grant &lt;strong&gt;does not stop your existing syncs&lt;/strong&gt;, because syncs don't depend on the OAuth tokens. To actually stop a sync, you delete the project (or pause the schedule) inside Codeless Sync. To rotate the credential at the database level, you change your Supabase database password — at which point you'd reconnect from the CLS wizard anyway.&lt;/p&gt;

&lt;p&gt;In other words: the OAuth grant has a deliberately small blast radius. It's only powerful enough to fetch project metadata so the wizard can pre-fill fields. The actual database access lives in the encrypted connection string, fully under your control.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the Manual Connection String Is Still the Right Call
&lt;/h2&gt;

&lt;p&gt;OAuth isn't always the better choice. Codeless Sync keeps the manual paste option in the wizard for a few legitimate cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You don't have admin access&lt;/strong&gt; to authorise OAuth apps on the Supabase organisation (common in larger teams)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your organisation restricts third-party OAuth integrations&lt;/strong&gt; as a policy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're using self-hosted Supabase&lt;/strong&gt; rather than the hosted product (OAuth is hosted-only)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You just prefer the manual flow&lt;/strong&gt; — you already have the connection string saved, and pasting it once is faster than the OAuth roundtrip&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of those apply, the manual path is identical to what it always was: paste the pooler connection string from &lt;strong&gt;Project Settings → Database&lt;/strong&gt;, replace &lt;code&gt;[YOUR-PASSWORD]&lt;/code&gt; with your actual password, hit Test &amp;amp; Connect.&lt;/p&gt;

&lt;p&gt;The two flows produce the same end state — an encrypted connection string Codeless Sync uses for syncing. The only difference is how much of the string came from you versus from Supabase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Stripe, QuickBooks, Xero, or Paddle Data Into Supabase
&lt;/h2&gt;

&lt;p&gt;Once your Supabase project is connected — via OAuth or manual paste — the rest of Codeless Sync works the same way for everyone. Authorise a source provider, pick which records you want, and Codeless Sync auto-creates the table and keeps it in sync on the schedule you choose.&lt;/p&gt;

&lt;p&gt;A few worked examples for popular setups:&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&lt;/a&gt; — Stripe customers, invoices, subscriptions&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&lt;/a&gt; — accounting data with OAuth on both ends&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&lt;/a&gt; — Xero invoices, contacts, transactions&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; — if you're still choosing where to host your Postgres&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the full setup walkthrough, the &lt;a href="https://codelesssync.com/docs/guides/database-setup" rel="noopener noreferrer"&gt;database setup guide&lt;/a&gt; covers both the OAuth and manual paths step by step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the New OAuth Flow
&lt;/h2&gt;

&lt;p&gt;If you've been sat on a Codeless Sync trial because the connection-string step felt off, this is the part of the product that changed. The OAuth flow is live for every Supabase user — no special access, no waitlist.&lt;/p&gt;

&lt;p&gt;Start a project: &lt;a href="https://codelesssync.com/stripe-to-supabase" rel="noopener noreferrer"&gt;codelesssync.com/stripe-to-supabase&lt;/a&gt;&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What is Supabase OAuth?
&lt;/h3&gt;

&lt;p&gt;Supabase OAuth is an authorisation flow built on Supabase's Management API that lets approved third-party apps act on your behalf — fetching things like your project list and pooler configuration — without you ever pasting a full database connection string. You approve the integration once, against the Supabase organisation of your choice, and the third-party tool (in this case Codeless Sync) gets a short-lived access token and a refresh token. The OAuth grant never includes your database password, which stays your responsibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is OAuth more secure than pasting a connection string?
&lt;/h3&gt;

&lt;p&gt;It reduces the amount of secret material flowing into a third-party tool. The non-sensitive parts of the connection (host, port, user, database name) come from Supabase via OAuth instead of being copy-pasted by you. The only thing you actually type is the database password, and the full connection string is assembled in your browser before being sent to CLS. With a manual paste, the entire string — password included — is on your clipboard and sitting in whatever field you saved it to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Codeless Sync store my database password?
&lt;/h3&gt;

&lt;p&gt;Not as a standalone field. It's combined with the pooler details into a connection string client-side, the assembled string is sent to CLS over HTTPS, and CLS encrypts it at rest before storing it. To rotate the password, you reconnect through the wizard — there's no edit-the-stored-password field.&lt;/p&gt;

&lt;h3&gt;
  
  
  What permissions does Codeless Sync request from Supabase?
&lt;/h3&gt;

&lt;p&gt;In practice it uses the OAuth grant for two things: listing the projects in the organisation you authorise, and reading the pooler configuration for the project you pick. The exact scopes are shown on Supabase's authorisation screen before you confirm — review them there if you want the canonical list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I revoke Codeless Sync's access later?
&lt;/h3&gt;

&lt;p&gt;Yes — open the authorised applications area of your Supabase dashboard and remove the Codeless Sync integration. Worth knowing: this does not stop your existing syncs, because syncs use the stored connection string rather than the OAuth tokens. To stop a sync, delete the project (or pause its schedule) inside CLS. To kill database access entirely, rotate your Supabase database password.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the OAuth flow work with self-hosted Supabase?
&lt;/h3&gt;

&lt;p&gt;No. The OAuth flow uses Supabase's hosted Management API, which isn't available on self-hosted installations. If you're running self-hosted Supabase, use the manual connection string option in the wizard — everything else in the product works identically.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if I'm not the admin on my Supabase organisation?
&lt;/h3&gt;

&lt;p&gt;You can still use Codeless Sync, but you'll need to either ask an admin to authorise the OAuth app once for the organisation, or use the manual connection string path. The manual path doesn't require any OAuth permissions on the Supabase side.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I connect multiple Supabase projects to Codeless Sync?
&lt;/h3&gt;

&lt;p&gt;Yes. One OAuth authorisation gives Codeless Sync access to the project list for that organisation, and you can create separate Codeless Sync projects for each Supabase project you want to sync into. If you have projects across multiple Supabase organisations, authorise each organisation separately.&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/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/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&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/docs/guides/database-setup" rel="noopener noreferrer"&gt;Database Setup Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/docs/getting-started/quick-start" rel="noopener noreferrer"&gt;Quick Start: Connect Your Database&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>database</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
