FundReap
Product Pricing Guides Sign in Free health check

Tally · Beta

Auto-sync Tally with FundReap — no export needed

A small program that reads straight from TallyPrime's own connection on your PC, every few minutes, and uploads only when something actually changed. No Alt+E export, no upload button, nothing to remember to do after entering a voucher.

Short version

1. In Tally: F1 (Help) → Settings → Connectivity → "TallyPrime acts as" → Server, port 9000, restart Tally when prompted.

2. Download the script, right-click it, Run with PowerShell.

3. It opens your browser to sign in — no token to copy. If Chrome asks to access your local network, click Allow. Type your exact Tally company name when it asks. Leave the window open.

What this actually is — and isn't

This talks to Tally's own XML/HTTP gateway (a feature built into TallyPrime itself) on localhost:9000, asks it for your outstanding bills and recent receipts, and uploads that to the same place a browser upload or the folder-watcher connector would — same limits, same history, same result.

This is not the same thing as real-time. Tally has no way to push data out the moment something changes — nothing in TallyPrime supports that. This script asks Tally a question every 3 minutes and only uploads when the answer changed. In practice that means your plan is never more than a few minutes behind what's actually in Tally, which is the same ceiling every product that talks to on-premise Tally has — nobody does better, because Tally itself doesn't offer better.

Prefer to keep exporting by hand, or Tally's gateway isn't something you want to turn on? The original folder-watcher connector still works exactly as before — this is an additional option, not a replacement.

Step 1 — Turn on Tally's Gateway Server

  1. Open TallyPrime with the company you want to sync loaded
  2. Press F1 (Help) → SettingsConnectivityClient/Server configuration
  3. Set TallyPrime acts as to Server. Leave Enable ODBC as Yes and Port as 9000 unless you have a reason to change it.
  4. Accept, then restart TallyPrime when it asks — this only takes effect after a restart.

This has to be done once per machine Tally runs on. It doesn't open anything to the internet — the gateway only ever listens on that machine itself (localhost), which is exactly why this script has to run on the same PC as Tally, not on a server somewhere else.

Verify it before you run it

This one is a bigger ask than the folder-watcher version — it talks to Tally's own gateway automatically, with no export step for you to see happening. That's exactly why it's worth reading before you run it, not just trusting the description. It's a plain, unsigned PowerShell script; Windows will likely show a security warning the first time (normal, see "Common problems" below), and the standard practice for any unsigned script is to actually read it — so here's everything that takes.

What it can and can't do
Read the entire script before downloading it (300 lines)
# FundReap live Tally connector.
#
# Unlike fundreap-connector.ps1 (which watches a folder for a file you export
# from Tally by hand), this talks to TallyPrime's own XML/HTTP gateway
# directly, on the same machine, and needs no manual export step at all.
#
# What it does, every poll:
#   1. Ask Tally (http://localhost:9000) for the "Bills Receivable" report
#      and for Receipt vouchers, as XML.
#   2. If nothing changed since the last poll, do nothing.
#   3. If it changed, combine both responses and upload them to the exact
#      same endpoint the folder-watcher connector uses.
#
# This is still not "real time" — Tally has no push/webhook mechanism, only
# request/response (see connect-tally-live.html for why). It is "a few
# minutes stale at worst", which is the same ceiling every real product that
# talks to on-prem Tally has settled on (Biz Analyst polls every 5 minutes by
# default; this defaults to 3).
#
# Requires: TallyPrime running locally with the Gateway Server enabled
#   (F1 Help -> Settings -> Connectivity -> "TallyPrime acts as: Server",
#   port 9000) and the company you want to sync already open.
#
# Run it: right-click this file -> "Run with PowerShell". First run opens your
# browser to sign in (or, if you're already signed in to fundreap.com, pairs
# instantly with no prompt at all) — no token to copy-paste. Then it asks for
# your Tally company name, the one thing FundReap has no way to know, and
# remembers both in a config file next to this script.

