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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;





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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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




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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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




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

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

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

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




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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;p&gt;You can absolutely own that chore yourself, and if you are hand-building, this post's pattern (curated columns, JSONB catch-all, upsert key, sync metadata) minimises it, because unknown fields flow into &lt;code&gt;data&lt;/code&gt; instead of breaking the pipeline.&lt;/p&gt;

&lt;p&gt;The alternative is to not own it at all. &lt;a href="https://codelesssync.com/" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; creates these exact tables in one click and keeps them filled on a schedule (hourly, daily, or custom cron on paid tiers), with full and incremental sync modes, timestamps converted, amounts kept in minor units, and the complete payload preserved in &lt;code&gt;data&lt;/code&gt;. There are no webhooks or cron jobs to babysit and no drift handling on your plate. The 5-minute walkthrough is in &lt;a href="https://codelesssync.com/blog/how-to-sync-stripe-data-to-postgresql" rel="noopener noreferrer"&gt;how to sync Stripe data to PostgreSQL&lt;/a&gt;, and if you are still weighing approaches, &lt;a href="https://codelesssync.com/blog/5-ways-to-get-stripe-data-into-postgresql" rel="noopener noreferrer"&gt;5 ways to get Stripe data into PostgreSQL&lt;/a&gt; compares the options honestly.&lt;/p&gt;

&lt;p&gt;Either way, start from the right schema. All nine production templates (customers, invoices, subscriptions, payment intents, products, prices, refunds, invoice line items, subscription items) are free to inspect and copy in the &lt;a href="https://codelesssync.com/tools/stripe-to-postgres-schema" rel="noopener noreferrer"&gt;Stripe to Postgres Schema tool&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Should I store the whole Stripe payload in one JSONB column?
&lt;/h3&gt;

&lt;p&gt;Not on its own. A single JSONB column preserves everything but makes every query a JSON-path expression, indexes get awkward, and type safety disappears. The hybrid works better: a handful of typed columns for the fields you query, plus one &lt;code&gt;data JSONB&lt;/code&gt; column holding the full payload for everything else. You get fast, readable SQL and lose nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should Stripe amounts be INTEGER, BIGINT, or NUMERIC in PostgreSQL?
&lt;/h3&gt;

&lt;p&gt;Use an integer type and keep Stripe's minor units. &lt;code&gt;INTEGER&lt;/code&gt; covers single-object amounts comfortably (its ceiling is over 21 million pounds in pence), and &lt;code&gt;BIGINT&lt;/code&gt; is the safe pick for aggregates or if you would rather never think about the ceiling. Avoid &lt;code&gt;REAL&lt;/code&gt; and &lt;code&gt;FLOAT&lt;/code&gt; entirely for money, and only convert to &lt;code&gt;NUMERIC&lt;/code&gt; at display or reporting time when you divide by 100 (or 1000 for three-decimal currencies).&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I store Stripe metadata in PostgreSQL?
&lt;/h3&gt;

&lt;p&gt;As JSONB. Stripe's &lt;code&gt;metadata&lt;/code&gt; object is arbitrary user-defined keys, so it has no fixed shape to model as columns. Query it with &lt;code&gt;data-&amp;gt;'metadata'-&amp;gt;&amp;gt;'your_key'&lt;/code&gt;, and if you filter on metadata often, add a GIN index on the JSONB column or an expression index on the specific key you query.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a separate table for every Stripe object?
&lt;/h3&gt;

&lt;p&gt;Yes, one table per object type: customers, invoices, subscriptions, payment intents, and so on. Stripe objects reference each other by ID, so separate tables joined on those IDs (for example &lt;code&gt;stripe_invoices.customer&lt;/code&gt; to &lt;code&gt;stripe_customers.id&lt;/code&gt;) mirror the API's own structure and keep queries simple. The &lt;a href="https://codelesssync.com/tools/stripe-to-postgres-schema" rel="noopener noreferrer"&gt;schema tool&lt;/a&gt; shows the table for each of the 9 core objects.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I keep my Stripe tables up to date when the API changes?
&lt;/h3&gt;

