Skip to content
n8nStripeGoogle SheetsTutorial

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 Makhija
11 min read

TL;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

  1. 1Stripe Trigger — registers a webhook with Stripe automatically and fires the moment a payment succeeds.
  2. 2Set — flattens the nested event payload into the flat columns a spreadsheet actually wants, converting money and timestamps on the way.
  3. 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:

EventFires whenHas customer email?
checkout.session.completedA Stripe Checkout session is paidYes — customer_details.email
payment_intent.succeededAny PaymentIntent succeedsOnly if receipt_email was set
charge.succeededA charge is capturedIn billing_details.email, sometimes
invoice.paidA subscription invoice is paidYes — 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.

Set node — field expressions
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.

stripe-to-sheets.json
{
  "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

SymptomCauseFix
Every amount is 100x too bigStripe sends the smallest currency unitDivide by 100 (except zero-decimal currencies)
All dates show 1970Unix seconds treated as millisecondsMultiply created by 1000
Two rows per paymentSubscribed to overlapping eventsListen to one event only
Rows reappear hours laterStripe retrying a webhook it thinks failedUse Append or Update matched on Event ID
Email column always blankWrong event for the data you wantUse checkout.session.completed or invoice.paid
Nothing arrives from real paymentsWorkflow registered with a test-mode keySwap to a live restricted key and re-activate
Execution fails on one paymentA field Stripe omitted, accessed without ?.Add optional chaining and ?? '' fallbacks

Worth doing next

  • Log refunds too. A second workflow on charge.refunded writing 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_failed into 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.

About the author

Ritik Makhija

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.

Send a message