$ErrorActionPreference = 'Stop'
$scriptDir  = Split-Path -Parent $MyInvocation.MyCommand.Path
$configPath = Join-Path $scriptDir 'fundreap-connector-live.config.json'
$appUrl     = 'https://fundreap.com'
$apiUrl     = "$appUrl/api/connector/upload"
$gatewayUrl = 'http://localhost:9000'
$pollSeconds = 180
$clearedMarker = "`n<!--FUNDREAP-CLEARED-BILLS-->`n"

function Write-Status($msg, $color = 'Gray') {
  Write-Host "[$(Get-Date -Format 'HH:mm:ss')] $msg" -ForegroundColor $color
}

# Writes a minimal raw HTTP response onto a NetworkStream. Hand-rolled rather
# than System.Net.HttpListener: HttpListener goes through Windows' HTTP.sys,
# which silently swallowed every request in testing here — connections
# completed at the TCP level but no response ever came back, with no
# exception raised anywhere to explain why (a known class of issue tied to
# HTTP.sys URL-ACL reservations, which normally need an explicit
# `netsh http add urlacl` a script like this can't assume it's allowed to
# run). A bare TcpListener on a loopback port needs no such registration.
function Send-PairResponse($stream, [int]$statusCode, [string]$statusText, [string]$bodyText) {
  $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($bodyText)
  $header = @(
    "HTTP/1.1 $statusCode $statusText"
    'Content-Type: application/json'
    "Content-Length: $($bodyBytes.Length)"
    "Access-Control-Allow-Origin: $appUrl"
    'Access-Control-Allow-Methods: POST, OPTIONS'
    'Access-Control-Allow-Headers: content-type'
    # Chrome's Private Network Access policy blocks a public HTTPS origin
    # (fundreap.com) from fetching a loopback address at all unless the
    # response explicitly opts in with this header — ordinary CORS headers
    # alone aren't enough. Confirmed missing this the hard way: it worked
    # in every localhost-to-localhost test (PNA doesn't apply there) and
    # only failed once tested against the real https://fundreap.com origin,
    # which is exactly the real-world case.
    'Access-Control-Allow-Private-Network: true'
    'Connection: close'
    ''
    ''
  ) -join "`r`n"
  $headerBytes = [System.Text.Encoding]::ASCII.GetBytes($header)
  $stream.Write($headerBytes, 0, $headerBytes.Length)
  if ($bodyBytes.Length -gt 0) { $stream.Write($bodyBytes, 0, $bodyBytes.Length) }
  $stream.Flush()
}

