PayPal to Postgres: how to replicate transactions your finance team can query
9 min read Data engineering The Adapters team
Last updated July 2026
Field mapping auto-plugged · tap a port to rewire
Plug a source port into
Transform on this cable
JSON in
JSON out
5 sample records ready
To replicate PayPal transactions into Postgres, read them through the Transaction Search API
(v1/reporting/transactions), step through bounded date windows (the API returns a limited
window, historically up to 31 days per call and about three years back), paginate with page
and page_size, keep gross, fee, and net as separate NUMERIC(19,4) columns with a
currency code, and upsert with INSERT ... ON CONFLICT (transaction_id) DO UPDATE.
Key takeaways
- The API returns a bounded date window per call. You MUST step windows with overlap or you silently drop rows that fall between calls.
- Gross, fee, and net are separate amount objects. Store them as separate columns so the total ties out to the PayPal payout.
- Transactions update status after creation. A pending sale can settle or reverse later, so re-pull recent windows to catch the change.
- Upsert on the PayPal transaction id.
ON CONFLICT (transaction_id) DO UPDATEmakes a re-pulled window idempotent instead of double-counting revenue.
How do you connect PayPal to Postgres?
You connect PayPal to Postgres by reading transactions through the PayPal Transaction Search API and writing them into Postgres tables, because PayPal has no native database export and no read replica to point psql at. Something sits in the middle: a managed connector, or a scheduled job that gets an OAuth2 token, walks bounded date windows, and upserts each page keyed on the PayPal transaction id.
The connection has three parts. First, a PayPal REST app in the developer dashboard that gives you a
client id and secret. You exchange those for an access token with the OAuth2
client_credentials grant, and the token is short lived, so the job requests a fresh one and
caches it until it expires. The app also needs the Transaction Search feature enabled on the account.
Second, a Postgres landing spot: a database, a schema, and a role that can create tables and run
upserts. Third, the transfer loop itself, which steps a start and end date across the range you want,
pages each window with page and page_size, lands each batch in staging, and
upserts into the target. The managed path is the
PayPal to Postgres integration, which
owns the token refresh, the date windowing, the pagination, the staging tables, the upserts, and the
backoff, so you configure the field mapping and leave the plumbing alone.
| Method | What it is | When it fits |
|---|---|---|
| Full historical backfill in date windows | Step the Transaction Search API from your earliest available date to now, one bounded window at a time, paging each window to the last record | The first load, and any time you need history. PayPal limits how far back you can reach (about three years), so archive older data separately |
| Recent-window incremental | Re-read a trailing window (for example the last several days) on a schedule and upsert, so new and newly-settled transactions land | The default steady state. Cheap, catches status changes, and needs no webhook endpoint to maintain |
| Webhooks (IPN or webhooks, best-effort) | PayPal POSTs an event to your endpoint when a payment or refund happens, which you verify and treat as a nudge to re-read | Lower latency hints. Best effort only: verify the signature, treat it as a trigger to re-pull the window, never as the source of truth |
| Managed sync tool | A hosted connector that owns the token, the windowing, the pagination, and idempotent loads | When you would rather not maintain token refresh and window bookkeeping. Less control over the exact request shape |
Most production setups combine two: a full historical backfill in date windows for the initial load, then a recent-window incremental on a schedule for the steady state. Webhooks are an optional latency booster on top, not a replacement, because they can be missed or delivered out of order. If your reporting lives in a warehouse rather than an application database, the sibling path is PayPal to BigQuery, and the same date windowing and reconciliation discipline carries over unchanged.
Does PayPal have an API to export transaction data?
Yes. PayPal exposes the Transaction Search API under its REST platform at
v1/reporting/transactions, which returns transaction detail for a date range, but it is a
reporting endpoint, not a database and not a bulk dump. You authenticate with an OAuth2
client_credentials token, pass a start_date and end_date in ISO
8601, and read results a page at a time. There is no direct database connection.
Two constraints shape everything you build on top of it. First, the endpoint returns a bounded window:
historically a single call can span up to 31 days, and the searchable history reaches back around three
years, so a full backfill is a loop of adjacent windows, not one request. Second, data is not instantly
available. PayPal requires a transaction to be a few hours old before it appears in Transaction Search,
so a sync that only ever reads "the last hour" will miss recent activity. Each record arrives as a
transaction_info object with fields like transaction_id,
transaction_amount (the gross), fee_amount (a negative value PayPal charged),
transaction_status, and transaction_initiation_date, alongside a
payer_info object with the payer email and name. That gap between a reporting API and a
database is the whole reason this pipeline exists. Calling PayPal live from a dashboard is slow and
easily throttled, and you cannot join PayPal responses to your own orders or accounting tables. Once the
data sits in Postgres, attributing revenue to orders or reconciling fees is one join instead of a paging
loop, which is exactly what a data integration platform is for.
How PayPal fields map to Postgres tables and types
Keep the money fields exact and keep net derived. PayPal hands you gross and fee as separate amount objects, each with a numeric value and a three-letter ISO currency code, and net is gross plus fee (fee is negative). Store gross and fee as they arrive, compute net once, and never fold the amounts into a floating point type, because binary floats cannot hold every two-decimal cent and the drift shows up when finance sums a month.
| PayPal field | Postgres column | Type and notes |
|---|---|---|
transaction.id |
paypal_transaction.transaction_id | TEXT primary key. Stable per transaction. The upsert conflict target |
transaction.gross |
paypal_transaction.gross_amount | NUMERIC(19,4). The gross value before fees. Store as NUMERIC so cents never drift |
transaction.fee |
paypal_transaction.fee_amount | NUMERIC(19,4). The PayPal fee, arrives negative. Keep the sign as PayPal sends it |
| net (derived) | paypal_transaction.net_amount | NUMERIC(19,4). Computed as gross plus fee. Derive it once on load, do not re-key |
payer.email |
paypal_transaction.payer_email | TEXT. Flatten from the payer_info object. Joins to your customers table |
transaction.status |
paypal_transaction.status | TEXT. P, S, V, or D. Can change after creation, so re-pull recent windows |
transaction.created |
paypal_transaction.created_at | TIMESTAMPTZ. Parse the ISO 8601 initiation date, store in UTC, index it |
| currency | paypal_transaction.currency_code | CHAR(3). Three-letter ISO currency. Keep it beside every amount, never blend currencies |
The status codes are worth memorizing because reconciliation depends on them:
P is pending, S is successfully completed, V is reversed, and
D is denied. A sale can land as P, settle to S hours later, and
move to V if it is reversed, which is precisely why a one-time export is misleading. Refunds
and chargebacks do not overwrite the original row; PayPal records them as separate transaction events
with their own ids and their own (negative) amounts, so your net revenue for a period is the sum across
those events, not a single field. Model that as ordinary rows and the arithmetic stays honest. With the
settlement data modeled cleanly in Postgres you can join it to orders and, once net revenue is
reconciled, feed the reconciled net figures straight into a tool that
turns the ledger into board-ready financial statements without
re-reading PayPal for every refresh.
How do you sync PayPal incrementally instead of a full export?
Sync incrementally by re-reading a trailing date window on a schedule rather than replaying all of history. Store the end of your last successful window as a watermark, start the next run a little before it, page to the end, and upsert. Overlap is deliberate: because transactions appear a few hours late and change status after creation, re-reading recent days catches both new and updated rows.
The mechanics are a two-level loop. The outer loop steps the date range in windows the API will accept
(historically up to 31 days each), and the inner loop pages a single window with page and
page_size until it returns the last page. For steady-state incremental runs you do not need
the full three years; you need a trailing window wide enough to cover PayPal's ingestion delay and any
late status changes, commonly the last few days to a week. Set the window start to a few hours or a day
before your stored watermark, not exactly at it, so a transaction that only became visible after your
previous run is still inside the window this time. Then advance the watermark to the end of the window
you just read. Because the load is idempotent (the next section), overlapping windows cost nothing:
a row you have already seen updates itself in place. The one rule you cannot break is never assuming a
transaction is final on first sight, since a P today can be S or
V tomorrow.
How do you reconcile PayPal gross, fees, and net to your payout?
Reconcile by summing gross and fee across every transaction event in the settlement period and checking that gross plus fees plus refunds equals the net PayPal actually paid out. Because gross, fee, and net live in separate amount objects and refunds and chargebacks are their own rows, keep each as its own column, sum with SQL, and compare the total to the payout figure from PayPal.
Reconciliation is where the modeling pays off. A payout is a batch of transactions PayPal moves to your
bank, and its amount is the net of everything in the batch: sales gross, minus PayPal fees, minus refunds
and reversals in the window. If you stored only a single "amount" per row you cannot rebuild that number,
which is why gross and fee are separate NUMERIC(19,4) columns and net is derived rather than
trusted from a lone field. Group your paypal_transaction rows by settlement period,
SUM(gross_amount), SUM(fee_amount), and SUM(net_amount), and the
net sum should match the payout. When it does not, the usual causes are a currency you blended by
accident (always group by currency_code too), a status you counted that should have been
excluded (a denied D transaction never settled), or a window boundary that split a payout in
half. Keeping currency beside every amount and treating refunds as first-class rows makes the query a
one-liner, and a PayPal to BigQuery path
gives the same numbers to a warehouse if analytics lives there instead.
How do you keep a PayPal Postgres sync from creating duplicates?
Make the load idempotent. Give the table a primary key on the PayPal transaction_id, land
each page in a staging table, and upsert with INSERT ... ON CONFLICT (transaction_id) DO UPDATE
SET .... Because the id is stable, re-reading an overlapping date window or retrying a failed batch
updates the existing row in place rather than inserting a second copy, so the target converges to the
same state a clean run would produce.
This is the single most important property of the pipeline, because everything else about PayPal pushes
you toward overlap: windows have to overlap to cover the ingestion delay, status changes force you to
re-read recent days, and retries replay pages you already loaded. Without an upsert, each of those would
double a row. With one, they are free. The DO UPDATE clause is what lets a status change
actually land: when a P becomes S, the second read carries the same
transaction_id with a new status, and the upsert overwrites the status, the net, and the
loaded_at timestamp in place. Set DO UPDATE SET status = EXCLUDED.status,
net_amount = EXCLUDED.net_amount, loaded_at = now() and let the primary key do the deduplication.
Refunds and chargebacks never collide with the original because they carry different ids, so they insert
as new rows exactly as they should. Keep a loaded_at column on the table so freshness checks
and drift debugging are a one-line query, and the
managed PayPal to Postgres sync wires all
of this up without you writing the conflict clause by hand.
How often should you sync PayPal to your database?
For most finance and ops teams, every hour on a trailing window is the right steady state, with a wider nightly pass that re-reads the last week to catch late status changes. Match the interval to the decision the data drives: cash and revenue dashboards are happy hourly, while month-end reconciliation only needs a daily settled view. Syncing every few minutes buys little, because PayPal only surfaces transactions a few hours after they happen.
Backfilling is a different job from the steady state and should be built as one. Run the full historical load as a loop of adjacent date windows from your earliest reachable date (roughly three years back) to now, page each window to its end, record which windows completed so a failure at 80 percent resumes rather than restarts, and load into staging before you upsert. Only after the backfill lands should you set the incremental watermark, and set it to the start of the backfill rather than its end so anything that changed while the backfill ran gets picked up on the first incremental pass. Metered per-row pipeline pricing interacts badly with a busy sales month, since row counts climb exactly when you least want a surprise invoice. Our flat pricing is by plan rather than per row, so a heavy month does not change the bill. If you want to see the pair running against your own account before you wire anything up, book a walkthrough or map it yourself with the PayPal to Postgres integration.
Replicate PayPal transactions into Postgres without dropping or doubling rows
Bounded date windows with overlap, gross and fee as separate NUMERIC(19,4) columns, ISO currency codes, and ON CONFLICT upserts on the transaction id. Map the pair in the browser and pay a flat price from $49 a month.
No credit card required.