&lt;p&gt;Three habits if you are hand-rolling: pin your Stripe API version so changes only arrive when you choose, watch the &lt;a href="https://docs.stripe.com/changelog" rel="noopener noreferrer"&gt;Stripe API changelog&lt;/a&gt; for moved or removed fields, and keep a full-payload JSONB column so new fields are captured even before you model them. Or hand the chore off entirely: a managed sync like Codeless Sync owns the schema, the conversions, and the drift handling, so your tables stay current without migration work.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/how-to-sync-stripe-data-to-postgresql" rel="noopener noreferrer"&gt;How to Sync Stripe Data to PostgreSQL in 5 Minutes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/why-stripe-postgresql-sync-keeps-breaking" rel="noopener noreferrer"&gt;Why Your Stripe to PostgreSQL Sync Keeps Breaking&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/calculate-mrr-churn-ltv-postgresql" rel="noopener noreferrer"&gt;How to Calculate MRR, Churn, and LTV in PostgreSQL&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/blog/5-ways-to-get-stripe-data-into-postgresql" rel="noopener noreferrer"&gt;5 Ways to Get Stripe Data into PostgreSQL&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelesssync.com/tools/stripe-to-postgres-schema" rel="noopener noreferrer"&gt;Stripe to Postgres Schema (free tool)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>api</category>
      <category>stripe</category>
    </item>
    <item>
      <title>Xero API vs Database Sync: Which is Better?</title>
      <dc:creator>ilshaad</dc:creator>
      <pubDate>Mon, 06 Jul 2026 14:11:03 +0000</pubDate>
      <link>https://dev.to/ilshadyx/xero-api-vs-database-sync-which-is-better-57pm</link>
      <guid>https://dev.to/ilshadyx/xero-api-vs-database-sync-which-is-better-57pm</guid>
      <description>&lt;p&gt;&lt;em&gt;Xero API vs database sync for getting accounting data into PostgreSQL: a build-vs-buy guide covering OAuth tokens, rate limits, and the real maintenance cost.&lt;/em&gt;&lt;/p&gt;

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




&lt;p&gt;If you're building on top of Xero, you'll eventually need that accounting data in your own database, to power dashboards, reconcile revenue, or join it against your product tables. There are two ways to get it there: build your own integration against the Xero API, or hand the job to a managed database sync. Both work, but they cost you very different things.&lt;/p&gt;

&lt;p&gt;It helps to be honest about the framing first. This isn't really "API versus no API", because a managed sync uses the Xero API under the hood too. The real question is build versus buy: do you own the OAuth tokens, the tenant routing, the pagination, the rate-limit backoff, and the schema mapping yourself, or do you let a service run all of that for you? This post compares the two at decision altitude. (If you just want the step-by-step setup instead, here is &lt;a href="https://codelesssync.com/blog/how-to-sync-xero-data-to-postgresql" rel="noopener noreferrer"&gt;how to sync Xero to PostgreSQL in five minutes&lt;/a&gt;.)&lt;/p&gt;

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

&lt;p&gt;Building your own integration means going directly to Xero's developer platform. The shape of the work is always the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Register an app in the Xero Developer portal and get your client credentials.&lt;/li&gt;
&lt;li&gt;Send each customer through the OAuth 2.0 consent flow with the &lt;code&gt;offline_access&lt;/code&gt; scope, then store the access and refresh tokens. Because one Xero login can cover several companies, you also call the Connections endpoint and keep track of each &lt;code&gt;tenantId&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Poll the Accounting API for the objects you care about (invoices, contacts, payments, and so on), paging through the results and sending the right &lt;code&gt;Xero-tenant-id&lt;/code&gt; header on every call.&lt;/li&gt;
&lt;li&gt;Map each object shape into your own tables, and keep refreshing tokens so the connection never goes stale.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In code, the loop you end up owning looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The moving parts you operate yourself, on every connected organisation, forever&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;refreshIfExpired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 30-minute access tokens&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pageThrough&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/Invoices&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 100 per page&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;upsertIntoPostgres&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// your mapping, your schema&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// plus: refresh-token rotation, 429 backoff, per-tenant scheduling, and schema drift&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That snippet hides a lot. The OAuth handshake, the tenant enumeration, the pagination, and the incremental fetching are each a small project of their own. The point here is narrower: what does that integration cost you after it ships?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hidden Costs of Rolling Your Own
&lt;/h2&gt;