# Opens the browser to appUrl/connector/pair with a fresh one-time code, then
# blocks (with a timeout) on a tiny local listener until that page hands a
# connector token back to it. See public/connector/pair.html for the other
# side of this — it's what actually calls POST /api/connector/token once the
# user is signed in, then POSTs the result here. This machine-local listener
# is the only thing that can ever act on that POST, and only because it
# knows $pairCode, which never leaves this process except embedded in the
# URL it opens in the user's own browser.
function Get-TokenViaPairing {
  $pairCode = [System.Guid]::NewGuid().ToString('N')
  $listener = $null
  $port = 17865
  for ($i = 0; $i -lt 20; $i++) {
    try {
      $listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $port)
      $listener.Start()
      break
    } catch {
      $listener = $null
      $port++
    }
  }
  if (-not $listener) { throw 'Could not open a local port to receive the pairing token.' }

  try {
    $pairUrl = "$appUrl/connector/pair?code=$pairCode&port=$port"
    Write-Status 'Opening your browser to sign in and pair this connector...' 'Cyan'
    Start-Process $pairUrl

    $deadline = (Get-Date).AddMinutes(5)
    while ((Get-Date) -lt $deadline) {
      if (-not $listener.Pending()) { Start-Sleep -Milliseconds 300; continue }
      $client = $listener.AcceptTcpClient()
      try {
        # Browsers open and drop loopback connections for reasons that have
        # nothing to do with our request — preconnect, speculative fetches,
        # a CORS preflight the browser abandons after reading the response
        # headers. Any of those can throw partway through a raw socket read.
        # A single one of those must not take the whole listener down with
        # it — confirmed the hard way against a real browser, not just
        # curl, which never triggers this. Only a genuinely successful
        # /pair POST is allowed to return out of this loop.
        try {
          $stream = $client.GetStream()
          $buffer = New-Object byte[] 8192
          $ms = New-Object System.IO.MemoryStream
          $headerEnd = -1
          while ($headerEnd -lt 0 -and $ms.Length -lt 65536) {
            $read = $stream.Read($buffer, 0, $buffer.Length)
            if ($read -le 0) { break }
            $ms.Write($buffer, 0, $read)
            $headerEnd = ([System.Text.Encoding]::ASCII.GetString($ms.ToArray())).IndexOf("`r`n`r`n")
          }
          if ($headerEnd -lt 0) { continue }

          $allBytes = $ms.ToArray()
          $headerText = [System.Text.Encoding]::ASCII.GetString($allBytes, 0, $headerEnd)
          $lines = $headerText -split "`r`n"
          $reqParts = $lines[0] -split ' '
          $method = $reqParts[0]
          $reqPath = $reqParts[1]

          $contentLength = 0
          foreach ($line in $lines) {
            if ($line -match '^Content-Length:\s*(\d+)') { $contentLength = [int]$Matches[1] }
          }

          $bodyStart = $headerEnd + 4
          while (($allBytes.Length - $bodyStart) -lt $contentLength) {
            $read = $stream.Read($buffer, 0, $buffer.Length)
            if ($read -le 0) { break }
            $ms.Write($buffer, 0, $read)
            $allBytes = $ms.ToArray()
          }

          if ($method -eq 'OPTIONS') { Send-PairResponse $stream 204 'No Content' ''; continue }
          if ($method -ne 'POST' -or $reqPath -ne '/pair') { Send-PairResponse $stream 404 'Not Found' '{}'; continue }

          $bodyBytes = $allBytes[$bodyStart..($bodyStart + $contentLength - 1)]
          $payload = ([System.Text.Encoding]::UTF8.GetString($bodyBytes)) | ConvertFrom-Json

          if ($payload.code -ne $pairCode) { Send-PairResponse $stream 403 'Forbidden' '{}'; continue }

          Send-PairResponse $stream 200 'OK' '{"ok":true}'
          return $payload.token
        } catch {
          continue
        }
      } finally {
        $client.Close()
      }
    }
    throw 'Timed out waiting for sign-in. Run the script again when you are ready.'
  } finally {
    $listener.Stop()
  }
}

if (Test-Path $configPath) {
  $config = Get-Content $configPath -Raw | ConvertFrom-Json
} else {
  Write-Host ''
  Write-Host 'FundReap Live Connector - first-time setup' -ForegroundColor Cyan
  Write-Host ''
  $token = Get-TokenViaPairing
  Write-Status 'Paired.' 'Green'

  $company = Read-Host 'Exact Tally company name (as shown at the top of Tally)'
  if ([string]::IsNullOrWhiteSpace($company)) { Write-Host 'No company entered - exiting.' -ForegroundColor Red; exit 1 }

  $config = [pscustomobject]@{ token = $token.Trim(); company = $company.Trim() }
  $config | ConvertTo-Json | Set-Content $configPath
  Write-Host "Saved. Editing $configPath directly changes these later." -ForegroundColor DarkGray
}

function Build-Request([string]$reportXml) {
  return $reportXml -replace '\{\{COMPANY\}\}', [System.Security.SecurityElement]::Escape($config.company)
}

