orders/create webhook fires when an order is placed (paid status). n8n picks up the order JSON, runs a state-code check against the buyer's shipping address vs the seller's registered state, splits the tax into CGST+SGST (intra-state) or IGST (inter-state), generates a Tally-compatible XML voucher with the right line items and ledgers, and uploads it to a dated Drive folder. The accountant clicks Import once a day in Tally. End-to-end runtime per order: ~4 seconds.
## Why this matters now — April 2026
Shopify shipped GraphQL Admin API 2026-04 with cleaner tax-line objects (CGST/SGST/IGST are now reported as separate taxLines entries with proper rate metadata, not embedded in a single tax amount). The orderCreate idempotency-key requirement (from 2026-04) made retry-safe pipelines viable.
On the Tally side, TallyPrime 6.1 (Feb 2026) cleaned up the XML schema for GST classification on ledgers, so Sales vouchers with the right output-tax ledgers flow through without manual repair. The combination — Shopify clean tax data + Tally clean ingestion — is what made this workflow deployable in 2026 vs janky in 2024.
A long [r/IndianAccounting thread from March 2026](https://www.reddit.com/r/IndianAccounting/) on Shopify+Tally sync had 40+ comments and zero good answers — most CAs were quoting ₹50k–₹1.5L for custom integrations. We built it for our client at one-third that and open-sourced the workflow structure below.
## The 5-block workflow
- n8n v1.121+ (the Shopify Trigger node v2.2 ships order GraphQL by default)
- Shopify store on Basic or above (webhooks free)
- Shopify Admin API access token with read_orders and read_customers scopes
- Your seller GSTIN and registered-business state code (e.g. Maharashtra = 27)
- TallyPrime 6.1+ with company configured for GST
- Pre-created Tally output tax ledgers: Output CGST @ 9%, Output SGST @ 9%, Output IGST @ 18%, etc., for each rate slab you sell
- Google Drive folder structure ready: /Tally Imports/Sales/YYYY-MM/
Order payment (fires after the order is paid, not just created — avoids the "abandoned cart" trap). Format: JSON. URL: https://n8n.softechinfra.com/webhook/shopify-order.
Shopify signs with HMAC-SHA256 over the raw body using your shared secret in header X-Shopify-Hmac-Sha256 (base64). Verify before doing anything else.
const crypto = require('crypto');
const secret = $env.SHOPIFY_WEBHOOK_SECRET;
const signature = $json.headers['x-shopify-hmac-sha256'];
const body = $json.body; // raw string
const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('base64');
if (computed !== signature) {
throw new Error('Invalid Shopify signature');
}
const order = JSON.parse(body);
return { json: order };{
"parameters": {
"httpMethod": "POST",
"path": "shopify-order",
"responseMode": "responseNode",
"options": {
"rawBody": true
}
},
"id": "shopify-webhook-001",
"name": "Shopify Order Paid",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [240, 300]
}id is the natural idempotency key. We push it to a small Redis-backed Set node (or to a Google Sheets-based dedupe if you do not have Redis) and skip if seen.
## Step 2 — The GST split logic
This is the only block that requires real accounting attention. The rule under India's GST framework:
- Intra-state sale (buyer's state of supply = seller's registered state): tax is split into CGST + SGST, each half the total rate. E.g. 18% becomes 9% CGST + 9% SGST.
- Inter-state sale (buyer's state ≠ seller's state): tax is IGST, full rate. 18% becomes 18% IGST.
- Export (buyer is outside India): zero-rated for GST. We tag with LUT (Letter of Undertaking) reference if your client has one.
Shopify's 2026-04 taxLines array gives you the rate and title (e.g. { title: "CGST", rate: 0.09, price: "45.00" }) — but Shopify's tax engine isn't always correctly configured. We never trust Shopify's split. We re-derive from the buyer's shipping address.
The Code node:
const SELLER_STATE_CODE = '27'; // Maharashtra
const SELLER_GSTIN = '27ABCDE1234F1Z5';
const STATE_BY_NAME = {
'maharashtra': '27', 'karnataka': '29', 'tamil nadu': '33', 'delhi': '07',
'haryana': '06', 'uttar pradesh': '09', 'gujarat': '24', 'rajasthan': '08',
'west bengal': '19', 'telangana': '36', 'andhra pradesh': '37',
'kerala': '32', 'madhya pradesh': '23', 'punjab': '03', 'bihar': '10',
'odisha': '21', 'chhattisgarh': '22', 'jharkhand': '20', 'assam': '18',
// ... fill out for all states
};
const order = $json;
const shipping = order.shipping_address || order.billing_address;
const stateName = (shipping.province || '').toLowerCase().trim();
const stateCode = STATE_BY_NAME[stateName];
const country = (shipping.country_code || 'IN').toUpperCase();
let transactionType;
if (country !== 'IN') {
transactionType = 'export';
} else if (!stateCode) {
transactionType = 'unknown_state'; // route to manual review
} else if (stateCode === SELLER_STATE_CODE) {
transactionType = 'intra';
} else {
transactionType = 'inter';
}
// Aggregate tax across line items
let totalTaxable = 0;
let totalTax = 0;
let gstRate = null;
for (const li of order.line_items) {
totalTaxable += parseFloat(li.price) * li.quantity;
for (const tl of li.tax_lines || []) {
totalTax += parseFloat(tl.price);
if (!gstRate) gstRate = Math.round(tl.rate * 100);
}
}
// Detect mixed rates (e.g. some products 5%, some 18%)
const rates = new Set();
for (const li of order.line_items) {
for (const tl of li.tax_lines || []) rates.add(Math.round(tl.rate 100 2)); // doubled because intra-state is per-half
}
const mixedRates = rates.size > 1;
let cgst = 0, sgst = 0, igst = 0;
if (transactionType === 'intra') {
cgst = totalTax / 2;
sgst = totalTax / 2;
} else if (transactionType === 'inter') {
igst = totalTax;
}
return {
json: {
...order,
gst: {
transaction_type: transactionType,
buyer_state: shipping.province,
buyer_state_code: stateCode,
seller_state_code: SELLER_STATE_CODE,
taxable_amount: totalTaxable,
cgst, sgst, igst,
total_tax: totalTax,
gst_rate_pct: gstRate,
mixed_rates: mixedRates,
requires_review: mixedRates || transactionType === 'unknown_state'
}
}
};requires_review = true route to a Slack channel for the accountant to validate, not to auto-import. About 2-3% of orders flag — usually mixed-rate carts or buyers with international shipping addresses.
## Step 3 — Tally Sales Voucher XML
Tally Sales voucher XML is more complex than the Purchase XML from Post 4 because it must include the customer party ledger with their state code and (if registered) GSTIN, plus the inventory-allocation block per line item.
A minimal Sales voucher for an intra-state order:
Import Data
All Masters
Indore D2C Pvt Ltd
20260427
#2891
SHOPIFY-2891
Shopify order #2891 — Riya Sharma, Maharashtra
Sales
Riya Sharma - 27
Riya Sharma
Maharashtra
India
Riya Sharma - 27
Yes
-1180.00
Sales — Online
No
1000.00
Output CGST @ 9%
No
90.00
Output SGST @ 9%
No
90.00
{name} - {state_code} so two buyers named "Rahul Kumar" from different states do not collide. Tally needs unique ledger names; this is a convention that works for B2C.
- ISDEEMEDPOSITIVE is "Yes" for the party (debit — money is receivable) and "No" for sales + output tax (credit). The amounts are negative on debit and positive on credit.
- For inter-state orders, replace the two CGST/SGST lines with a single Output IGST @ 18% line at the full tax amount.
- For B2B orders where the buyer has a GSTIN (we get it via a custom Shopify checkout field), include PARTYGSTIN and STATENAME so Tally captures it for GSTR-1.
The Code node that emits the XML:
const o = $json;
const g = o.gst;
const dateTally = o.processed_at.split('T')[0].split('-').join(''); // YYYYMMDD
const partyName = (o.customer?.first_name + ' ' + (o.customer?.last_name || '')).trim();
const partyLedger = partyName + ' - ' + g.buyer_state_code;
let taxXml = '';
if (g.transaction_type === 'intra') {
taxXml =
Output CGST @ ${g.gst_rate_pct/2}%
No
${g.cgst.toFixed(2)}
Output SGST @ ${g.gst_rate_pct/2}%
No
${g.sgst.toFixed(2)}
;
} else if (g.transaction_type === 'inter') {
taxXml =
Output IGST @ ${g.gst_rate_pct}%
No
${g.igst.toFixed(2)}
;
}
const total = (parseFloat(o.total_price)).toFixed(2);
const xml =
Import Data
All Masters
${$env.TALLY_COMPANY_NAME}
${dateTally}
#${o.order_number}
SHOPIFY-${o.order_number}
Shopify order #${o.order_number} — ${partyName}, ${g.buyer_state}
Sales
${partyLedger}
${partyName}
${g.buyer_state}
India
${partyLedger}
Yes
-${total}
Sales — Online
No
${g.taxable_amount.toFixed(2)}
${taxXml}
;
return {
json: { ...o, tally_xml_filename: 'order-' + o.order_number + '.xml' },
binary: { data: { data: Buffer.from(xml).toString('base64'), mimeType: 'application/xml', fileName: 'order-' + o.order_number + '.xml' } }
};/Tally Imports/Sales/2026-04/, naming files order-{order_number}.xml for traceability.
A separate sister workflow runs every weekday at 7 PM IST (Cron) — it lists files added to that day's Drive folder, totals the order values, and posts a Slack digest to #accounts:
:moneybag: Sales digest — 27 Apr
38 orders ready to import. Total: ₹4,18,640.
Intra-state: 22 orders (₹2,38,210) · Inter-state: 14 orders (₹1,68,930) · Export: 2 orders (₹11,500)
Mixed-rate / review needed: 1 —
:google-drive: tax_lines. Saved ~₹18k in incorrect tax remittance.
The Shopify+Tally pattern is a building block we use across [AI automation engagements](https://softechinfra.com/services/ai-automation) for Indian D2C brands. Founded by [Vivek Singh](https://viveksinra.com), Softechinfra's been shipping these accounting-integration projects since 2022; we have a library of ~40 vendor-mapping templates pre-built.
## Common mistakes
Mistake 1 — Trusting Shopify's tax_lines blindly. Shopify Markets occasionally misclassifies state codes, especially for newly-added states or address normalisation edge cases. Always re-derive from shipping_address.province.
Mistake 2 — Skipping the orders/paid topic and using orders/create. Orders/create fires on unpaid orders too — you will book Tally entries for orders that never get paid, and have to reverse them. Use orders/paid.
Mistake 3 — Auto-creating customer ledgers without state code. Without the state suffix, you collide ledgers across states. Always tag with {name} - {state_code}.
Mistake 4 — Forgetting refunds. Shopify's refunds/create webhook needs a parallel workflow that generates a Credit Note voucher in Tally with the opposite GST signs. About 4% of our client's orders refund; without this, books drift.
Mistake 5 — Naming XML files with order number only. Two orders in different months can share order numbers in Tally if you have multiple Shopify stores. Use {store_id}-{order_number}-{date}.xml to keep file names unique.
## FAQ
### Does this work for Shopify Plus?
Yes — Plus has the same webhooks, just higher rate limits. For Plus stores doing 10k+ orders/day we use multi-region n8n with horizontal scaling, but the workflow JSON is unchanged.
### What about Magento, WooCommerce, Razorpay Magic Checkout?
Magento: webhook payload differs but the GST split logic transfers. We have a Magento variant. WooCommerce: WP webhooks need an Application Password; sometimes JSON is malformed — add a sanitise step. Razorpay Magic: their order completion webhook is cleaner than Shopify's; same XML emit at the end.
### How do I handle e-invoicing (IRN) for orders over the threshold?
Add a branch before the Tally XML emit: if order.total > ₹50,000 and customer has GSTIN, hit the IRP API (NIC's Invoice Registration Portal) for the IRN + QR code, then include them in the Tally XML's block. Adds ~6 nodes and ₹2 per IRN call.
### What if my Shopify store does not collect GST correctly?
Then your tax-line data will be wrong before our workflow even sees it. Fix Shopify first: in Settings → Taxes → India, configure each rate slab and apply it to the right product collections. Our workflow can flag mismatches but cannot fix bad source data.
### Does this work with composition-scheme businesses?
No. Composition-scheme businesses do not charge GST line-by-line and file GSTR-4 quarterly with a fixed turnover-based tax. The XML schema is different (no tax breakdown, just bill_amount). We have a composition variant — different workflow, simpler.
### Can I roll-up multiple orders into one Tally voucher per day?
Yes, but we recommend against it. Per-order vouchers give you cleaner audit trails and easier refund handling. The daily digest gives you the rollup view without polluting Tally's voucher register.
### What is the audit trail if a tax officer asks?
Every order's XML file in Drive is named with the order number. Tally's voucher narration includes the Shopify reference. Click any voucher in Tally → see the Shopify order ID → look up the original Shopify order screen. That round-trip is exactly what auditors want.
Want this Shopify-to-Tally sync set up for your store?
We ship the full 20-node workflow above, configured to your seller state, your existing Tally ledgers, and your Shopify product tax slabs. Includes the refunds workflow and a state-mapping validation against your last 200 orders. Typical cost: ₹55,000–₹95,000 depending on order volume and how many tax slabs you sell. Suitable if you do 300+ orders/month and your accountant is data-entering on weekends. No slides — send us a Shopify CSV export and we will show you the Tally XML that comes out.
Book a 20-min Call