&lt;p&gt;The first version is the fun part. The cost shows up later, in the things you have to keep running:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OAuth tokens you babysit forever.&lt;/strong&gt; &lt;a href="https://developer.xero.com/documentation/guides/oauth2/token-types" rel="noopener noreferrer"&gt;Access tokens last 30 minutes&lt;/a&gt;, so you are refreshing constantly. The refresh token has a rolling 60-day life, but it is single-use: every refresh returns a brand new refresh token and invalidates the old one, so you must persist the newest value every single time. Store the wrong one and you get a dead connection that only a manual reconnect can fix. Let a connection sit idle past 60 days and it expires too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limits you engineer around.&lt;/strong&gt; Xero throttles &lt;a href="https://developer.xero.com/documentation/guides/oauth2/limits/" rel="noopener noreferrer"&gt;each connected organisation&lt;/a&gt; to 60 calls per minute, with only 5 requests allowed in flight at once and a further daily cap, on top of a 10,000-per-minute ceiling across your whole app. Cross a line and you get an HTTP 429 with a &lt;code&gt;Retry-After&lt;/code&gt; header, then you back off and retry. With one client that is trivial. Across dozens of connected organisations, throttling becomes its own scheduling problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;List calls hide the detail you actually need.&lt;/strong&gt; Ask Xero for a list of invoices and it returns a trimmed summary with no line items, to keep the response fast. To get the full record you either fetch each invoice individually or page through with the &lt;code&gt;pageSize&lt;/code&gt; parameter (100 per page by default, up to 1000). So a single "get my invoices" is rarely one call, it's many.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incremental sync that quietly misses changes.&lt;/strong&gt; The efficient way to pull only what changed is the &lt;code&gt;If-Modified-Since&lt;/code&gt; header, which filters on each record's &lt;code&gt;UpdatedDateUTC&lt;/code&gt;. The catch is that some changes never bump that timestamp, including a contact's outstanding balance and its customer or supplier flags. A naive incremental sync slowly drifts out of date, so you still need a periodic full reconciliation to stay honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhooks cover almost nothing.&lt;/strong&gt; If you were hoping to skip polling, Xero webhooks only fire for four things: contacts, invoices, credit notes, and Xero App Store subscriptions. There is nothing for accounts, payments, bank transactions, items, or journals. They emit create and update events only (a deletion or void arrives, if at all, as an update), and the payload carries just a reference, the resource id and URL, not the data itself, so you still call the API to fetch the actual record. Undelivered events are retried and stored for around 31 days, which means idempotency and replay handling are yours to build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema mapping across many objects, and it moves.&lt;/strong&gt; Invoices, contacts, accounts, bank transactions, credit notes, items, purchase orders, and journals each carry their own shape and their own relationships. You map every one of them into tables, and when Xero changes a payload, you update the mapping.&lt;/p&gt;

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

&lt;p&gt;Database sync takes the opposite approach. Instead of you operating a pipeline against the Xero API, a managed service does the pulling and writes the results straight into your PostgreSQL database.&lt;/p&gt;