$outstandingReq = Build-Request @'
<ENVELOPE>
 <HEADER>
  <VERSION>1</VERSION>
  <TALLYREQUEST>EXPORT</TALLYREQUEST>
  <TYPE>DATA</TYPE>
  <ID>Bills Receivable</ID>
 </HEADER>
 <BODY>
  <DESC>
   <STATICVARIABLES>
    <SVCURRENTCOMPANY>{{COMPANY}}</SVCURRENTCOMPANY>
    <SVEXPORTFORMAT>$SysName:XML</SVEXPORTFORMAT>
   </STATICVARIABLES>
  </DESC>
 </BODY>
</ENVELOPE>
'@

$receiptsReq = Build-Request @'
<ENVELOPE>
 <HEADER>
  <VERSION>1</VERSION>
  <TALLYREQUEST>EXPORT</TALLYREQUEST>
  <TYPE>COLLECTION</TYPE>
  <ID>ReceiptVouchers</ID>
 </HEADER>
 <BODY>
  <DESC>
   <STATICVARIABLES>
    <SVCURRENTCOMPANY>{{COMPANY}}</SVCURRENTCOMPANY>
   </STATICVARIABLES>
   <TDL>
    <TDLMESSAGE>
     <COLLECTION NAME="ReceiptVouchers" ISMODIFY="No">
      <TYPE>Voucher</TYPE>
      <FILTER>FilterReceipts</FILTER>
      <FETCH>DATE, VOUCHERNUMBER, PARTYLEDGERNAME, VOUCHERTYPENAME</FETCH>
      <FETCH>LEDGERNAME, AMOUNT, BILLALLOCATIONS.NAME, BILLALLOCATIONS.AMOUNT</FETCH>
     </COLLECTION>
     <SYSTEM TYPE="Formulae" NAME="FilterReceipts">$VoucherTypeName = "Receipt"</SYSTEM>
    </TDLMESSAGE>
   </TDL>
  </DESC>
 </BODY>
</ENVELOPE>
'@

function Get-TallyXml([string]$requestXml) {
  try {
    return Invoke-RestMethod -Uri $gatewayUrl -Method Post -Body $requestXml -ContentType 'text/xml' -TimeoutSec 30
  } catch {
    throw "Could not reach Tally at $gatewayUrl - $($_.Exception.Message). Is TallyPrime open, with the gateway server enabled (F1 > Settings > Connectivity) and '$($config.company)' the loaded company?"
  }
}

Write-Host ''
Write-Status "Polling Tally every $pollSeconds seconds for '$($config.company)'." 'Green'
Write-Status 'Leave this window open. Ctrl+C to stop.' 'DarkGray'
Write-Host ''

$lastHash = $null

while ($true) {
  try {
    $outstandingXml = Get-TallyXml $outstandingReq
    $receiptsXml    = Get-TallyXml $receiptsReq

    # Both Invoke-RestMethod calls above return the response auto-parsed as
    # XML objects when content-type allows it; re-serialize to raw text so
    # the combined payload matches exactly what server-side parse-tally-xml.js
    # expects (it parses text, not a .NET XML DOM).
    $outstandingText = if ($outstandingXml -is [System.Xml.XmlDocument]) { $outstandingXml.OuterXml } else { [string]$outstandingXml }
    $receiptsText    = if ($receiptsXml -is [System.Xml.XmlDocument]) { $receiptsXml.OuterXml } else { [string]$receiptsXml }

    $combined = $outstandingText + $clearedMarker + $receiptsText
    $hash = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::Create().ComputeHash([System.Text.Encoding]::UTF8.GetBytes($combined)))

    if ($hash -eq $lastHash) {
      Write-Status 'No change since last poll.' 'DarkGray'
    } else {
      Write-Status 'Change detected - uploading ...'
      $bytes = [System.Text.Encoding]::UTF8.GetBytes($combined)
      $base64 = [System.Convert]::ToBase64String($bytes)
      $body = @{ fileBase64 = $base64; filename = "tally-live-$(Get-Date -Format 'yyyyMMdd-HHmmss').xml" } | ConvertTo-Json

      $r = Invoke-RestMethod -Uri $apiUrl -Method Post -Body $body `
        -ContentType 'application/json' `
        -Headers @{ Authorization = "Bearer $($config.token)" }

      if ($r.error) {
        Write-Status "  Rejected: $($r.error)" 'Yellow'
      } else {
        $lastHash = $hash
        Write-Status '  Synced.' 'Green'
      }
    }
  } catch {
    Write-Status "  $($_.Exception.Message)" 'Red'
    Write-Status '  Will retry next poll.' 'DarkGray'
  }
  Start-Sleep -Seconds $pollSeconds
}

