Copy puts the full contract on your clipboard, prompt included — paste it into any assistant and it can build your backend.
Data Sync is one-way. Totals pushes your records to a URL you choose and never reads anything back.
Your backend has three jobs:
POST, PUT, or PATCH requests (chosen per rule) at paths the user defines.That's a webhook receiver, not a protocol — about thirty lines in any framework. Everything below is the detail, plus a complete single-file server you can copy.
Data Sync lives at Settings → Advanced → Data Sync. It is off by default and starts with an explicit consent screen. Two concepts do all the work:
A base URL plus auth: an API-key header (any header name, X-API-Key by default), a bearer token, basic auth, or none. Secrets are kept in the platform keystore, never in the app database.
One entity per rule — transactions, accounts, or budgets — with optional filters (bank, account, amount range, date range, credit/debit, profile), the HTTP method and path, an optional field map, and either one record per request or bulk arrays.
Rules fire manually, on every new transaction, when connectivity returns, on an interval (15-minute minimum), or at fixed times daily. Everything flows through a durable outbox drained in the background, so expect bursts. When a rule is first enabled, Totals offers to backfill everything that already matches, and a reset-and-resend action can replay all data from scratch.
POST /transactions HTTP/1.1
Host: api.your-server.dev
Content-Type: application/json
X-API-Key: change-me
{
"amount": -350,
"reference": "FT26TEST0001",
"creditor": null,
"receiver": "Ethio Telecom",
"note": "Airtime",
"time": "2026-07-05T09:30:00",
"status": "Completed",
"currentBalance": "11,995.67",
"bankId": 1,
"type": "DEBIT",
"transactionLink": null,
"accountNumber": "1234",
"categoryId": null,
"categoryIds": null,
"categoryNames": []
}| Method | POST, PUT, or PATCH, chosen per rule. Never GET or DELETE. |
| URL | Destination base URL plus the rule's path. Paths may embed record fields: /transactions/{reference} resolves to /transactions/FT26189QWK1, percent-encoded. Bulk rules cannot use placeholders. |
| Headers | Content-Type: application/json, plus the configured auth: X-API-Key: <secret>, Authorization: Bearer <secret>, or Authorization: Basic <base64>. |
| Body | One JSON object per request, or a JSON array in bulk mode, chunked at 500 records per request. |
| Limits | 1 MB per request — anything larger is never sent and is marked failed on-device. 15-second timeout: respond fast, defer slow work. |
| Transport | HTTPS required in release builds; localhost and private-LAN addresses are refused. For a home server, use a tunnel (Cloudflare Tunnel, Tailscale Funnel) or a small VPS. |
| Probe | The app's Test connection button sends HEAD to the base URL with auth headers attached; any HTTP response counts as reachable. |
| Your response | What Totals does |
|---|---|
2xx | Success — the record is marked sent. The response body is ignored, except the first ~200 characters shown in the app's sync log. |
408 429 5xxtimeouts & network errors | Retried with exponential backoff — doubling from 30 seconds, ±20% jitter, Retry-After honored (seconds or HTTP date). After 8 failed attempts the record is dropped. |
| anything else3xx, 400, 401, 403, 404, 422… | Dropped immediately — no retry. |
The give-up-on-4xx rule is deliberate — a record your schema rejects shouldn't loop forever — but it makes misconfigured auth expensive: every record sent with a wrong key is dropped. Fix the key, then use reset-and-resend on the Data Sync screen.
3xx counts as give-up — respond 2xx directly at the configured URL.The same record will reach you more than once — retries, edits, backfills, and reset-and-resend all resend it. Key your writes on the identity fields:
| Entity | Identity |
|---|---|
| transactions | reference |
| accounts | accountNumber + bank |
| budgets | id |
Payloads are built from the live record at send time. Each arrival is the record's current state, not a change event: rapid edits collapse into one send, and there is no ordering guarantee across records. Model your store as a mirror, not an event log.
The default shapes, straight from the app's models. Keys are present on every upsert unless marked otherwise; values can be null. New optional fields may be added over time — ignore keys you don't recognize.
{
"amount": 2500.0, // positive = credit, negative = debit
"reference": "FT26189QWK1", // bank reference — the record's stable id
"creditor": "ABEBE KEBEDE",
"receiver": null,
"note": "July rent",
"time": "2026-07-01T09:30:00",
"status": "Completed",
"currentBalance": "12,345.67", // a string, as parsed from the SMS
"bankId": 1, // Totals' internal bank id
"type": "CREDIT", // "CREDIT" | "DEBIT"
"transactionLink": null, // bank receipt URL, when the SMS has one
"accountNumber": "1234", // often just the last digits from the SMS
"categoryId": 7, // primary category
"categoryIds": [7, 9], // all assigned categories
"categoryNames": ["Rent", "Household"] // resolved names, always present
}
// present only when set: profileId, serviceCharge, vat,
// sourceType, sourceMessageId, sourceFingerprint// identity fields only. Same method, same path as the upsert.
{ "reference": "FT26189QWK1" }{
"accountNumber": "1000123456789", // full number — identity, with bank
"bank": 1, // same id space as bankId above
"balance": 12345.67,
"accountHolderName": "ABEBE KEBEDE",
"settledBalance": 12000.0, // nullable
"pendingCredit": 345.67, // nullable
"profileId": 1 // present only when set
}// identity fields only. Same method, same path as the upsert.
{ "accountNumber": "1000123456789", "bank": 1 }{
"id": 3, // identity
"name": "Groceries",
"type": "monthly",
"amount": 8000.0,
"categoryId": 7,
"categoryIds": [7, 9],
"startDate": "2026-07-01T00:00:00.000",
"endDate": null,
"rollover": false,
"alertThreshold": 80.0, // percent
"isActive": true,
"createdAt": "2026-05-01T10:00:00.000",
"updatedAt": "2026-06-28T18:12:00.000",
"timeFrame": "monthly",
"calendar": "gregorian"
}// identity fields only. Same method, same path as the upsert.
{ "id": 3 }Field mapping caveat: each rule can rename fields ({ totalsField: yourField }). When a map is set, only mapped fields are sent unless the rule opts into unmapped ones too — so a configured endpoint may receive renamed or fewer keys than shown here. The reference server below assumes default payloads.
Deleting a record sends the same request — same method, same path— with a body containing only the record's identity fields:
{ "reference": "FT26189QWK1" } // transactions
{ "accountNumber": "1000123456789", "bank": 1 } // accounts
{ "id": 3 } // budgetsThat is also how you tell them apart: upserts always carry the full field set, deletes only the identity. In bulk mode the same rule applies per element — a delete rides inside the array as an identity-only object next to full upsert objects. If you use a field map, keep the identity fields mapped — otherwise delete bodies arrive empty and indistinguishable.
Deletes skip rule filters (the record is gone, so there is nothing to filter on). A delete for a record you never stored is normal — treat it as a no-op and return 2xx.
A complete backend in one file, in the runtime of your choice: zero dependencies, every record stored in totals.db. Node 22.13+ (built-in node:sqlite), Bun 1.1+ (bun:sqlite), or Python 3.10+ (stdlib sqlite3). Each handles both batch modes, deletes, auth, the HEAD probe, and the status-code contract. Copy one, run it, point a rule at it.
// totals-sync-server.mjs — a minimal self-hosted backend for Totals Data Sync.
// Zero dependencies. Node 22.13+ (uses the built-in node:sqlite).
//
// API_KEY=change-me node totals-sync-server.mjs
//
// Point Totals at it (Settings → Advanced → Data Sync):
// Destination Base URL: your public https URL
// Auth: API key header · Header name: X-API-Key
// Rules transactions → POST /transactions
// accounts → POST /accounts
// budgets → POST /budgets
// Works with per-record and bulk-array rules alike.
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";
const PORT = process.env.PORT ?? 8787;
const API_KEY = process.env.API_KEY ?? "change-me";
const db = new DatabaseSync("totals.db");
db.exec(`
CREATE TABLE IF NOT EXISTS records (
entity TEXT NOT NULL,
ref TEXT NOT NULL,
body TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (entity, ref)
)
`);
const upsert = db.prepare(`
INSERT INTO records (entity, ref, body) VALUES (?, ?, ?)
ON CONFLICT (entity, ref)
DO UPDATE SET body = excluded.body, updated_at = datetime('now')
`);
const remove = db.prepare("DELETE FROM records WHERE entity = ? AND ref = ?");
// How Totals identifies each record. Upserts MUST be keyed on these —
// the same record can arrive more than once (retries, edits, backfills).
const identityOf = {
transactions: (r) => r.reference,
accounts: (r) =>
r.accountNumber != null && r.bank != null
? r.accountNumber + "|" + r.bank
: undefined,
budgets: (r) => (r.id != null ? "budget:" + r.id : undefined),
};
const identityFields = {
transactions: ["reference"],
accounts: ["accountNumber", "bank"],
budgets: ["id"],
};
// A delete arrives as the same request, but its body carries ONLY the
// identity fields. Upserts always include the full field set.
const isDelete = (entity, record) =>
Object.keys(record).every((key) => identityFields[entity].includes(key));
const server = createServer((req, res) => {
// Totals' "Test connection" button sends HEAD to the base URL.
if (req.method === "HEAD") return res.writeHead(204).end();
if (req.headers["x-api-key"] !== API_KEY) {
// Any 4xx other than 408/429 tells Totals to give up on the record —
// correct for bad auth. Fix the key in the app, then reset & resend
// from the Data Sync screen to recover dropped records.
return res.writeHead(401).end();
}
const [entity] = req.url.split("?")[0].split("/").filter(Boolean);
if (!identityOf[entity] || !["POST", "PUT", "PATCH"].includes(req.method)) {
return res.writeHead(404).end();
}
let raw = "";
req.on("data", (chunk) => (raw += chunk));
req.on("end", () => {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return res.writeHead(400).end("body must be JSON");
}
// Per-record rules send one object; bulk-array rules send an array
// of up to 500 records.
const records = Array.isArray(parsed) ? parsed : [parsed];
try {
for (const record of records) {
const ref = identityOf[entity](record);
if (ref == null) continue;
if (isDelete(entity, record)) remove.run(entity, String(ref));
else upsert.run(entity, String(ref), JSON.stringify(record));
}
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, received: records.length }));
} catch (error) {
console.error(error);
// 5xx means "try again later" — Totals retries with backoff.
res.writeHead(500).end();
}
});
});
server.listen(PORT, () => {
console.log("totals sync server listening on port " + PORT);
});// totals-sync-server.bun.mjs, a minimal self-hosted backend for Totals Data Sync.
// Zero dependencies. Bun 1.1+ (uses the built-in bun:sqlite).
//
// API_KEY=change-me bun totals-sync-server.bun.mjs
//
// Same contract as the Node variant: per-record and bulk-array rules,
// deletes, auth, and the HEAD probe.
import { Database } from "bun:sqlite";
const PORT = process.env.PORT ?? 8787;
const API_KEY = process.env.API_KEY ?? "change-me";
const db = new Database("totals.db");
db.run(`
CREATE TABLE IF NOT EXISTS records (
entity TEXT NOT NULL,
ref TEXT NOT NULL,
body TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (entity, ref)
)
`);
const upsert = db.prepare(`
INSERT INTO records (entity, ref, body) VALUES (?, ?, ?)
ON CONFLICT (entity, ref)
DO UPDATE SET body = excluded.body, updated_at = datetime('now')
`);
const remove = db.prepare("DELETE FROM records WHERE entity = ? AND ref = ?");
// How Totals identifies each record. Upserts MUST be keyed on these:
// the same record can arrive more than once (retries, edits, backfills).
const identityOf = {
transactions: (r) => r.reference,
accounts: (r) =>
r.accountNumber != null && r.bank != null
? r.accountNumber + "|" + r.bank
: undefined,
budgets: (r) => (r.id != null ? "budget:" + r.id : undefined),
};
const identityFields = {
transactions: ["reference"],
accounts: ["accountNumber", "bank"],
budgets: ["id"],
};
// A delete arrives as the same request, but its body carries ONLY the
// identity fields. Upserts always include the full field set.
const isDelete = (entity, record) =>
Object.keys(record).every((key) => identityFields[entity].includes(key));
Bun.serve({
port: PORT,
async fetch(req) {
// Totals' "Test connection" button sends HEAD to the base URL.
if (req.method === "HEAD") return new Response(null, { status: 204 });
if (req.headers.get("x-api-key") !== API_KEY) {
// Any 4xx other than 408/429 tells Totals to give up on the record,
// which is correct for bad auth. Fix the key in the app, then
// reset & resend from the Data Sync screen to recover dropped records.
return new Response(null, { status: 401 });
}
const [entity] = new URL(req.url).pathname.split("/").filter(Boolean);
if (!identityOf[entity] || !["POST", "PUT", "PATCH"].includes(req.method)) {
return new Response(null, { status: 404 });
}
let parsed;
try {
parsed = await req.json();
} catch {
return new Response("body must be JSON", { status: 400 });
}
// Per-record rules send one object; bulk-array rules send an array
// of up to 500 records.
const records = Array.isArray(parsed) ? parsed : [parsed];
try {
for (const record of records) {
const ref = identityOf[entity](record);
if (ref == null) continue;
if (isDelete(entity, record)) remove.run(entity, String(ref));
else upsert.run(entity, String(ref), JSON.stringify(record));
}
return Response.json({ ok: true, received: records.length });
} catch (error) {
console.error(error);
// 5xx means "try again later". Totals retries with backoff.
return new Response(null, { status: 500 });
}
},
});
console.log("totals sync server listening on port " + PORT);# totals_sync_server.py, a minimal self-hosted backend for Totals Data Sync.
# Zero dependencies. Python 3.10+ (stdlib sqlite3).
#
# API_KEY=change-me python3 totals_sync_server.py
#
# Same contract as the Node variant: per-record and bulk-array rules,
# deletes, auth, and the HEAD probe.
import json
import os
import sqlite3
from http.server import BaseHTTPRequestHandler, HTTPServer
PORT = int(os.environ.get("PORT", "8787"))
API_KEY = os.environ.get("API_KEY", "change-me")
db = sqlite3.connect("totals.db")
db.execute("""
CREATE TABLE IF NOT EXISTS records (
entity TEXT NOT NULL,
ref TEXT NOT NULL,
body TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (entity, ref)
)
""")
db.commit()
# How Totals identifies each record. Upserts MUST be keyed on these:
# the same record can arrive more than once (retries, edits, backfills).
IDENTITY_FIELDS = {
"transactions": ["reference"],
"accounts": ["accountNumber", "bank"],
"budgets": ["id"],
}
def identity_of(entity, record):
values = [record.get(field) for field in IDENTITY_FIELDS[entity]]
if any(value is None for value in values):
return None
return "|".join(str(value) for value in values)
# A delete arrives as the same request, but its body carries ONLY the
# identity fields. Upserts always include the full field set.
def is_delete(entity, record):
return all(key in IDENTITY_FIELDS[entity] for key in record)
class Handler(BaseHTTPRequestHandler):
# Totals' "Test connection" button sends HEAD to the base URL.
def do_HEAD(self):
self.send_response(204)
self.end_headers()
def _write(self):
if self.headers.get("X-API-Key") != API_KEY:
# Any 4xx other than 408/429 tells Totals to give up on the
# record, which is correct for bad auth. Fix the key in the app,
# then reset & resend from the Data Sync screen.
self.send_response(401)
self.end_headers()
return
entity = self.path.split("?")[0].strip("/").split("/")[0]
if entity not in IDENTITY_FIELDS:
self.send_response(404)
self.end_headers()
return
length = int(self.headers.get("Content-Length") or 0)
try:
parsed = json.loads(self.rfile.read(length))
except ValueError:
self.send_response(400)
self.end_headers()
self.wfile.write(b"body must be JSON")
return
# Per-record rules send one object; bulk-array rules send an array
# of up to 500 records.
records = parsed if isinstance(parsed, list) else [parsed]
try:
for record in records:
ref = identity_of(entity, record)
if ref is None:
continue
if is_delete(entity, record):
db.execute(
"DELETE FROM records WHERE entity = ? AND ref = ?",
(entity, ref),
)
else:
db.execute(
"""INSERT INTO records (entity, ref, body)
VALUES (?, ?, ?)
ON CONFLICT (entity, ref) DO UPDATE SET
body = excluded.body,
updated_at = datetime('now')""",
(entity, ref, json.dumps(record)),
)
db.commit()
body = json.dumps({"ok": True, "received": len(records)}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
except Exception as error:
print(error)
# 5xx means "try again later". Totals retries with backoff.
self.send_response(500)
self.end_headers()
do_POST = _write
do_PUT = _write
do_PATCH = _write
if __name__ == "__main__":
print(f"totals sync server listening on port {PORT}")
HTTPServer(("", PORT), Handler).serve_forever()# pick your runtime
API_KEY=change-me node totals-sync-server.mjs
API_KEY=change-me bun totals-sync-server.bun.mjs
API_KEY=change-me python3 totals_sync_server.py
# In another terminal — get a temporary public https URL for it:
cloudflared tunnel --url http://localhost:8787cloudflared quick tunnels are handy while you experiment — you get a public HTTPS URL in one command. Give the server a stable home (VPS, named tunnel, Fly, Render…) before relying on it.
Impersonate the app from a terminal to develop your backend without a phone in hand:
curl -i -X POST https://api.your-server.dev/transactions \
-H 'Content-Type: application/json' \
-H 'X-API-Key: change-me' \
-d '{
"amount": -350.0,
"reference": "FT26TEST0001",
"creditor": null,
"receiver": "Ethio Telecom",
"note": "Airtime",
"time": "2026-07-05T09:30:00",
"status": "Completed",
"currentBalance": "11,995.67",
"bankId": 1,
"type": "DEBIT",
"transactionLink": null,
"accountNumber": "1234",
"categoryId": null,
"categoryIds": null,
"categoryNames": []
}'To watch real payloads without writing any code, point a rule at a webhook inspector like webhook.site — but only with test data or a tightly filtered rule. Inspector URLs are effectively public, and this is your financial data.
From the app: Test connectionverifies reachability and auth wiring; then trigger a manual sync and watch the run log — every send records your server's status code and the start of its response.
Data Sync is opt-in and off by default. Your data goes only to servers you configure, over HTTPS — the full story is in the privacy policy.
The engine is open source: the durable outbox, backoff logic, and state machine live in lib/services/data_sync on GitHub, with unit tests that double as the spec.
Last updated July 2026. If this page and the code ever disagree, the code wins — and we'd love an issue about it.