Softechinfra
Productivity

n8n + Shopify + Tally: Auto-Sync Every Sale Into Your Indian Accounting Stack

A real Shopify order webhook to Tally XML voucher workflow with intra-state vs inter-state GST split logic. Full code, schema, and the audit-proof trail.

Hrishikesh BaidyaHrishikesh Baidya
April 27, 202614 min read
n8n + Shopify + Tally: Auto-Sync Every Sale Into Your Indian Accounting Stack

Our D2C client in Indore was reconciling ~1,400 Shopify orders into Tally manually every month. Their accountant called it "the second job." We rebuilt it on n8n: Shopify webhook → CGST/SGST/IGST split based on the buyer's shipping state → Tally-importable XML → Drive upload → daily Slack digest of the day's vouchers. 20 nodes, 14 hours of manual entry a month vanished. The whole thing has been live for 14 weeks; not a single GSTR-1 mismatch in that window.

20
n8n nodes (production)
14 hrs
Monthly accountant time freed
0
GSTR-1 mismatches in 14 weeks

TL;DR

A Shopify 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 on Shopify+Tally sync had 40+ comments and zero good answers — most CAs were quoting steep fees for custom integrations. We built it for our client at a fraction of that and open-sourced the workflow structure below.

The 5-block workflow

🛒
Step 1: Shopify webhook
orders/paid topic. HMAC verify. Idempotency key check to skip duplicates.
🗺️
Step 2: GST split logic
Shipping state vs seller state → intra/inter. Validate tax math against line items.
📜
Step 3: Tally XML emit
Sales voucher XML with customer ledger (auto-created on first order), sales ledger, output GST ledgers.
📁
Step 4: Drive + daily digest
Push XML to dated folder. Cron at 7 PM IST posts day's total to #accounts with import link.

Prerequisites

  • 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/

Step 1 — Shopify webhook + signature verify

Set the webhook in Shopify Admin → Notifications → Webhooks. Topic: 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.

javascript
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 };

Webhook node config:

json
{
    "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]
  }