Verify the file you downloaded matches this source exactly. Its SHA-256 checksum is published at fundreap-connector-live.ps1.sha256 — computed live from the file actually being served. After downloading, check it yourself in PowerShell:

Get-FileHash .\fundreap-connector-live.ps1 -Algorithm SHA256

Compare the Hash value it prints to what's at the link above — a match means the file on your computer is byte-for-byte what's shown here.

Step 2 — Download and run the script

  1. Download it from the Integrations page (Integrations → Auto-sync — no export neededSet up), or directly: fundreap-connector-live.ps1
  2. Right-click the downloaded file → Run with PowerShell
  3. It opens a browser tab and asks you to sign in with your FundReap account — the same email-link sign-in as the main app. Already signed in to fundreap.com in that browser? It pairs instantly with no form at all.
  4. On Chrome, a permission prompt appears — "fundreap.com wants to access devices on your local network". Click Allow. This is Chrome itself asking, the same way it would for a camera or microphone — it's how the page hands the token to the script running on your PC, and it only asks once per browser.
  5. Once paired, the script itself asks for your exact Tally company name — copy it character-for-character from the top of the Tally window. This is the one thing FundReap has no way to know on its own. Both the pairing and the company name get saved next to the script, in fundreap-connector-live.config.json.

Nothing to copy-paste, no token to lose — if you ever need to re-pair (new PC, lost the config file), just delete fundreap-connector-live.config.json and run the script again.

Step 3 — Leave it running

That's it. Every 3 minutes it asks Tally for your current outstanding bills and recent receipts; if nothing changed it does nothing; if something changed it uploads automatically. Enter a sale or record a payment in Tally, and within a few minutes your plan on fundreap.com reflects it — no export, no login, no click.

Common problems

"Could not reach the connector script on this PC" during pairing

Almost always means the Chrome permission prompt ("fundreap.com wants to access devices on your local network") got dismissed or Blocked instead of Allowed — reload the pairing page and choose Allow this time. Otherwise, make sure the PowerShell window from Step 2 is still open; closing it before pairing finishes stops the listener it's waiting on.

"Could not reach Tally at http://localhost:9000"

TallyPrime either isn't running, doesn't have the right company loaded, or the Gateway Server setting from Step 1 didn't take (remember: it needs a restart to apply). Open Tally, load the company, check F1 → Settings → Connectivity shows "Server", and try again.

"Running scripts is disabled on this system" / SmartScreen warning

Same fix as the folder-watcher connector — see the common problems section of that guide. Both scripts are plain, readable PowerShell with nothing compiled or hidden.

It says "No change since last poll" every time, even after I added a voucher

Double-check the company name in fundreap-connector-live.config.json matches the company currently open in Tally exactly — a mismatch means it's quietly asking Tally about the wrong (or no) company, which reads back as "nothing outstanding" rather than an error.

Turning it off

Close the PowerShell window — nothing runs when it isn't open, there's no background service or scheduled task installed. To fully disconnect (invalidate the token so this script can no longer upload anything even if it's run again), go to Integrations → Connect Tally → Reconnect — the token is shared with the folder-watcher connector, so rotating it there revokes both.

Haven't connected yet?

Sign in, then Integrations → Auto-sync to download the script.

Go to Integrations

Also see: The folder-watcher connector (simpler, no Tally configuration needed) · How to reduce DSO