This is the workflow most finance teams eventually build by hand, badly. It takes the three Stripe events that actually matter for your books (charge.succeeded, charge.refunded, payout.paid), routes each one down its own path, finds or creates the matching Xero record, separates the Stripe processing fee into its own expense line so your net deposit ties out exactly, and posts a Slack alert only when the numbers genuinely disagree. Every event is logged to an audit table before and after processing, so nothing is ever lost silently.
Workflow diagram
flowchart TD
A[Stripe Webhook] --> B[Log Raw Event]
B --> C[Validate Signature]
C --> D{Signature Valid?}
D -->|No| E[Reject]
D -->|Yes| F{Route Event Type}
F -->|charge.succeeded| G[Find Xero Invoice]
F -->|charge.refunded| H[Find Invoice For Refund]
F -->|payout.paid| I[Reconcile Bank Feed]
G --> J{Invoice Exists?}
J -->|Yes| K[Update Invoice]
J -->|No| L[Create Invoice]
K --> M[Calculate Net Amount]
L --> M
M --> N[Post Stripe Fee Expense]
H --> O[Create Credit Note]
N --> P[Merge All Cases]
O --> P
I --> P
P --> Q{Amounts Match?}
Q -->|No| R[Slack Mismatch Alert]
Q -->|Yes| S[No Action]
R --> T[Log Reconciliation Entry]
S --> T Every branch shown here (IF/Switch outcomes) exists as a real conditional in the downloadable JSON, not a simplification for this diagram.
Why reconciliation breaks without automation
The arithmetic problem at the heart of Stripe reconciliation is simple to state and tedious to solve: Stripe charges a customer a gross amount, deducts a processing fee, and deposits the net. Your accounting system records the gross invoice. Your bank feed shows the net deposit. Those two numbers will never match on their own, and closing the gap manually means opening a Stripe payout report, exporting a CSV, and matching line by line against Xero invoices.
For a business processing a few dozen transactions a month, that is an annoying afternoon. At a few hundred transactions, it is a recurring multi-day task that someone dreads. And the failure mode is not that it takes long, it is that under time pressure people start matching in bulk and stop checking edge cases. Partial refunds, disputed charges, multi-currency payouts and subscription proration are exactly the transactions most likely to be wrong, and exactly the ones a rushed manual reconciliation glosses over.
The deeper issue is that manual reconciliation produces no audit trail of its own. If a number is wrong three months later, there is no record of what was matched against what, or why. This workflow treats the audit log as a first-class output, not an afterthought: every raw event is written to a log table before processing begins, and every reconciliation outcome is written again after it completes.
Architecture: why the branches are structured this way
The workflow has a deliberate shape. A single webhook entry point receives all Stripe events, but the very first thing it does is log the raw payload, before any validation or business logic. This matters: if a later node throws, the raw event is already on disk and the execution can be replayed from that point rather than lost. This is the single most common structural mistake in hand-built n8n reconciliation flows, validation and processing happen before any durable record exists, so a failure means the event is simply gone.
Signature verification comes second, not first, for the same reason. An invalid signature is still worth logging (it may indicate a misconfigured endpoint or an attack), but it must never reach Xero. The IF node after verification dead-ends invalid requests into a no-op rather than throwing, so a burst of bad requests does not fill your execution error log.
The Switch node then fans out into three genuinely different code paths. A successful charge needs an invoice lookup, a possible create, a fee calculation and a fee expense line. A refund needs a credit note against an existing invoice. A payout needs bank-feed line reconciliation. Trying to handle all three in one linear path with nested IFs is how these workflows become unmaintainable, the Switch keeps each concern visually and logically separate, and the Merge node at the end rejoins them into one stream for the shared mismatch-check and audit-log logic.
The fee separation logic that makes the books tie out
The Calculate Net Amount node is short but it is the entire point of the workflow. Stripe reports a gross amount and a fee. Xero needs three things to reconcile cleanly: an invoice for the gross amount, a bank transaction for the net deposit, and an expense entry for the fee. Most naive integrations record only the gross, which leaves a permanent unexplained gap between the invoice total and the bank feed.
By posting the Stripe fee to a dedicated expense account (Post Stripe Fee Expense), the bank feed reconciliation in Xero becomes a one-click confirm rather than a manual investigation. Your accountant sees processing fees as a proper, categorised business expense rather than a mysterious shortfall, which also means the number is available for actual analysis: fee as a percentage of revenue, fee trend over time, effect of a pricing or payment-method change.
Failure handling and what happens when a step breaks
The Mismatch Check IF node is the workflow's safety net. After all three branches rejoin, it compares the calculated net amount against what Xero actually recorded. If they agree, execution proceeds straight to the audit log with no noise. If they disagree, a Slack alert fires with the order context attached, and execution still continues to the audit log so the mismatch itself is recorded.
This is an important design choice: the alert path does not terminate the workflow. Both branches merge back together before the final log node, so the audit table contains a complete record of every event, reconciled or not. A workflow that alerts and stops leaves gaps in its own history exactly where the interesting data is.
In n8n, set the workflow's error workflow to a shared handler if you run several of these. Node-level retry (Settings → Retry on Fail) is worth enabling on the Xero HTTP nodes specifically, since Xero's API rate-limits aggressively and a brief 429 should not escalate to a human.
Node-by-node reference
| Node | Type | Role |
|---|---|---|
| Stripe Webhook | Webhook | Entry point for charge.succeeded, charge.refunded, payout.paid |
| Log Raw Event | HTTP Request | Writes the untouched payload to an audit table before any processing |
| Validate Webhook Signature | Code | HMAC-SHA256 verification against the Stripe signing secret |
| Signature Valid? | IF | Gates everything downstream, invalid requests dead-end |
| Route Event Type | Switch | Fans out into three independent processing paths |
| Find Xero Invoice | HTTP Request | Looks up an existing invoice by reference |
| Invoice Exists? | IF | Decides between update and create |
| Update / Create Xero Invoice | HTTP Request ×2 | The two halves of the upsert |
| Calculate Net Amount | Set | gross − Stripe fee = actual bank deposit |
| Post Stripe Fee Expense | HTTP Request | Writes the fee as its own categorised expense line |
| Create Xero Credit Note | HTTP Request | Refund path, offsets the original invoice |
| Reconcile Bank Feed Line | HTTP Request | Payout path, matches the deposit in Xero |
| Merge All Cases | Merge | Rejoins all three branches into one stream |
| Mismatch Check | IF | Compares calculated net against recorded Xero amount |
| Post Slack Mismatch Alert | HTTP Request | Fires only on genuine discrepancies |
| Log Reconciliation Entry | HTTP Request | Final audit row for every processed event |
24 total nodes in the downloadable file, including sticky-note documentation embedded directly on the canvas.
Key logic, in code
Stripe signature verification (Validate Webhook Signature node)
const crypto = require('crypto');
const sigHeader = $input.first().json.headers['stripe-signature'];
const rawBody = JSON.stringify($input.first().json.body);
const secret = $env.STRIPE_WEBHOOK_SECRET;
const parts = Object.fromEntries(
sigHeader.split(',').map(p => p.split('='))
);
const signedPayload = `${parts.t}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Constant-time comparison avoids leaking timing information
const signatureValid = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1)
);
return [{ json: { ...$input.first().json, signatureValid } }]; Net amount calculation (Calculate Net Amount node)
// Stripe reports amounts in the smallest currency unit (cents)
const gross = $json.data.object.amount / 100;
const fee = $json.data.object.application_fee_amount
? $json.data.object.application_fee_amount / 100
: (gross * 0.029 + 0.30); // fallback to standard Stripe pricing
return [{
json: {
...$json,
grossAmount: gross,
stripeFee: Number(fee.toFixed(2)),
netAmount: Number((gross - fee).toFixed(2)),
currency: $json.data.object.currency.toUpperCase(),
}
}]; Before / after
| Metric | Before | After this workflow |
|---|---|---|
| Monthly reconciliation time | 4–8 hours | Under 15 minutes of review |
| Time to close the books | End of month scramble | Same-day, continuously |
| Unexplained bank feed gaps | Common, fees not separated | Zero, fees posted as expense |
| Audit trail | None beyond the CSV export | Every event logged twice, raw and resolved |
Prerequisites
- A self-hosted or cloud n8n instance (v1.40+ recommended for the Switch v3 node)
- Stripe account with webhook endpoint access and the signing secret
- Xero account with OAuth2 app credentials and the accounting.transactions scope
- A Slack workspace with a bot token carrying the chat:write scope
- An Airtable base (or substitute any database node) for the two audit tables
Common pitfalls
Do not skip signature verification in production
A webhook endpoint without signature verification accepts any POST from anyone who discovers the URL. For a workflow that writes to your accounting system, that is a genuinely serious exposure, not a theoretical one.
Xero rate-limits harder than most APIs
Xero enforces both a per-minute and a daily call limit. Enable node-level retry with backoff on all Xero HTTP nodes, and if you process high volume, add a Split In Batches node ahead of the Xero calls.
Multi-currency needs explicit handling
The template assumes a single currency. If you take payments in several, the Calculate Net Amount node must convert using the rate at settlement date, not the rate at charge date, or your books will drift.
Test with Stripe CLI before going live
Run stripe trigger charge.succeeded against your development webhook URL first. Reconciliation bugs discovered in production are expensive to unwind because they have already written to your ledger.
Want this deployed, configured and monitored?
The template is free. Wiring in your real credentials, tuning the logic to your business, and keeping it running when an upstream API changes is what we do.
Get my free automation plan →Frequently asked questions
Does this work with QuickBooks instead of Xero?
The structure is identical, only the four Xero HTTP Request nodes change to QuickBooks endpoints. The signature verification, routing, fee calculation and alerting logic are unchanged.
How does it handle partial refunds?
The charge.refunded branch creates a Xero credit note for the refunded amount specifically, not the full invoice, so partial refunds offset correctly rather than zeroing out the original sale.
What happens if Xero is down when an event arrives?
The raw event is already logged by node 2, so nothing is lost. Enable retry on the Xero nodes, and failed executions can be replayed from the log table once Xero recovers.
Can I run this on n8n Cloud?
Yes. The only node requiring care is Validate Webhook Signature, which uses the crypto module, available in n8n Cloud's Code node by default.