How to Sync Stripe Payments to Google Sheets Using n8n
The naive version of this workflow logs every payment twice, off by a factor of 100, with a blank email column. Here's the build that doesn't — and why each of those three things happens.
Ritik MakhijaTL;DR
Use the Stripe Trigger node to subscribe to a payment event, a Set node to flatten and convert the payload, and a Google Sheets "Append or Update Row" node matched on the Stripe event ID. Matching on event ID rather than plain appending is what makes the workflow safe when Stripe redelivers a webhook, which it will. Divide amounts by 100, convert the Unix timestamp, and listen to checkout.session.completed rather than payment_intent.succeeded if you need the customer's email.
Key takeaways
- Stripe amounts are in the smallest currency unit — 4999 means $49.99. Divide by 100, but not for zero-decimal currencies like JPY.
- Stripe retries webhooks. Use "Append or Update Row" matched on the event ID so a redelivery overwrites rather than duplicates.
- `payment_intent.succeeded` does not reliably carry a customer email; `checkout.session.completed` does, in `customer_details.email`.
- Subscribing to both `payment_intent.succeeded` and `charge.succeeded` logs every payment twice — pick one.
- Test and live mode use separate API keys and register separate webhooks. A workflow tested in test mode is not live until it's activated with a live key.
Stripe's dashboard is good. It's just not where your team does anything else. Finance wants payments next to the invoice tracker, ops wants them beside the fulfilment queue, and the founder wants a number they can pivot without exporting a CSV every Monday.
So you sync payments into a Google Sheet. It's a two-node workflow that takes ten minutes, and the ten-minute version is wrong in three specific ways. This walks through the version that isn't.
What you're building
- 1Stripe Trigger — registers a webhook with Stripe automatically and fires the moment a payment succeeds.
- 2Set — flattens the nested event payload into the flat columns a spreadsheet actually wants, converting money and timestamps on the way.
- 3Google Sheets (Append or Update Row) — writes the row, keyed on the Stripe event ID so redeliveries can't duplicate it.
Unlike a Sheets trigger, this one is push-based. Stripe calls you, so it lands in about a second.
Step 1 — Pick the right event
This decision determines your whole payload shape, so make it deliberately. Stripe fires several events for one payment, and they overlap:
| Event | Fires when | Has customer email? |
|---|---|---|
checkout.session.completed | A Stripe Checkout session is paid | Yes — customer_details.email |
payment_intent.succeeded | Any PaymentIntent succeeds | Only if receipt_email was set |
charge.succeeded | A charge is captured | In billing_details.email, sometimes |
invoice.paid | A subscription invoice is paid | Yes — customer_email |
If you sell through Stripe Checkout or Payment Links, use checkout.session.completed. If you're on subscriptions, use invoice.paid. Use payment_intent.succeeded only if you built a custom payment flow and know what you set on it.
Step 2 — Connect Stripe
In Stripe, go to Developers → API keys. Don't grab the standard secret key — create a restricted key instead, and give it write access to *Webhook endpoints* plus read access to whatever objects you're reading. The Stripe Trigger node registers and deregisters the webhook itself, which is why it needs webhook write; it does not need permission to move money, so don't give it any.
Paste that key into a new Stripe API credential in n8n, add a Stripe Trigger node, and select your event.
Step 3 — Fix the payload
Click Listen for Test Event, then run a test payment in Stripe. What comes back is the full event object — nested, verbose, and in units your spreadsheet will misread. Add a Set node (Edit Fields) and build the flat row.
Money is in cents
Stripe reports amounts in the smallest currency unit. A $49.99 charge arrives as 4999. Dropping that straight into a sheet gives you a revenue column inflated by a hundred, which someone eventually pastes into a board deck.
Divide by 100 — with one caveat worth knowing before you sell internationally. Zero-decimal currencies (JPY, KRW, VND among others) are already whole units, so ¥5000 arrives as 5000 and dividing gives you ¥50. If you take payments in those currencies, branch on currency rather than dividing blindly.
Timestamps are Unix seconds
Stripe's created is Unix time in seconds; JavaScript expects milliseconds. Multiply by 1000 before converting, or you'll log every payment as happening in January 1970.
eventId {{ $json.id }}
paidAt {{ new Date($json.created * 1000).toISOString() }}
sessionId {{ $json.data.object.id }}
email {{ $json.data.object.customer_details?.email ?? '' }}
name {{ $json.data.object.customer_details?.name ?? '' }}
amount {{ $json.data.object.amount_total / 100 }}
currency {{ $json.data.object.currency.toUpperCase() }}
orderId {{ $json.data.object.metadata?.order_id ?? '' }}
mode {{ $json.livemode ? 'live' : 'test' }}The optional chaining (?.) and ?? '' fallbacks aren't decoration. Stripe omits fields rather than sending nulls, and a missing customer_details on one guest checkout will otherwise throw and fail the whole execution — losing a payment record over a blank name field.
That metadata line is the one to plan ahead on. Anything you attach as metadata when creating the Checkout session — your own order ID, a plan name, the referral source — comes straight back on the event. It's the only reliable way to reconcile a Stripe payment against your own records later.
Step 4 — Write to the sheet, idempotently
Create a sheet with a header row matching your fields exactly: Event ID, Paid At, Session ID, Email, Name, Amount, Currency, Order ID, Mode.
Now the important part. Add a Google Sheets node and set the operation to Append or Update Row, not *Append Row*. Set Column to Match On to Event ID.
Here's why. Stripe guarantees at-least-once delivery, not exactly-once. If your n8n instance is slow to respond, is redeploying, or returns a 500, Stripe retries the same event — for up to three days on live mode. With a plain append, each retry adds a row and your revenue total quietly climbs. With append-or-update matched on the event ID, a redelivery finds the existing row and overwrites it with identical data. Nothing changes. That's the whole trick, and it costs you nothing.
The full workflow
Import via Workflows → Import from Clipboard, then re-select your credential and spreadsheet in each node.
{
"name": "Stripe payments to Google Sheets",
"nodes": [
{
"parameters": { "events": ["checkout.session.completed"] },
"id": "d1e2f3a4-stripe-trigger",
"name": "Payment completed",
"type": "n8n-nodes-base.stripeTrigger",
"typeVersion": 1,
"position": [-240, 0],
"webhookId": "REPLACE_ME",
"credentials": { "stripeApi": { "id": "REPLACE_ME", "name": "Stripe account" } }
},
{
"parameters": {
"assignments": {
"assignments": [
{ "id": "f1", "name": "Event ID", "value": "={{ $json.id }}", "type": "string" },
{
"id": "f2",
"name": "Paid At",
"value": "={{ new Date($json.created * 1000).toISOString() }}",
"type": "string"
},
{
"id": "f3",
"name": "Session ID",
"value": "={{ $json.data.object.id }}",
"type": "string"
},
{
"id": "f4",
"name": "Email",
"value": "={{ $json.data.object.customer_details?.email ?? '' }}",
"type": "string"
},
{
"id": "f5",
"name": "Name",
"value": "={{ $json.data.object.customer_details?.name ?? '' }}",
"type": "string"
},
{
"id": "f6",
"name": "Amount",
"value": "={{ $json.data.object.amount_total / 100 }}",
"type": "number"
},
{
"id": "f7",
"name": "Currency",
"value": "={{ $json.data.object.currency.toUpperCase() }}",
"type": "string"
},
{
"id": "f8",
"name": "Order ID",
"value": "={{ $json.data.object.metadata?.order_id ?? '' }}",
"type": "string"
},
{
"id": "f9",
"name": "Mode",
"value": "={{ $json.livemode ? 'live' : 'test' }}",
"type": "string"
}
]
},
"includeOtherFields": false,
"options": {}
},
"id": "e2f3a4b5-normalise",
"name": "Build the row",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [-20, 0]
},
{
"parameters": {
"operation": "appendOrUpdate",
"documentId": { "__rl": true, "value": "YOUR_SPREADSHEET_ID", "mode": "id" },
"sheetName": { "__rl": true, "value": "gid=0", "mode": "list", "cachedResultName": "Payments" },
"columns": {
"mappingMode": "autoMapInputData",
"matchingColumns": ["Event ID"],
"value": {},
"schema": []
},
"options": { "cellFormat": "USER_ENTERED" }
},
"id": "f3a4b5c6-sheets",
"name": "Append or update payment",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [200, 0],
"credentials": { "googleSheetsOAuth2Api": { "id": "REPLACE_ME", "name": "Google Sheets account" } }
}
],
"connections": {
"Payment completed": { "main": [[{ "node": "Build the row", "type": "main", "index": 0 }]] },
"Build the row": {
"main": [[{ "node": "Append or update payment", "type": "main", "index": 0 }]]
}
},
"settings": { "executionOrder": "v1" },
"pinData": {}
}The Sheets node uses autoMapInputData, which maps incoming fields to columns by name — that's why the Set node names its fields Event ID and Paid At rather than eventId and paidAt. Rename a column in the sheet and the mapping silently stops filling it, so keep the two in sync.
What breaks, and why
| Symptom | Cause | Fix |
|---|---|---|
| Every amount is 100x too big | Stripe sends the smallest currency unit | Divide by 100 (except zero-decimal currencies) |
| All dates show 1970 | Unix seconds treated as milliseconds | Multiply created by 1000 |
| Two rows per payment | Subscribed to overlapping events | Listen to one event only |
| Rows reappear hours later | Stripe retrying a webhook it thinks failed | Use Append or Update matched on Event ID |
| Email column always blank | Wrong event for the data you want | Use checkout.session.completed or invoice.paid |
| Nothing arrives from real payments | Workflow registered with a test-mode key | Swap to a live restricted key and re-activate |
| Execution fails on one payment | A field Stripe omitted, accessed without ?. | Add optional chaining and ?? '' fallbacks |
Worth doing next
- Log refunds too. A second workflow on
charge.refundedwriting to the same sheet — with a negative amount — keeps the total honest. - Alert on the big ones. An IF node after the Set node, plus a Slack post when the amount clears a threshold. That's the Sheets-to-Slack pattern in reverse.
- Handle failures.
payment_intent.payment_failedinto a separate tab tells you about churn risk before the customer emails you. - Set an error workflow. Under Settings → Error Workflow, point at something that notifies you. A payment log that silently stops recording is worse than no log at all, because you'll trust it.
Once the sheet is more than a few thousand rows, be honest about what it's for. Sheets is a good reporting surface and a bad database — the Sheets API will start rate-limiting you, and a single sync gap becomes hard to detect. At that point the sheet should be a view onto a real store, not the store itself.
If you want this built with the retry handling, alerting and reconciliation already in place, that's the job. Tell me what you're selling and I'll tell you which events you actually need.
Frequently asked questions
Use a Stripe Trigger node subscribed to a single payment event such as checkout.session.completed, a Set node to flatten the event into flat columns (dividing the amount by 100 and converting the Unix timestamp), and a Google Sheets node using Append or Update Row matched on the Stripe event ID. Matching on event ID makes the workflow safe against Stripe's webhook retries.
Stripe reports amounts in the smallest currency unit, so $49.99 arrives as 4999. Divide by 100 in a Set node expression. The exception is zero-decimal currencies such as JPY, KRW and VND, which are already whole units — dividing those by 100 makes them a hundred times too small. If you take payments in multiple currencies, branch on the currency field rather than dividing unconditionally.
Stripe guarantees at-least-once delivery and retries events it believes failed, for up to three days. Use the Google Sheets "Append or Update Row" operation with Column to Match On set to the Stripe event ID, so a redelivered event overwrites the existing row instead of adding a new one. Where the destination can't match on a key, put a Remove Duplicates node set to "Remove Items Seen in Previous Executions" ahead of it.
For Stripe Checkout or Payment Links, use checkout.session.completed — it includes customer_details.email. For subscriptions, use invoice.paid. Use payment_intent.succeeded only for custom payment flows, and note it carries an email only if receipt_email was set. Never subscribe to two overlapping events like payment_intent.succeeded and charge.succeeded, because both fire for the same payment.
No, and it shouldn't have one. Create a restricted key in Stripe with write access to Webhook endpoints — the trigger registers and removes its own webhook — plus read access to the objects you're reading. It never needs permission to create charges or move money, so don't grant it.
About the author

Ritik Makhija
Founder & Product Lead · AI Kaptan
I build AI agents and automation that run in production — and I've open-sourced 5,000+ workflows so you can read the work rather than take my word for it. I run outreach infrastructure sending 6,000 emails a day on this stack, and I've mentored 700+ builders 1:1.
Got a process you're trying to automate?
Tell me what it is and I'll say straight whether it needs an agent, a plain workflow, or nothing at all. Free 30 minutes, no pitch.