&lt;p&gt;The flow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Connect your PostgreSQL database (Supabase, Neon, Railway, AWS RDS, or any PostgreSQL host)&lt;/li&gt;
&lt;li&gt;Authorize Xero once through the standard consent flow, and pick which organisation to sync&lt;/li&gt;
&lt;li&gt;The sync service calls the Xero API for you, pages through the data, and writes structured tables&lt;/li&gt;
&lt;li&gt;On every later run it pulls what changed and upserts, so there are no duplicates and no gaps&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is no Xero app to register, no refresh-token rotation to schedule, no 429 backoff to code, and no tenant headers to juggle. The accounting data simply shows up in your database, ready to query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side-by-Side Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Build It Yourself (Xero API)&lt;/th&gt;
&lt;th&gt;Managed Database Sync&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Time to first data&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Days to weeks: app, OAuth, tenants, paging, schema&lt;/td&gt;
&lt;td&gt;About 5 minutes: connect database, authorize&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OAuth token lifecycle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yours to run: 30-min access, 60-day rolling single-use refresh&lt;/td&gt;
&lt;td&gt;Handled for you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Multi-organisation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You enumerate tenants and route every call&lt;/td&gt;
&lt;td&gt;Pick an organisation and sync it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rate-limit handling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You build it: 60/min per org, 5 concurrent, 429 backoff&lt;/td&gt;
&lt;td&gt;Managed by the service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Historical backfill&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You write paginated backfill&lt;/td&gt;
&lt;td&gt;Full backfill on first sync&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Incremental updates&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You track &lt;code&gt;UpdatedDateUTC&lt;/code&gt; and its blind spots&lt;/td&gt;
&lt;td&gt;Pulls changes and upserts every run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Schema mapping&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You map every object and track payload changes&lt;/td&gt;
&lt;td&gt;Tables auto-created and maintained&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data freshness&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;On-demand; near real-time for the 4 webhook objects&lt;/td&gt;
&lt;td&gt;Batch (scheduled, e.g. hourly or daily)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Control &amp;amp; customization&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full: fields, transforms, write-back&lt;/td&gt;
&lt;td&gt;Standard tables, less custom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total cost of ownership&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Build plus perpetual maintenance&lt;/td&gt;
&lt;td&gt;Predictable flat subscription&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When to Build It Yourself
&lt;/h2&gt;

&lt;p&gt;Rolling your own is the right call in a few real cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need write-back.&lt;/strong&gt; Sync tools are read-only by design. If you have to create or update records inside Xero, like raising invoices or adding contacts, you need the API directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need real-time reaction&lt;/strong&gt; to one of the few events Xero actually pushes, such as a new invoice or contact, rather than a scheduled batch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You have unusual object or field requirements&lt;/strong&gt; that a standard sync does not cover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You already run pipeline infrastructure,&lt;/strong&gt; so the marginal cost of one more integration is genuinely low.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want full control&lt;/strong&gt; over every transform and mapping, and you are willing to maintain it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If that sounds like you, build it directly against the API and budget for the upkeep above. The control is real, and so is the maintenance.&lt;/p&gt;

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

&lt;p&gt;Managed sync is the right call when you mainly want to query the data, not operate a pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build dashboards and reporting&lt;/strong&gt; on revenue, cash flow, or accounts payable and receivable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run ad-hoc SQL&lt;/strong&gt; against your accounting data, like "which suppliers are we due to pay this week?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Join Xero data with your own tables,&lt;/strong&gt; matching invoices or contacts to your app's users&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep maintenance near zero,&lt;/strong&gt; with no token rotation, rate-limit code, or reconciliation job to own&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pay a predictable flat cost&lt;/strong&gt; instead of perpetual engineer time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once your data is in Postgres, a question like "what are we owed, and what do we owe?" is just SQL:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Try doing that against the Xero API directly. You would need paginated calls per organisation, client-side filtering, and careful rate-limit handling. With synced data, it's one query. If you would rather not build any of the pipeline, here is &lt;a href="https://codelesssync.com/blog/how-to-sync-xero-data-to-postgresql" rel="noopener noreferrer"&gt;how to sync Xero data to PostgreSQL automatically&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;These two approaches are not mutually exclusive, and the strongest setups use both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Xero API (or a webhook)&lt;/strong&gt; for the few moments you need to react instantly, like flagging a large invoice the second it's approved&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed sync&lt;/strong&gt; for the queryable, reconciled copy your team runs reports against&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your app reacts to the events that genuinely need real-time handling, while your team runs any query it likes against the synced database. The common mistake is building and maintaining a whole custom integration just to power dashboards that never needed sub-minute freshness, then paying for that decision in token refreshes, 429 retries, and reconciliation jobs for years.&lt;/p&gt;

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

