# LedgerSync API: build my live bank integration
Follow this document exactly. Do not invent endpoints, fields, query
parameters, or error codes.
I am not a programmer. Assume I cannot answer a technical question
without help.
If you need something only I can give you, ask me for one thing at a
time, in plain words, and explain what it is and where I would find it.
If I say I do not know, do not stop and do not guess: tell me the
options, pick a sensible default, and carry on.
You need three things from me. Ask for them one at a time:
- My live API key, starting with `sk_live_`. Help me put it directly in
my project's secret or environment settings if your tool supports that.
- Where my app runs, so you know where to put the code. If I do not
know, ask me who looks after my website and tell me to forward your
answer to them.
- A public web address for receiving updates. If I do not have one, do
not stall: set one up yourself if you can, or explain in one sentence
what it is and help me get a permanent HTTPS address for the app. Never
just ask me for a URL and wait. `localhost` will never work.
Keep my live key in server-side secret or environment settings. Never
print it back to me, log it, or write it into code you show me. Keep
secret files out of version control. Never ask for my bank password.
If you can fetch a web page yourself and need more detail than this
document carries, read https://docs.ledgersyncappv2.com/llms-full.txt
## What this API does
LedgerSync links my users' bank accounts and returns their accounts,
balances, transactions and statements through one REST API. Bank
passwords are typed into a LedgerSync-hosted page and never reach my
servers.
## Base URL and auth
Use https://api.ledgersyncappv2.com/v3 from the start, with my live key.
Send `Authorization: Bearer <my key>` on every request. JSON over HTTPS.
Use a plain HTTP client for this integration.
Every response has an `X-LS-Trace-Id` header. Log it.
## How the objects fit together
Client (`cli_` + 24 hex) is my record of one end user.
-> Connection (`con_<SOURCE>_<number>`, e.g. `con_FINICITY_41294`),
one per linked bank.
-> Account (`acc_<SOURCE>_<number>`)
-> Transaction (`txn_<SOURCE>_<number>`) and Statement
(`stmt_<SOURCE>_<number>`)
SOURCE is FINICITY, MX, FDE or PDF, and LedgerSync picks it, never me.
## Set up live updates first
Before anyone opens a bank connection link, deploy the HTTPS receiver
with the signature checks below, then create the live subscription.
To receive webhooks, create a subscription once with
`POST /webhooks/subscriptions` with `{"url": "...", "event_types": ["*"]}`.
The response carries a `signing_secret` ONCE and no endpoint returns it
later. Save it directly in the deployed receiver's server-side secret or
environment settings. If you cannot configure those settings, tell me
how to save the secret from the response there without pasting it into
chat. Never repeat it in your reply, put it in example code, or log it.
Restart or redeploy the receiver if needed so it loads the new secret.
Confirm the deployed receiver has signature verification enabled before
anyone opens a bank link. A local `.env` file alone does not configure a
hosted app.
If the app needs to send the user back to it after bank sign-in, configure
its HTTPS return address. First `GET /settings/redirect-urls`, then add
the address to `allowed_redirect_urls` without removing existing entries.
`POST /settings/redirect-urls` with `{"allowed_redirect_urls": ["..."]}`
sets the complete list. Pass the address as `redirect_url` when creating
the connect session. If no return address is needed, omit that field.
## Build this flow
1. `POST /clients` with `{"name": "..."}`. Store the returned `cli_...` id.
2. `POST /clients/{client_id}/connect-session` with `{}` (or with the
optional `redirect_url` configured above).
Returns `{"url": "...", "expires_at": "..."}`.
3. Send that url to my end user. They open it, find their own bank, sign
in, and choose accounts. I build no bank picker and never see their
password.
4. Wait for the `connection.active` webhook. Read `data.connection.id`
from it. That is the real connection id. Store it against my user.
5. `GET /accounts?client_id=cli_...&connection_id=con_FINICITY_41294`
6. `GET /accounts/{account_id}/transactions?client_id=cli_...&from=2026-01-01`
## Reading data
`GET /accounts?client_id=...` and optionally `&connection_id=...`
`GET /accounts/{account_id}/transactions?client_id=...&from=YYYY-MM-DD&to=YYYY-MM-DD`
`from` includes that day, `to` excludes it. Newest first. Pending rows
are always included, so branch on the `pending` field.
`GET /accounts/{account_id}/statements?client_id=...`
`GET /statements/{statement_id}/download?client_id=...` returns PDF bytes.
`client_id` is REQUIRED on every one of those. Leaving it off is a 400.
Transaction fields, and this is the complete list: `id`, `account_id`,
`external_id`, `amount`, `iso_currency_code`, `date`, `description`,
`merchant_name`, `category`, `pending`. `amount` is negative for money
out and positive for money in.
Account `type` is checking, savings, credit_card, loan, investment or
other. `current_balance` is from the account holder's point of view, so a
credit card with a balance owed comes back negative.
Lists page with `?cursor=...&limit=...` and return
`{"data": [...], "next_cursor": "...", "has_more": true}`. Keep passing
`next_cursor` back until `has_more` is false. A cursor only works for the
exact query that produced it.
Errors look like this. Branch on `error.code` and nothing else:
{"error": {"code": "not_found", "message": "...", "type": "not_found",
"category": "RESOURCE_NOT_FOUND", "trace_id": "..."}}
On a 429, honor the `Retry-After` header and back off.
## Verifying webhooks
Check every delivery before trusting it:
signed = X-LS-Webhook-Timestamp + "." + the raw request body
expected = hex(HMAC_SHA256(key = signing_secret, message = signed))
compare expected with the X-LS-Webhook-Signature header, constant-time
The key is the whole `whsec_...` string as UTF-8. Do not decode it.
Capture the raw body bytes before any JSON parsing touches them. Reject
anything whose timestamp is more than 5 minutes old.
Reply 2xx within about 10 seconds and queue anything slower: we drop the
connection after that and treat it as a failure. We retry for 24 hours,
so the same event can arrive twice. Ignore duplicates by `event_id`.
## Rules that will break my integration if you get them wrong
1. Only ever store a connection id shaped `con_<SOURCE>_<number>`. A
`con_` followed by a plain uuid is a temporary placeholder and returns
400 everywhere. The real id comes from the `connection.active`
webhook, or from `GET /clients/{client_id}/connections`. Re-checking
the operation never turns the placeholder into the real one.
2. `client_id` goes on every accounts, transactions and statements read.
3. Branch on `error.code`. Never on the HTTP status or the message text.
4. Sign `timestamp + "." + body`, never the body on its own.
5. Put an `Idempotency-Key` header on every POST, and reuse the exact
same value for every retry of that one request. Retrying without the
header, or with a new key, creates a duplicate. A 409 carrying
`Retry-After` means the first attempt is still running: wait, then
send the same key again.
6. `external_id` and `metadata` on a Client come back empty on every
read, so never build a lookup on them. Use the `cli_` id.
7. A statement `download_url` is a partial address. Join it to the API
host, not to the `/v3` URL, and send my key with it.
8. The first webhook for a new connection is
`connection.requires_action`. There is no `connection.initiated`.
9. There is no event for a newly opened account. Re-read `GET /accounts`
and compare ids.
10. Never put my key in anything that runs in a browser or a phone app.
## Only if I ask you for my own bank picker
`GET /institutions?q=chase` finds an `ins_...` id, then
`POST /clients/{client_id}/connections` with `{"institution_id": "..."}`
returns a `202`. Read `GET /operations/{operation_id}` and open
`result.connection.action.widget_url` for the user. The real connection
id still only arrives on the webhook, exactly as in rule 1.
## Finish with a real bank connection
Give me a way to create a secure bank link for each client. Tell me where
to click in my app, then have me open a link and connect my own bank.
Show me where the returned accounts and transactions appear in my app.
If something fails, check your work and explain the next action in plain
words. Do not create duplicate clients or subscriptions when resuming
an existing integration; inspect its saved configuration first.