Idempotency: Shopify retries failed webhooks for ~48 hours. The order's 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:

    javascript
    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'
          }
        }
      };

    Cases where 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:

    xml
    <ENVELOPE>
       <HEADER><TALLYREQUEST>Import Data</TALLYREQUEST></HEADER>
       <BODY><IMPORTDATA>
        <REQUESTDESC>
         <REPORTNAME>All Masters</REPORTNAME>
         <STATICVARIABLES><SVCURRENTCOMPANY>Indore D2C Pvt Ltd</SVCURRENTCOMPANY></STATICVARIABLES>
        </REQUESTDESC>
        <REQUESTDATA><TALLYMESSAGE xmlns:UDF="TallyUDF">
         <VOUCHER VCHTYPE="Sales" ACTION="Create">
          <DATE>20260427</DATE>
          <VOUCHERNUMBER>#2891</VOUCHERNUMBER>
          <REFERENCE>SHOPIFY-2891</REFERENCE>
          <NARRATION>Shopify order #2891 — Riya Sharma, Maharashtra</NARRATION>
          <VOUCHERTYPENAME>Sales</VOUCHERTYPENAME>
          <PARTYLEDGERNAME>Riya Sharma - 27</PARTYLEDGERNAME>
          <BASICBUYERNAME>Riya Sharma</BASICBUYERNAME>
          <STATENAME>Maharashtra</STATENAME>
          <COUNTRYNAME>India</COUNTRYNAME>
          <ALLLEDGERENTRIES.LIST>
           <LEDGERNAME>Riya Sharma - 27</LEDGERNAME>
           <ISDEEMEDPOSITIVE>Yes</ISDEEMEDPOSITIVE>
           <AMOUNT>-1180.00</AMOUNT>
          </ALLLEDGERENTRIES.LIST>
          <ALLLEDGERENTRIES.LIST>
           <LEDGERNAME>Sales — Online</LEDGERNAME>
           <ISDEEMEDPOSITIVE>No</ISDEEMEDPOSITIVE>
           <AMOUNT>1000.00</AMOUNT>
          </ALLLEDGERENTRIES.LIST>
          <ALLLEDGERENTRIES.LIST>
           <LEDGERNAME>Output CGST @ 9%</LEDGERNAME>
           <ISDEEMEDPOSITIVE>No</ISDEEMEDPOSITIVE>
           <AMOUNT>90.00</AMOUNT>
          </ALLLEDGERENTRIES.LIST>
          <ALLLEDGERENTRIES.LIST>
           <LEDGERNAME>Output SGST @ 9%</LEDGERNAME>
           <ISDEEMEDPOSITIVE>No</ISDEEMEDPOSITIVE>
           <AMOUNT>90.00</AMOUNT>
          </ALLLEDGERENTRIES.LIST>
         </VOUCHER>
        </TALLYMESSAGE></REQUESTDATA>
       </IMPORTDATA></BODY>
      </ENVELOPE>

    Things to note:

  • We name customer ledgers as {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:

    javascript
    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 = `
          <ALLLEDGERENTRIES.LIST>
           <LEDGERNAME>Output CGST @ ${g.gst_rate_pct/2}%</LEDGERNAME>
           <ISDEEMEDPOSITIVE>No</ISDEEMEDPOSITIVE>
           <AMOUNT>${g.cgst.toFixed(2)}</AMOUNT>
          </ALLLEDGERENTRIES.LIST>
          <ALLLEDGERENTRIES.LIST>
           <LEDGERNAME>Output SGST @ ${g.gst_rate_pct/2}%</LEDGERNAME>
           <ISDEEMEDPOSITIVE>No</ISDEEMEDPOSITIVE>
           <AMOUNT>${g.sgst.toFixed(2)}</AMOUNT>
          </ALLLEDGERENTRIES.LIST>`;
      } else if (g.transaction_type === 'inter') {
        taxXml = `
          <ALLLEDGERENTRIES.LIST>
           <LEDGERNAME>Output IGST @ ${g.gst_rate_pct}%</LEDGERNAME>
           <ISDEEMEDPOSITIVE>No</ISDEEMEDPOSITIVE>
           <AMOUNT>${g.igst.toFixed(2)}</AMOUNT>
          </ALLLEDGERENTRIES.LIST>`;
      }
      
      const total = (parseFloat(o.total_price)).toFixed(2);
      const xml = `<ENVELOPE>
       <HEADER><TALLYREQUEST>Import Data</TALLYREQUEST></HEADER>
       <BODY><IMPORTDATA>
        <REQUESTDESC>
         <REPORTNAME>All Masters</REPORTNAME>
         <STATICVARIABLES><SVCURRENTCOMPANY>${$env.TALLY_COMPANY_NAME}</SVCURRENTCOMPANY></STATICVARIABLES>
        </REQUESTDESC>
        <REQUESTDATA><TALLYMESSAGE xmlns:UDF="TallyUDF">
         <VOUCHER VCHTYPE="Sales" ACTION="Create">
          <DATE>${dateTally}</DATE>
          <VOUCHERNUMBER>#${o.order_number}</VOUCHERNUMBER>
          <REFERENCE>SHOPIFY-${o.order_number}</REFERENCE>
          <NARRATION>Shopify order #${o.order_number} — ${partyName}, ${g.buyer_state}</NARRATION>
          <VOUCHERTYPENAME>Sales</VOUCHERTYPENAME>
          <PARTYLEDGERNAME>${partyLedger}</PARTYLEDGERNAME>
          <BASICBUYERNAME>${partyName}</BASICBUYERNAME>
          <STATENAME>${g.buyer_state}</STATENAME>
          <COUNTRYNAME>India</COUNTRYNAME>
          <ALLLEDGERENTRIES.LIST>
           <LEDGERNAME>${partyLedger}</LEDGERNAME>
           <ISDEEMEDPOSITIVE>Yes</ISDEEMEDPOSITIVE>
           <AMOUNT>-${total}</AMOUNT>
          </ALLLEDGERENTRIES.LIST>
          <ALLLEDGERENTRIES.LIST>
           <LEDGERNAME>Sales — Online</LEDGERNAME>
           <ISDEEMEDPOSITIVE>No</ISDEEMEDPOSITIVE>
           <AMOUNT>${g.taxable_amount.toFixed(2)}</AMOUNT>
          </ALLLEDGERENTRIES.LIST>${taxXml}
         </VOUCHER>
        </TALLYMESSAGE></REQUESTDATA>
       </IMPORTDATA></BODY>
      </ENVELOPE>`;
      
      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' } }
      };

    Step 4 — Drive upload + daily digest

    The Google Drive node uploads to /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:

    code
    :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 — <link to order>
      
      :google-drive: <Folder link>

    The accountant imports the day's XMLs in one go via Tally's Bulk Import (or via TDL-driven scripted import on Tally Connect for fully automated end-to-end).

    Cost comparison

    For 1,400 orders/month (our Indore client's typical volume):

    n8n + Drive SyncGo / Tally Connector seat Custom integration (one-off, amortised) Manual entry @ 1 min/order × 1400

    When NOT to do this

    You sell across multiple Shopify markets. If you have a US, UK, and India Shopify Markets setup, the tax engine differs per market. The workflow needs branches per market — and you should engage a tax consultant for export-LUT mechanics before automating.

    Your sales mix is mostly B2B with complex e-invoicing. B2B orders > ₹50 lakh aggregate / year trigger e-invoice generation requirements (IRN, QR code). The XML schema needs additional fields. Build a separate B2B branch with IRP API integration.

    You are on Tally Connect or Tally on Cloud. Both have direct REST APIs that beat XML import. Use those (we have a separate workflow pattern for them — drop the XML, hit the API directly).

    State-name mapping gotcha: Shopify returns shipping province as free-form text ("Maharashtra", "MH", "maharashtra", sometimes "maharastra" — typo). Our mapping handles 50+ common variants per state. A misspelled state can flip a sale from intra to inter and create a GSTR-1 mismatch. Always lowercase + trim + check fallback.

    Real case — Indore D2C apparel brand, ₹4 Cr GMV

    The client: a 9-person apparel brand selling shapewear PAN-India via Shopify. ~1,400 orders a month, accountant Vipul (CA, in-house) was spending Wednesdays and Saturdays just on data entry.

    After 14 weeks live:

  • All 1,400 orders/month auto-flow into Tally — Vipul reviews the daily digest in 4 minutes
  • Manual time per month: from 14 hours to ~45 minutes
  • GSTR-1 filing prep: from 6 hours to 30 minutes (because the data is already in Tally with correct state codes)
  • Errors caught in the first month: 3 cases where Shopify had charged wrong GST (their tax engine had a stale state code for Telangana). The workflow caught it because our re-derivation logic disagreed with Shopify's tax_lines.
  • The Shopify+Tally pattern is a building block we use across AI automation engagements for Indian D2C brands. Founded by Vivek Kumar, 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 <INVOICEDETAILS.LIST> block. Adds ~6 nodes.

    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. 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
    Tags:
    n8nShopifyTallyGSTIndiaD2CWorkflow AutomationAccounting
    Share this post:
    Hrishikesh Baidya

    Hrishikesh Baidya

    CTO at Softechinfra specializing in Python, system architecture, and building secure, scalable software solutions.