Sync Shopify Orders to Google Sheets in Real-Time (No Zapier)
Build a real-time Shopify to Google Sheets sync with a signed webhook, a small Node.js receiver and the Google Sheets API. Every step checked against the Shopify and Google documentation as of September 2026.
Zapier bills every order as a task, so the monthly cost grows with your shop. A no-code Zap gives you no control over signature checks, duplicates or historical orders.
A direct webhook integration: Shopify signs each delivery, a small Node.js receiver verifies it and writes the order to Google Sheets through the Sheets API. No per-task fees and full control over retries and data protection.
Why not Zapier and why not Apps Script
Every Shopify store owner hits the same wall: you need order data in Google Sheets for inventory, fulfillment or reporting. Zapier works, but every order consumes at least one task and multi-step Zaps consume several. According to the Zapier pricing page, the Free plan includes 100 tasks per month and the Professional plan starts at 19.99 USD per month billed annually (29.99 USD billed monthly) for 750 tasks, with higher task tiers priced accordingly. The bill grows with your shop and you still have no control over signature checks, duplicates or historical orders.
Many tutorials recommend a Google Apps Script web app as the webhook receiver. That does not work reliably with Shopify: Apps Script answers every request with a redirect to script.googleusercontent.com and expects the client to follow it (Google Apps Script docs), while Shopify treats any response outside the 200 range, including 3xx, as a failed delivery (Shopify webhook docs). On top of that, the Apps Script doPost event object exposes only the query string, parameters and body, not the request headers (Apps Script web apps), so the HMAC signature cannot be verified.
This webhook-based integration is the setup we use in our Integration service. Unlike brittle Zapier workflows, native Shopify webhooks are documented and versioned by Shopify, so you control when a version change affects you.
The Architecture: Shopify Webhook, Node.js Receiver, Google Sheets
Here is the production-ready setup we use in our automation implementations:
Flow:
- A new order is created in Shopify and Shopify sends a signed webhook to your receiver
- The receiver verifies the
X-Shopify-Hmac-Sha256header against the raw body - The receiver checks whether the order ID is already in the sheet (idempotency)
- The receiver appends one row through the Google Sheets API
- The receiver answers
200 OKwithin Shopify’s five-second window
What you need: a Shopify store, a Google account with a Google Cloud project and any host that gives your Node.js receiver a public HTTPS URL (your own server, Cloud Run, a container platform). Shopify only delivers webhooks to HTTPS endpoints.
Retries: Shopify retries a failed delivery 8 times over 4 hours and deletes the subscription after repeated failures (Shopify webhook docs).
API version: Shopify releases a new version every three months and supports each stable version for at least 12 months. As of September 2026 the latest stable version is 2026-07, 2026-10 is the release candidate (Shopify API versioning).
Step 1: Create the Google Sheet and a Service Account
First, create your order tracking sheet:
- Open Google Sheets and create a new spreadsheet
- Name the first tab “Shopify Orders”
- Add these column headers in row 1:
Order ID | Order Number | Created At | Customer Name | Customer Email | Line Items | Subtotal | Tax | Shipping | Total | Payment Status | Fulfillment Status | Tags | Notes
- Copy the spreadsheet ID from the URL (the long string between
/d/and/edit)
The receiver writes through the Sheets API with a service account, so it needs no browser login (Google service account setup):
- In the Google Cloud console open IAM & Admin and then Service Accounts, click Create service account, give it a name and click Done
- Open the service account, click the Keys tab, then Add key and Create new key, choose JSON and download the file. Google gives you this file only once
- Enable the Google Sheets API for the project in APIs & Services
- Share the spreadsheet with the service account’s email address as an editor, exactly like sharing with a person
Pro tip: Keep the column order consistent. The receiver writes data in this exact order.
Step 2: Build the Webhook Receiver
Create a small Node.js project with express and googleapis. The receiver reads the raw body, because the HMAC is computed over the exact bytes Shopify sent, compares it in constant time and only then parses the JSON. Shopify computes a base64-encoded HMAC-SHA256 of the raw body with the webhook secret (Shopify webhook docs).
import crypto from 'node:crypto';
import express from 'express';
import { google } from 'googleapis';
const SHOPIFY_WEBHOOK_SECRET = process.env.SHOPIFY_WEBHOOK_SECRET;
const SPREADSHEET_ID = process.env.SPREADSHEET_ID;
const SHEET_NAME = process.env.SHEET_NAME || 'Shopify Orders';
const auth = new google.auth.GoogleAuth({
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});
const sheets = google.sheets({ version: 'v4', auth });
export async function appendRow(row) {
const existing = await sheets.spreadsheets.values.get({
spreadsheetId: SPREADSHEET_ID,
range: `${SHEET_NAME}!A2:A`,
});
const knownIds = (existing.data.values || []).map((cells) => String(cells[0]));
if (knownIds.includes(String(row[0]))) {
return false;
}
await sheets.spreadsheets.values.append({
spreadsheetId: SPREADSHEET_ID,
range: `${SHEET_NAME}!A1`,
valueInputOption: 'RAW',
insertDataOption: 'INSERT_ROWS',
requestBody: { values: [row] },
});
return true;
}
function rowFromWebhook(order) {
const lineItems = order.line_items
.map((item) => `${item.quantity}x ${item.name} (${item.price})`)
.join(' | ');
const customerName = order.customer
? `${order.customer.first_name || ''} ${order.customer.last_name || ''}`.trim()
: 'Guest';
return [
String(order.id),
order.order_number,
order.created_at,
customerName,
order.email || '',
lineItems,
order.subtotal_price,
order.total_tax,
order.total_shipping_price_set?.shop_money?.amount || '0.00',
order.total_price,
order.financial_status,
order.fulfillment_status || 'unfulfilled',
order.tags,
order.note || '',
];
}
function isValidSignature(rawBody, headerValue) {
const digest = crypto
.createHmac('sha256', SHOPIFY_WEBHOOK_SECRET)
.update(rawBody)
.digest('base64');
const received = Buffer.from(headerValue || '', 'base64');
const expected = Buffer.from(digest, 'base64');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
app.post(
'/webhooks/shopify/orders',
express.raw({ type: 'application/json' }),
async (req, res) => {
if (!isValidSignature(req.body, req.get('X-Shopify-Hmac-Sha256'))) {
return res.status(401).send('Invalid signature');
}
const order = JSON.parse(req.body.toString('utf8'));
try {
await appendRow(rowFromWebhook(order));
return res.status(200).send('OK');
} catch (error) {
console.error(`Order ${order.id} not written: ${error.message}`);
return res.status(500).send('Retry');
}
}
);
app.listen(process.env.PORT || 8080);
Set four environment variables before starting: SHOPIFY_WEBHOOK_SECRET (you get it in step 3), SPREADSHEET_ID, SHEET_NAME and GOOGLE_APPLICATION_CREDENTIALS with the path to the downloaded JSON key. The Google client library finds the key through that variable (Application Default Credentials).
Three details in this code matter:
- The order ID is stored as a string with
valueInputOption: 'RAW', so Sheets never reformats it and the duplicate check compares exact values (Sheets API values guide). - Every webhook header includes the payload’s API version in
X-Shopify-API-Versionand a delivery ID inX-Shopify-Webhook-Id(webhook delivery structure). The receiver deduplicates by order ID instead, which also covers the backfill below. - A
500response makes Shopify retry the delivery, a200closes it. Do not answer200before the row is written.
The webhook body is the full REST order resource, so field names like order_number, line_items[].price or total_shipping_price_set come from the Order resource. That payload format is unaffected by the REST Admin API being legacy.
Step 3: Create the Shopify Webhook
You do not need an app to receive orders. Webhooks created in the Shopify admin are signed with a secret that is unique to your store and shown on the same page (Shopify Help Center: webhooks):
- Log into your Shopify admin
- Go to Settings and then Notifications
- Open the Webhooks section
- Click Create webhook
- Configure:
- Event: order creation (topic
orders/create) - Format: JSON
- URL:
https://your-host/webhooks/shopify/orders - Webhook API version:
2026-07
- Event: order creation (topic
- Click Save
- Copy the signing secret shown below the webhook list and set it as
SHOPIFY_WEBHOOK_SECRETon your receiver, then restart the receiver
Pin the version deliberately. Shopify keeps 2026-07 accessible until July 2027. When you move to a newer version, check the release notes for payload changes first.
Step 4: Test the Integration
Let’s verify everything works:
- On the webhook in Settings and then Notifications, click Send test. Your receiver must answer
200and a test row should appear in the sheet - Create a real test order: go to Orders, click Create order, add a product and a customer and complete the order
- Check your Google Sheet, the new row should appear within a few seconds
- Check your receiver logs for errors
Debugging tip: If nothing appears, check:
- Receiver logs for
Invalid signature: the secret was copied from the wrong place or the raw body was altered by a JSON middleware before the check - The response time: Shopify allows one second to connect and five seconds for the whole request
- The Google side: the sheet is shared with the service account email, the Sheets API is enabled and the tab name matches
SHEET_NAME
Step 5: Retries, Duplicates and Reconciliation
Three behaviours documented by Shopify shape how a production receiver must behave (Shopify webhook docs and subscribe over HTTPS):
- Retries: after no response or an error Shopify retries 8 times over the next 4 hours. Every retry carries the same order, so the duplicate check by order ID in
appendRowis what keeps the sheet clean. Shopify also recommendsX-Shopify-Webhook-Idfor deduplication when you store deliveries elsewhere. - No ordering guarantee: Shopify does not guarantee ordering within a topic. If you later add
orders/updated, compareupdated_atbefore overwriting a row. - No delivery guarantee: Shopify states that apps should not rely on webhooks alone and recommends a reconciliation job. The backfill script below doubles as that job when you run it daily with a
created_atfilter for the last few days.
Google’s side has limits too: the Sheets API allows 300 write requests per minute per project and 60 per user per minute. Beyond that it returns 429 and Google recommends exponential backoff (Sheets API limits). A shop with a few thousand orders per day stays well inside that, a flash sale may not. If your receiver sees 429, answer 500 and let Shopify’s retry schedule spread the load.
Need help setting up automated monitoring for your integrations? We can alert you before issues affect customers.
Advanced: Backfill Historical Orders with the GraphQL Admin API
Webhooks only cover orders from now on. For everything before that you need the Admin API. Shopify has declared the REST Admin API legacy as of October 1, 2024, with all new public apps required to use GraphQL since April 1, 2025 (REST Admin API). Use GraphQL.
Since January 1, 2026 you can no longer create custom apps inside the Shopify admin; new custom apps are created in the Dev Dashboard and there is no access token to copy anymore. Your script exchanges the app’s client ID and client secret for a token that is valid for 24 hours (client credentials grant, Help Center: custom apps):
- In your Shopify admin go to Settings, then Apps, then Develop apps and Build apps in Dev Dashboard
- Click Create app, name it and create a version with your app URL and the webhooks API version
- In the Access section of the version enter the scope
read_orders. It covers orders from the last 60 days. Older orders requireread_all_orders, which you have to request from Shopify (access scopes) - Release the version, open Installs and install the app on your store
- In the app’s Settings copy the Client ID and Client secret and set them as
SHOPIFY_CLIENT_IDandSHOPIFY_CLIENT_SECRET, together withSHOPIFY_SHOP(themyshopify.comsubdomain)
The script pages with first and after and stops when hasNextPage is false (GraphQL pagination). Page sizes are small on purpose: a single query may not exceed 1,000 cost points and a standard plan restores 100 points per second (GraphQL rate limits).
import { appendRow } from './receiver.js';
const SHOP = process.env.SHOPIFY_SHOP;
const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET;
const API_VERSION = '2026-07';
const SINCE = process.env.BACKFILL_SINCE || '2026-01-01T00:00:00Z';
async function getAccessToken() {
const response = await fetch(`https://${SHOP}.myshopify.com/admin/oauth/access_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
const data = await response.json();
return data.access_token;
}
const ORDERS_QUERY = `
query Orders($cursor: String, $filter: String) {
orders(first: 10, after: $cursor, sortKey: CREATED_AT, query: $filter) {
pageInfo { hasNextPage endCursor }
nodes {
legacyResourceId
name
createdAt
email
customer { firstName lastName }
subtotalPriceSet { shopMoney { amount } }
totalTaxSet { shopMoney { amount } }
totalShippingPriceSet { shopMoney { amount } }
totalPriceSet { shopMoney { amount } }
displayFinancialStatus
displayFulfillmentStatus
tags
note
lineItems(first: 20) {
nodes { quantity name originalUnitPriceSet { shopMoney { amount } } }
}
}
}
}
`;
async function fetchPage(token, cursor) {
const response = await fetch(`https://${SHOP}.myshopify.com/admin/api/${API_VERSION}/graphql.json`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': token,
},
body: JSON.stringify({
query: ORDERS_QUERY,
variables: { cursor, filter: `created_at:>='${SINCE}'` },
}),
});
const payload = await response.json();
if (payload.errors) {
throw new Error(JSON.stringify(payload.errors));
}
const throttle = payload.extensions.cost.throttleStatus;
if (throttle.currentlyAvailable < 200) {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
return payload.data.orders;
}
function rowFromGraphql(node) {
const lineItems = node.lineItems.nodes
.map((item) => `${item.quantity}x ${item.name} (${item.originalUnitPriceSet.shopMoney.amount})`)
.join(' | ');
const customerName = node.customer
? `${node.customer.firstName || ''} ${node.customer.lastName || ''}`.trim()
: 'Guest';
return [
String(node.legacyResourceId),
node.name.replace('#', ''),
node.createdAt,
customerName,
node.email || '',
lineItems,
node.subtotalPriceSet?.shopMoney.amount || '0.00',
node.totalTaxSet.shopMoney.amount,
node.totalShippingPriceSet.shopMoney.amount,
node.totalPriceSet.shopMoney.amount,
(node.displayFinancialStatus || '').toLowerCase(),
node.displayFulfillmentStatus.toLowerCase(),
node.tags.join(', '),
node.note || '',
];
}
export async function backfillOrders() {
const token = await getAccessToken();
let cursor = null;
let hasNextPage = true;
let written = 0;
while (hasNextPage) {
const page = await fetchPage(token, cursor);
for (const node of page.nodes) {
if (await appendRow(rowFromGraphql(node))) {
written += 1;
}
}
hasNextPage = page.pageInfo.hasNextPage;
cursor = page.pageInfo.endCursor;
}
console.log(`Backfill finished, ${written} new rows`);
}
backfillOrders();
A few mappings differ from the webhook payload: legacyResourceId is the numeric order ID that webhooks send as id, name is the order number with a # prefix. The status enums come back in upper case, so the script lowercases them to match the webhook rows. The created_at:>='...' filter uses Shopify’s search syntax. Run the script once for the initial import and then daily with BACKFILL_SINCE set to a few days back as your reconciliation job.
Customer Data: Protected Customer Data and GDPR
An order payload contains the customer’s name, email, phone and addresses. Two rule sets apply before you copy that into a spreadsheet.
Shopify’s protected customer data rules. Name, address, email and phone are protected customer data. Public apps must request access and pass a review, custom apps have access available without review (protected customer data). For legacy custom apps created in the admin, level 2 access depends on the store plan (Help Center: custom apps). If customer fields come back empty from the GraphQL API, check the protected customer data settings of your app in the Dev Dashboard. The requirements Shopify lists apply to you regardless: process only the fields you need, limit who can access them, encrypt backups and define a retention period.
GDPR. Every row in the sheet is a second copy of personal data outside Shopify. You are the controller for it. Google acts as your processor under the Cloud Data Processing Addendum, which covers Google Workspace and Google Cloud, so record the sheet in your processing register and keep sharing restricted to the people who need it. Drop the email and name columns if reporting is the only purpose. When a customer asks for erasure, delete their rows as well: the compliance webhooks customers/data_request, customers/redact and shop/redact are mandatory for App Store apps (privacy law compliance), a private receiver like this one has to handle such requests manually.
Common Pitfalls (And How We Fix Them)
Pitfall 1: Webhook signature validation fails Make sure you copied the signing secret from the Webhooks section under Settings and then Notifications, not the client secret of a Dev Dashboard app. Webhooks created by an app are signed with that app’s client secret instead. After a secret rotation Shopify can keep signing with the old one for up to an hour.
Pitfall 2: Duplicate orders in the sheet
Shopify retries on timeouts and errors. Always check for the order ID before appending. Never answer 200 before the row is written.
Pitfall 3: Line items formatting breaks
Some product names contain pipes or commas. The receiver joins items with ' | '; switch to a separate sheet with one row per line item if you need clean CSV exports.
Pitfall 4: The receiver is too slow Shopify waits five seconds for the whole request. Two Sheets API calls per order normally take well under a second, but a sheet with tens of thousands of rows makes the duplicate check in column A slow. Keep an in-memory set of recent IDs or move the archive to a second tab.
Production Deployment Checklist
Before going live:
- Test with 10+ orders: verify all edge cases (multiple line items, discounts, guest checkout, refunds)
- Set up error alerts: forward receiver errors to email or Slack, see automated monitoring
- Monitor logs: check daily for the first week, including Shopify’s delivery history for the webhook
- Keep duplicate detection: the order ID check is what makes Shopify’s retries safe
- Document the API version:
2026-07is supported until July 2027, plan the upgrade before then - Schedule the reconciliation: run the backfill script daily (or use automated backups)
Alternative: The Same Flow in n8n
If you already run n8n, the flow is two nodes: the Shopify Trigger node for the order event and the Google Sheets node with the Append or Update Row operation, matched on the order ID column. The trigger needs Shopify credentials for a custom app; note that the n8n credential guide still describes the legacy admin flow, so create the app in the Dev Dashboard as shown above. Everything in step 5 and the data protection section applies unchanged.
Need Professional Help?
This integration architecture grows with your order volume. Our Integration service includes:
- Custom field mapping: sync metafields, custom line item properties and Shopify Plus features
- Multi-destination sync: send to Sheets, BigQuery, Slack and your custom backend
- Advanced error handling: automatic retry logic, dead letter queues and alerting
- Fulfillment sync: bi-directional sync (update Shopify when you mark “shipped” in Sheets)
- Performance optimization: handle large orders and flash sales without timeouts
Book a free 30-minute consultation to review your integration needs: Schedule here
Related Services
- Dashboard Analytics: visualize order data with custom dashboards
- Automation Workflows: auto-fulfill orders based on inventory rules
- Compare vs Zapier: why direct integrations beat Zapier
Next Guide
Want to sync Shopify inventory too? Check out our guide on Real-Time Shopify Inventory Sync with Low-Stock Alerts.