Payments

Daily Stripe reconciliation to Postgres with a Slack alert

Runs at 02:00, pages through yesterday's Stripe balance transactions, finds the ids with no row in stripe_events, logs the result and alerts Slack on a gap.

Trigger
Schedule Trigger, daily at 02:00
Nodes
7
Requires
Stripe (HTTP Header Auth), Postgres, Slack
Category
Payments
From the course
Stripe and Payment Automations
Published
2026-09-10

Node types used

  • Schedule Trigger
  • HTTP Request
  • Code
  • Postgres
  • If
  • Slack
Workflow diagram: Daily Stripe reconciliation to Postgres with a Slack alerttrueSchedule TriggerGet balance transacti…Get balance transactionsCollect matchable txn…Collect matchable txn idsFind unmatched txn idsWrite reconciliation …Write reconciliation logAny gap?Slack: reconciliation…Slack: reconciliation gap
TriggerActionLogicOutput

What it does

Webhooks get missed. n8n restarts mid delivery, a deploy takes the endpoint down for ninety seconds, an event lands while the database is locked. Nothing tells you, because the point of a webhook is that it is fire and forget. The only way to know your records match Stripe is to compare them on a schedule and look at the difference.

A Schedule Trigger fires daily at 02:00. An HTTP Request node calls GET https://api.stripe.com/v1/balance_transactions for the previous UTC day, using n8n's built-in pagination to follow starting_after until has_more is false. A Code node walks every page, keeps only the balance transaction types that could ever have a matching webhook event (charge, payment, refund and payment_refund, because payouts, fees, transfers and adjustments have no event and would alert every day), groups the surviving ids by currency so two currencies are never added together, and emits a zero-count row when the day was quiet so a quiet day is not mistaken for a broken check. A Postgres node unnests that id array and LEFT JOINs it against stripe_events on balance_transaction, returning the count and the array of ids that have no row. A second Postgres node writes run_date, currency, the Stripe count, the matched count, the missing count and the missing ids into stripe_reconciliation_log. An If node checks whether the missing count is greater than zero, and only then does a Slack node post the gap to a channel.

It reports gaps, it does not close them. Nothing here re-fetches a missing event from Stripe or backfills stripe_events, and the unmatched_in_events column is written as a literal 0 rather than calculated, so rows in your table with no matching Stripe transaction are not detected.

Node by node

  1. 1

    Schedule Trigger Fires once a day at hour 2.

  2. 2

    Get balance transactions GET https://api.stripe.com/v1/balance_transactions with created[gte] set to the start of yesterday UTC, created[lt] set to the start of today UTC, and limit 100. Pagination is on: each request sets starting_after to the last id of the previous response and stops when $response.body.has_more is false. Authenticates with a generic HTTP Header Auth credential.

  3. 3

    Collect matchable txn ids Flattens every page into one list, keeps only transactions whose type is charge, payment, refund or payment_refund, groups their ids by currency, and returns one item per currency with run_date, currency, the id array and the count. If nothing matched it returns a single zero-count row so the day still gets a log entry.

  4. 4

    Find unmatched txn ids Runs SELECT count(*) AS missing_in_events and array_agg of the ids over unnest($1::text[]) LEFT JOIN stripe_events ON e.balance_transaction = bt.txn_id WHERE e.id IS NULL. The id array is passed as a Postgres array literal built in the query replacement expression.

  5. 5

    Write reconciliation log Inserts run_date, currency, stripe_txn_count, matched_count (the Stripe count minus the missing count), missing_in_events, a hardcoded 0 for unmatched_in_events, and the missing ids joined into a notes string, into stripe_reconciliation_log.

  6. 6

    Any gap? An If node that tests missing_in_events greater than 0. The false output is empty, so a clean day ends silently with the log row already written.

  7. 7

    Slack: reconciliation gap Posts to the channel named in the node saying how many of the day balance transactions, in which currency, have no row in stripe_events.

Setup after import

  1. 1

    Create an HTTP Header Auth credential holding Authorization: Bearer plus your Stripe secret key, and select it on Get balance transactions.

  2. 2

    Create a Postgres credential and select it on both Postgres nodes.

  3. 3

    Create stripe_reconciliation_log with run_date (date), currency, stripe_txn_count, matched_count, missing_in_events, unmatched_in_events and notes.

  4. 4

    Make sure stripe_events exists and its balance_transaction column is populated. The webhook signature verification template writes it.

  5. 5

    Create a Slack credential and change the channel on Slack: reconciliation gap from the default #alerts to your own.

  6. 6

    Check the 02:00 hour against your n8n timezone. The query window is calculated in UTC, so a large offset means the run covers a day that is not the one you expect.

Limits

  • One direction only. It finds Stripe transactions missing from your table, not rows in your table missing from Stripe.

  • unmatched_in_events is written as a literal 0 and is never calculated.

  • Only four balance transaction types are compared. Payouts, fees, transfers and adjustments are ignored on purpose.

  • It alerts on a gap. It does not backfill the missing events.

Download the workflow

The 7 node export as n8n reads it. Credential ids, personal values and real endpoints are replaced with placeholders, so nothing here can reach an account that is not yours. No email, no account.

Download stripe-daily-reconciliation-postgres.json

Get help with this in the community

If the import fails, if a node errors on the first run, or if you want to point this at a service it does not cover yet, post it in the free House of Loops community. Say which template it is and paste the error. Shannon Atkinson answers, and the answer stays there for the next person who hits the same thing.

Ask in the community