Most Indian SMBs keep their books in Tally. If you're selling commerce software into that market, the question that decides the deal isn't about your product. It's does it sync with my Tally?
I've now built that sync twice, in two completely different shapes, and the second one exists because the first one had a ceiling. Here's what I got wrong, what Tally actually does as opposed to what its docs say, and the one decision I'd make first if I started again.
Tally doesn't have an API. It has a port.
No REST, no SDK, no OAuth. Tally Prime runs on a shop-floor Windows PC and, if enabled, listens
for HTTP on port 9000. You POST an XML <ENVELOPE> and it answers with one.
Every request is Export or Import, and the interesting part is the
ID:
<ENVELOPE>
<HEADER>
<VERSION>1</VERSION>
<TALLYREQUEST>Export</TALLYREQUEST>
<TYPE>Data</TYPE>
<ID>List of Accounts</ID>
</HEADER>
<BODY><DESC><STATICVARIABLES>
<SVEXPORTFORMAT>$$SysName:XML</SVEXPORTFORMAT>
<SVCURRENTCOMPANY>Acme Traders</SVCURRENTCOMPANY>
<AccountType>Stock Items</AccountType>
</STATICVARIABLES></DESC></BODY>
</ENVELOPE>
SVCURRENTCOMPANY is mandatory and it matters more than it looks. Tally can have
several companies open, and almost every identity assumption you're about to make is scoped to
one of them.
That ID names a Collection, which is a TDL concept — Tally
Definition Language, the language Tally itself is written in. The built-in collections give you
the obvious things. The moment you want a specific field list, a filter, or any kind of paging,
you're writing TDL. You can inject it inline in the request as a
<TDLMESSAGE> and define a collection on the fly, which is the trick that
makes the rest of this possible.
The identity decision: MasterId, not name, not REMOTEID
This is the one to get right before writing anything else, and it's where I see most integrations go wrong.
The tempting key is the ledger or stock item name, because that's what Tally shows you and what the accountant talks about. It does not survive contact with reality. Rename "Acme Traders" to "Acme Traders Pvt Ltd" and a name-keyed sync sees one master deleted and a different one created. You get a duplicate, and the original keeps all the history.
So the key is Tally's MasterId, which is stable for the life of the object. With
one catch that cost me an afternoon: MasterId is only unique within a company.
Two companies in the same Tally install will both happily have a master with id 3858. If a
store ever points at a second company, a bare MasterId collides silently — and a silent
collision in accounting data is about the worst failure mode available. The company has to be
part of the key.
Worth saying plainly, because a lot of writing about Tally repeats it: I don't use
REMOTEID. A composite sync id built from store, company and MasterId does the same
job, stays meaningful on our side, and doesn't depend on Tally preserving a field we don't
control.
The 413, and why the documented fix doesn't work
Item sync worked fine until it met a merchant with a real catalogue, and then every large
company failed with 413 Request Entity Too Large. The whole catalogue was going
out in one POST body.
Tally documents Start Batch Post for exactly this. It is a no-op for an
HTTP JSON collection. It doesn't error, it doesn't warn, it just doesn't batch, and
you're left staring at a request that should have been chunked and wasn't.
The fix had to happen inside TDL, because that's where the export is assembled. Two passes: gather every unsynced master into a List Variable with a sequence number, then post 500 rows at a time behind a system formula that windows on that number:
$sr > ##Lo AND $sr <= ##Hi
then stamp the returned ids back onto each master as a UDF so the next run knows what's already gone. It's a hand-rolled pager for an API that has no pagination, written in a language most people have never seen. It is not elegant. It works on companies of any size.
Two generations, and why the second one exists
The first version was a TDL add-on that runs inside Tally on the merchant's machine and pushes outbound over HTTPS. That has one enormous advantage: no inbound connectivity, no firewall, no NAT problem. It also has one hard ceiling — nothing happens unless a human opens Tally and clicks a menu item.
The second version inverts it. The server initiates, on a schedule, with no merchant involvement. Which means reaching a desktop behind a home-grade router:
Tally / BUSY on the shop PC
^
| http://127.0.0.1:<gateway-port>
local gateway ← tunnel client (outbound-only)
|
tunnel edge → stable public hostname → backend
A tunnel client can only forward to a single local URL, so there's a small local HTTP server in front that strips a prefix and routes to whichever accounting app is being asked for. That indirection also means the tunnel never has to restart when a port changes — it only ever knows about the one stable local port.
The security bit, because it's the thing I'd want a reviewer to check: Tally's HTTP server has no authentication whatsoever. Anything that can reach port 9000 can read and write that company's books. The moment you put a public hostname in front of it, your own auth check is the only access control in the entire path. Ours fails closed: with no secret configured the gateway refuses everything rather than proxying it. Fail-open here would publish a company's accounts to the internet.
The timeout that wasn't a timeout
My favourite bug of the whole project. Jobs weren't failing. They were going quiet — held for ten minutes and more on a request budgeted for three.
The cause: axios' timeout is a socket-idle timeout, not a cap on total
duration. Tally trickling a 20MB stock export back through a tunnel never goes idle.
Bytes keep arriving, the idle timer keeps resetting, and your budget never fires. You need an
AbortController with your own deadline if you want an actual limit.
A related one on the parsing side: a 26MB Day Book blocked Node's event loop for minutes. Long enough that the request's own abort timer couldn't fire, because the timer needed the loop that the parse was holding. The guard has to be on the byte size before you parse, not on elapsed time during it.
Tally tells you it succeeded when it didn't
Budget real time for this. Tally answers HTTP 200 on failure, and reports the actual outcome
inside the XML as <STATUS>, <LINEERROR>,
ERRORS and EXCEPTIONS. IMPORTRESULT will cheerfully show
STATUS 1 with failures hidden underneath it. If you check the HTTP status and move
on, you will believe you have written data that is not there.
A few more that cost me time, in case they save you some: date static variables need
TYPE="Date" or they're silently ignored. $MasterId is
0 on a voucher you just created, so you can't read it back in the same breath.
Voucher collections don't render in RemoteRequest reports at all. And the XML
comes back in the machine's codepage, from free-text fields an accountant typed, so it is
routinely not valid XML — sanitise before you hand it to a parser.
If you are about to do this
Decide your identity key first and make it composite. For Tally that's store plus company plus MasterId. Everything downstream depends on it, and retrofitting means auditing every record you have already written.
Then assume the host is offline, because several times a day it genuinely is. Queue everything, back off, and never block a user-facing request on someone's desktop being awake. Treat every HTTP 200 as unverified until you have read the XML. And put your own deadline on every call, because the library's timeout probably doesn't mean what you think it means.
The part I underestimated was TDL. I treated the Tally side as configuration and the Node side as the real project. That was wrong by about a month.