Tutorial: Emit a payroll and reconcile it
This tutorial walks through paying a list of suppliers in a single bank-payroll batch and then reconciling each payment against the corresponding bank transaction. It assumes you have already finished the Getting started quickstart and have an API_KEY and a COMPANY_ID.
The flow has 5 steps:
- Pick a source bank account.
- Make sure every recipient is enrolled at the bank.
- Create the payroll with all transactions.
- Pre-authorize and authorize the payroll.
- Poll for status until each transaction reconciles.
Total wall time on a real bank: typically 30–90 seconds. Most of that is the bank's processing window.
1. Pick the source bank_account
bank_accountcurl 'https://api.cardda.com/v1/banking/bank_accounts' \
-H "Authorization: Bearer $API_KEY" \
-H "company-id: $COMPANY_ID"Pick the id of the account you want to pay from. We'll call it BANK_ACCOUNT_ID. Cache it on your side — it almost never changes.
2. Make sure every recipient is enrolled
A "recipient" is an external bank account where money will land. To pay them, both these conditions must hold:
- The recipient exists in Cardda (
POST /v1/banking/bank_recipients, see Tutorial: Create and manage recipients). - The recipient is enrolled at your bank (
POST /v1/banking/bank_recipients/{id}/enroll).
Skip the second step if your bank account is configured for "open" payments (no enrollment required).
3. Create the payroll
A payroll is a container for many transactions that the bank processes as a single batch.
curl -X POST 'https://api.cardda.com/v1/banking/bank_payrolls' \
-H "Authorization: Bearer $API_KEY" \
-H "company-id: $COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"bank_account_id": "BANK_ACCOUNT_ID",
"name": "April vendor batch",
"scheduled_for": "2026-04-30T15:00:00Z",
"bank_transactions": [
{
"recipient_id": "11111111-1111-1111-1111-111111111111",
"amount": 1500000,
"currency": "CLP",
"description": "April invoice 1023"
},
{
"recipient_id": "22222222-2222-2222-2222-222222222222",
"amount": 280000,
"currency": "CLP",
"description": "April invoice 1024"
}
]
}'{
"id": "payroll-uuid",
"status": "draft",
"bank_transactions": [
{ "id": "tx-1-uuid", "status": "draft", ... },
{ "id": "tx-2-uuid", "status": "draft", ... }
]
}4. Validate, pre-authorize, authorize
# (a) Cardda validates each recipient exists and is enrolled
curl -X POST 'https://api.cardda.com/v1/banking/bank_payrolls/PAYROLL_ID/validate' \
-H "Authorization: Bearer $API_KEY" \
-H "company-id: $COMPANY_ID"
# (b) Cardda forwards the batch to the bank as "pending authorization"
curl -X POST 'https://api.cardda.com/v1/banking/bank_payrolls/PAYROLL_ID/preauthorize' \
-H "Authorization: Bearer $API_KEY" \
-H "company-id: $COMPANY_ID"
# (c) The user submits the bank token (TOTP / coordinates / signature)
curl -X POST 'https://api.cardda.com/v1/banking/bank_payrolls/PAYROLL_ID/authorize' \
-H "Authorization: Bearer $API_KEY" \
-H "company-id: $COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{ "bank_key_token": "123456" }'After step (c) the payroll status moves to processing, then authorized once the bank accepts it.
5. Reconcile each bank transaction
The payroll's status is the aggregate. Individual bank_transactions can succeed or fail independently.
curl 'https://api.cardda.com/v1/banking/bank_transactions?payroll_id=PAYROLL_ID' \
-H "Authorization: Bearer $API_KEY" \
-H "company-id: $COMPANY_ID"Each item has a status:
| Status | Meaning | Next step |
|---|---|---|
draft | Created, not yet sent | Wait for the payroll lifecycle. |
pending | At the bank, awaiting auth | Wait. |
authorized | Sent and accepted by the bank | Wait for the bank statement to confirm the funds movement. |
confirmed | Confirmed in the bank statement | ✅ Reconciled. Pull the receipt. |
failed | Bank rejected the transaction | Check failure_reason. Recreate manually if needed. |
rejected | Cardda blocked the transaction (e.g. recipient not enrolled) | Fix the underlying issue and retry. |
Pull the receipt URL once confirmed:
curl 'https://api.cardda.com/v1/banking/bank_transactions/TX_ID/receipt/url' \
-H "Authorization: Bearer $API_KEY" \
-H "company-id: $COMPANY_ID"Returns a presigned S3 URL valid for 5 minutes:
{ "url": "https://cardda-api.s3.amazonaws.com/.../receipt.pdf?..." }End-to-end snippet (Python)
import time, requests
api = "https://api.cardda.com/v1"
H = {"Authorization": f"Bearer {API_KEY}", "company-id": COMPANY_ID}
def post(path, body=None):
r = requests.post(api + path, headers={**H, "Content-Type": "application/json"}, json=body, timeout=30)
r.raise_for_status()
return r.json()
def get(path, **params):
r = requests.get(api + path, headers=H, params=params, timeout=30)
r.raise_for_status()
return r.json()
# 3. Create payroll
payroll = post("/banking/bank_payrolls", {
"bank_account_id": BANK_ACCOUNT_ID,
"name": "April vendor batch",
"bank_transactions": [
{"recipient_id": "...", "amount": 1500000, "currency": "CLP", "description": "Inv 1023"},
{"recipient_id": "...", "amount": 280000, "currency": "CLP", "description": "Inv 1024"},
],
})
# 4. Validate + preauthorize + authorize
post(f"/banking/bank_payrolls/{payroll['id']}/validate")
post(f"/banking/bank_payrolls/{payroll['id']}/preauthorize")
post(f"/banking/bank_payrolls/{payroll['id']}/authorize", {"bank_key_token": prompt_user()})
# 5. Poll for reconciliation (up to 5 minutes)
deadline = time.time() + 300
while time.time() < deadline:
txs = get("/banking/bank_transactions", payroll_id=payroll["id"])
statuses = {tx["status"] for tx in txs}
if statuses <= {"confirmed", "failed", "rejected"}:
break
time.sleep(10)
print({tx["id"]: tx["status"] for tx in txs})Common pitfalls
- Recipient not enrolled. The bank rejects the line item with
rejected. Look atfailure_reasonand call/bank_recipients/{id}/enroll. - Insufficient balance. The bank usually rejects the entire payroll (status
failed) rather than partials. - Wrong currency. All transactions in a single payroll must share the same currency.
- Re-authorizing a confirmed payroll. No-op; idempotent.
Related
Updated 1 day ago
