API Reference

A versioned REST API for connecting another system to your True Trail organization's data. Every endpoint here calls the exact same underlying logic the product's own UI uses -- there is one implementation of each operation, reachable two ways.

Authentication

Every request needs a per-organization API key as a bearer token:

Header
Authorization: Bearer ttk_<your-key>

Create a key from Settings → API Keys (organization administrators only, once signed in). The full key is shown exactly once, at creation -- store it somewhere safe; it can't be viewed again, only revoked and replaced with a new one. A key has one of two scopes:

  • read -- can call GET endpoints only.
  • write -- can call every endpoint (write implies read).

An invalid, missing, or revoked key returns 401. A read-scoped key calling a write-only endpoint returns 403.

Base URL -- the same domain you use to log into True Trail itself, with /api/v1appended. For this deployment, that's:

Base URL
https://truetrail.markangeloatienza.com/api/v1

Plan availability

The external API is available on the Organization and Enterprise plans. A Community-plan organization can still create and view API keys from Settings, but a request made with one returns 403 with code PLAN_FEATURE_LOCKEDuntil the organization upgrades -- keys aren't silently broken, the response says exactly what to do. Any organization still on its free trial has full API access regardless of which plan it will eventually choose. Outbound webhooks share this same plan gate.

Rate limits

Requests are limited per API key, using a sliding window:

  • 120 requests / minute for a real organization's key.
  • 20 requests / minute for a key issued by one of the public demo organizations (see the live demo) -- a tighter quota since those are openly published and shared.

Exceeding the limit returns 429 with code RATE_LIMITED and a Retry-After header (seconds until the window resets). Back off and retry after that many seconds rather than retrying immediately.

Response shape

Every successful response is:

Success
{ "data": ... }

Every error response is:

Error
{
  "error": {
    "message": "Human-readable explanation.",
    "code": "MACHINE_READABLE_CODE"
  }
}

Money

All amounts are integers in minor currency units (centavos, cents, etc.) -- never a decimal string. 15000means 150.00 in the organization's configured currency. GET /summarydoesn't currently return the currency code -- read it from the organization's own settings in the product UI for now.

Endpoints

GET/entitiesread scope

List members. Query params: query (search), page (default 1, 25 per page).

Response
{ "data": { "entities": [...], "total": 42, "page": 1, "pageSize": 25 } }
POST/entitieswrite scope

Create a member. A structure-free public ID (ENT-XXXXXX) is generated automatically.

Request
{
  "displayName": "Juan Dela Cruz",
  "contactEmail": "juan@example.com"
}
GET/entities/{id}read scope

Get one member by their internal id.

GET/ledgerread scope

List ledger entries. Query params: type (income/expense/correction), entityId, page.

GET/ledger/{id}read scope

Get one ledger entry, including its evidence and any corrections that reference it.

POST/ledger/incomewrite scope

entityId is required.

Request
{
  "entityId": "…uuid…",
  "amountMinorUnits": 15000,
  "description": "August dues",
  "transactionDate": "2026-08-01"
}
POST/ledger/expensewrite scope

Provide either entityId or recipientName.

Request
{
  "recipientName": "ABC Hardware",
  "amountMinorUnits": 500000,
  "description": "Community repair",
  "transactionDate": "2026-08-03"
}
POST/ledger/{id}/correctionswrite scope

Create a correction for an existing income or expense entry ({id} must match originalEntryId in the body). Cannot target an entry that is itself a correction.

Request
{
  "originalEntryId": "…uuid…",
  "amountMinorUnits": 480000,
  "description": "Community repair (corrected)",
  "transactionDate": "2026-08-03",
  "correctionReason": "Supplier receipt updated"
}
GET/summaryread scope

Organization-wide totals. balanceincludes the organization's configured startingBalance (set at signup or from Settings, before the first ledger entry); totalCollected/totalSpent do not.

Response
{
  "data": {
    "totalCollected": 1500000,
    "totalSpent": 500000,
    "startingBalance": 200000,
    "balance": 1200000
  }
}
Not yet covered: evidence upload/download and share-link management are only available through the product's own UI today, not the API.

Webhooks

Separate from the request/response API above -- instead of polling GET /ledger, True Trail can push an HTTP request to your own endpoint the moment income, an expense, or a correction is recorded. Configured entirely from Settings → Webhooks-- there's no API endpoint for this, it's a UI-managed integration, not part of /api/v1.

Available on the Organization and Enterprise plans (same gate as the API itself) -- up to 3 active webhooks per organization, in two destination kinds:

  • Slack -- paste a Slack Incoming Webhook URL (https://hooks.slack.com/services/…). True Trail posts an already-formatted message directly to it -- no relay (Zapier, Make, etc.), no signature to verify.
  • Generic -- add any https:// endpoint URL (no localhostor private IP addresses) and a signing secret is generated immediately. Unlike an API key, the secret stays visible and copyable afterward -- you'll need it again any time you set up or re-verify the receiving end's signature check. Use this for Zapier/Make/n8n or your own backend -- everything below is specific to this destination kind.

Event: ledger_entry.created

Fired once per entry -- a bulk income submission fires one event per member, not one aggregate event. Every request is a POST with these headers:

Headers
Content-Type: application/json
X-Webhook-Id: <uuid, one per delivery>
X-Webhook-Timestamp: <unix seconds>
X-Webhook-Signature: sha256=<hex>
Body
{
  "id": "…uuid…",
  "type": "ledger_entry.created",
  "createdAt": "2026-08-06T12:00:00.000Z",
  "data": {
    "id": "…uuid…",
    "type": "income",
    "entityId": "…uuid…",
    "amountMinorUnits": 15000,
    "description": "August dues",
    "transactionDate": "2026-08-01T00:00:00.000Z"
  }
}

Verifying a delivery

X-Webhook-Signature is an HMAC-SHA256 of {timestamp}.{raw body}, keyed with your signing secret, hex-encoded. Recompute it and compare (constant-time) before trusting the payload:

Node.js
const crypto = require("crypto");

function isValidDelivery(secret, timestamp, rawBody, signatureHeader) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  const provided = signatureHeader.replace(/^sha256=/, "");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
}

Delivery behavior

A 5-second timeout, no automatic retry, and no redirects followed (a redirecting response counts as a failed delivery, not a followed one). Each webhook's last delivery status, time, and error (if any) is shown in Settings → Webhooks -- check there if you suspect a delivery was missed, rather than assuming it will be retried.

Error codes

CodeStatusMeaning
UNAUTHORIZED401Missing, invalid, or revoked API key.
FORBIDDEN403A read-scoped key called a write-only endpoint.
PLAN_FEATURE_LOCKED403The organization's plan doesn't include API access.
NOT_FOUND404No matching record in this organization.
VALIDATION_ERROR400Request body failed validation -- see message for which field.
RATE_LIMITED429Too many requests -- see Retry-After.

Other codes come directly from the underlying operation (e.g. CANNOT_CORRECT_CORRECTION) -- the same errors the product's own UI surfaces to users, with the same message.

Versioning

Breaking changes ship as /api/v2; /api/v1 keeps working for existing integrations. Non-breaking additions (new optional fields, new endpoints) may land in v1 directly.