&lt;p&gt;If you'd rather not build and babysit all of that, &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; connects your PostgreSQL database and syncs Xero data, contacts, invoices, payments, accounts, bank transactions, credit notes, items, purchase orders, journals, and organisation details, in about 5 minutes. It handles the OAuth consent and token refresh for you, lets you pick which organisation to sync, auto-creates the destination tables, and upserts on every run so there are no duplicates. There's a free tier, no credit card required.&lt;/p&gt;

&lt;p&gt;The same model works for &lt;a href="https://codelesssync.com/pricing" rel="noopener noreferrer"&gt;Stripe, QuickBooks, and Paddle&lt;/a&gt; too, so if your finances span more than one provider, all of it lands in the same Postgres database. You can point it at &lt;a href="https://codelesssync.com/xero-to-supabase" rel="noopener noreferrer"&gt;Supabase&lt;/a&gt;, &lt;a href="https://codelesssync.com/xero-to-postgresql" rel="noopener noreferrer"&gt;Neon, Railway, or AWS RDS&lt;/a&gt;, whichever host you already use.&lt;/p&gt;

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

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

&lt;p&gt;Build it if you need to write data back into Xero, want real-time reaction to specific events, or already run pipeline infrastructure. Use a sync tool if you mainly need a queryable copy of your accounting data for dashboards, reporting, or reconciliation, and you would rather not own OAuth token rotation, tenant routing, and rate-limit handling. Plenty of teams do both: the API for the few real-time events, managed sync for everything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does it actually cost to maintain a Xero API integration?
&lt;/h3&gt;

&lt;p&gt;The code is a one-time build. The cost is everything after: rotating the single-use refresh token on every refresh before the 60-day window lapses, backing off when you hit the 60-calls-per-minute or 5-concurrent limit on each organisation, running a periodic reconciliation for changes that don't bump &lt;code&gt;UpdatedDateUTC&lt;/code&gt;, and updating your mapping when Xero changes a payload. That is recurring engineer time, which is what a flat-rate managed sync is really replacing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a managed Xero sync still use the Xero API?
&lt;/h3&gt;

&lt;p&gt;Yes. A sync tool like &lt;a href="https://codelesssync.com" rel="noopener noreferrer"&gt;Codeless Sync&lt;/a&gt; talks to the same OAuth 2.0 and Accounting API endpoints under the hood. The difference is that it operates the consent flow, the tenant selection, the pagination, and the schema mapping for you, so what you end up with is a set of PostgreSQL tables to query rather than a pipeline to maintain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can Xero webhooks replace a scheduled sync?
&lt;/h3&gt;

&lt;p&gt;Not for analytics. Xero webhooks only fire for contacts, invoices, credit notes, and App Store subscriptions, emit create and update events only (no deletes), and carry just a reference rather than the record itself, so you still call the API to fetch the data. For a complete, queryable copy of your accounting data, a scheduled sync is more reliable and needs no public endpoint. Use webhooks only for the specific events you must react to the instant they happen.&lt;/p&gt;

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

&lt;p&gt;Codeless Sync supports ten Xero data types: contacts, invoices, payments, accounts, bank transactions, credit notes, items, purchase orders, journals, and organisation details, each written to its own PostgreSQL table. After the first full pull, later runs are incremental, so each sync only brings over what changed.&lt;/p&gt;




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

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

</description>
      <category>postgres</category>
      <category>database</category>
      <category>api</category>
      <category>xero</category>
    </item>
    <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>
  </channel>
</rss>
