Skip to content

Transactions

Transactions represent bank transactions synced from Plaid, imported from Ramp, Xero or QuickBooks, uploaded from CSV, or created manually. They are the core data type for bank feeds, categorization, and reconciliation.

Transaction Lifecycle

  1. Sync — Transactions arrive from a connected bank (source: plaid), a card program (ramp), a migration (xero, quickbooks), a CSV upload (bulk_upload) or the API (manual)
  2. Clean — Merchant names are normalized into counterparty automatically
  3. Categorize — AI, bank rules, or manual assignment to an expense/revenue ledger via the transaction's journal entry
  4. Review — A user confirms the categorization (review_status: reviewed)
  5. Reconcile — Matched against bank statements (reconciled: true) — see Reconciliation

List Transactions

bash
curl "https://api.ondayzero.com/api/v1/transactions?limit=25&only_uncategorized=true&start_date=2026-03-01" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID"
json
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "0198a2c4-0000-7d3a-9a51-3f6f0c1d2e10",
        "amount": -4250,
        "display_amount": -4250,
        "currency": "USD",
        "datetime": "2026-03-14",
        "counterparty": "Staples",
        "description": "STAPLES 00123 AUSTIN TX",
        "ledger_id": "0198a2c4-0000-7d3a-9a51-3f6f0c1d2e01",
        "ledger_name": "Business Checking",
        "posted_status": "posted",
        "review_status": "unreviewed",
        "reconciled": false,
        "source": "plaid",
        "categorization_method": "uncategorized",
        "opposing_line_entries": []
      }
    ],
    "next_cursor": "0198a2c4-0000-7d3a-9a51-3f6f0c1d2e10",
    "prev_cursor": null,
    "has_next": true,
    "has_prev": false,
    "limit": 25,
    "total": null
  }
}

Rows carry more fields than shown (see Key Fields). Page with next_cursor as described in Pagination.

Filter Parameters

Parameter Type Description
start_date / end_date date Transaction date range
ledger_id UUID Bank account ledger the transaction belongs to
opposing_ledger_id UUID Category ledger (the expense/revenue side of the journal entry)
source_account_id UUID[] Plaid source account(s)
search string Case-insensitive match on description or counterparty
amount_direction string inflow (money in) or outflow (money out)
only_uncategorized boolean Only transactions with no category yet
missing_counterparty boolean Only transactions with no counterparty
review_status string unreviewed, reviewed, or comma-separated
reconciled boolean true only reconciled, false only unreconciled
posted_status string posted (cleared) or pending (unsettled authorizations)
source string plaid, ramp, bulk_upload, manual, xero, quickbooks, or comma-separated
rule_id UUID Only transactions this bank rule categorized (categorized_by.rule_id)
has_attachments boolean true only transactions with a receipt/document attached, false only those with none
receipt_status string attached, exempt, not_required, requested, missing, or comma-separated (applies the business's receipt policy)
card_holder string One cardholder's transactions (a Ramp employee, or the card member a bank reports on a multi-card account), by name exactly as GET /api/v1/transactions/card-holders lists it (case-insensitive)
tag_id / journal_entry_id / id UUID Exact-match filters

Plus the standard pagination parameters.

Key Fields

Field Type Description
amount integer Amount in cents as stored (positive = money in, negative = money out on bank accounts). Credit-card accounts can carry Plaid-native signs, so use display_amount for a consistent sign
display_amount integer Cents in cash-flow convention: negative = outflow/expense, positive = inflow/income, on every account type
currency string ISO currency code
datetime datetime When the transaction occurred
description string Raw bank description
counterparty string Cleaned merchant / payee name
ledger_id / ledger_name UUID / string The bank account ledger
journal_entry_id / journal_entry UUID / object The double-entry record that carries the categorization
opposing_line_entries array The category side(s) of that journal entry — where the money was booked
posted_status string not_posted, posted, deleted
review_status string unreviewed, reviewed
reconciled boolean Matched to a bank statement
categorization_method string ai, rules, similarity, transfer_between_accounts, user, historical, global_pattern, merchant, pfc, account_type, counterparty_default, uncategorized
categorized_by object When a bank rule chose the category: rule_id, rule_name, field_matched (description, counterparty, amount, ...); otherwise null
source string Where the transaction came from (see filters)
attachment_count / receipt_status integer / string Receipts attached (entries are in meta.attachments) and where the transaction stands: attached, exempt, not_required, requested, missing
card_holder_name / account_mask string Whose card the charge was on (the Ramp employee, or the card member the bank reports on an account with several cards) and the last four digits of the card/account; null when unknown
counterparty_provenance object Why counterparty shows this name (manual rename, naming rule, saved rename, registry, or raw bank text)
comment_count integer Comments on the transaction (GET /api/v1/transactions/{transaction_id} also returns the thread)
can_be_deleted boolean Whether the caller can delete or hide it (see Delete and Restore)
invoice_ids / bill_ids UUID[] Invoices or bills this transaction pays

Create a Transaction

For bank accounts that aren't connected to a feed. Every field is optional; datetime is a date (YYYY-MM-DD), currency is one of USD, CAD, AUD, EUR, GBP, and source_account_id is the bank account ledger UUID. Use a negative amount for money out:

bash
curl -X POST "https://api.ondayzero.com/api/v1/transactions" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": -5000,
    "currency": "USD",
    "datetime": "2026-03-15",
    "description": "Office supplies",
    "source_account_id": "SOURCE_ACCOUNT_UUID"
  }'

POST /api/v1/transactions/bulk creates many at once ({"transactions": [...]} of the same shape; the response reports success_count, error_count and errors). For CSV/Excel statements use the upload flow, which works from an s3_key of a file you already uploaded:

  • POST /api/v1/transactions/bulk-upload/paste with {"pasted_text": "..."} turns copied CSV/TSV text into an s3_key (and row_count); POST /api/v1/transactions/bulk-upload/from-statement with {"s3_key", "ledger_id"} reads a PDF, image, CSV or Excel statement into upload-ready rows (nothing is imported). GET /api/v1/transactions/bulk-upload/template returns a sample CSV (columns date, amount, description, category) as csv_content_stringified and csv_content_base64.
  • POST /api/v1/transactions/bulk-upload/headers (discover the columns), …/bulk-upload/preview, then …/bulk-upload with a column_mapping (keys date, amount, description, optional category). Target an existing ledger with ledger_id, or create one by sending ledger_name plus financial_account_type. Duplicates are skipped by default (check_duplicates). A category that names no ledger refuses the file (400) unless allow_unmatched_categories is true. The import runs as a background workflow; the response returns a workflow_id.
  • POST /api/v1/transactions/enrich-from-export matches a detailed bank "account activity" export to transactions already in a ledger and appends wire originators, payers and other detail to the description and counterparty. dry_run defaults to true; set it to false to write. Rows are matched on amount and a date within date_tolerance_days (default 2, max 7), one-for-one. Detected columns are echoed back; override them with column_mapping ({role: header} for date, amount, debit, credit, description, party, detail). Hand-edited descriptions and counterparties are left alone unless overwrite_user_edits is true, and a counterparty taken from invoices or bills is never replaced.

When the account already has part of the statement's period (a bank feed, an earlier upload), first call POST /api/v1/transactions/bulk-upload/diff with the s3_key and ledger_id. It is read-only and pairs each file row with at most one existing transaction — same amount and date and description, or same amount within date_tolerance_days (default 3) with a shared word or matching counterparty — and lists the rows the ledger is missing, ambiguous same-amount pairs, and ledger rows the file lacks. Then upload with only_missing_from_diff: true (or include_row_numbers) to import exactly the missing rows. Pass exclude_statuses: ["Pending"] when the export includes unsettled rows.

Categorization

Every transaction is categorized through its journal entry — the bank ledger on one side, the category ledger on the other. You rarely build that entry yourself:

  • Recategorize one or many: PATCH /api/v1/transactions/batch/category with {"transaction_ids": [...], "ledger_id": "CATEGORY_LEDGER_UUID"} moves the opposing line entries to the new ledger.
  • Every uncategorized transaction for a merchant: POST /api/v1/transactions/recategorize-by-counterparty with {"counterparty": "Uber", "ledger_id": "..."} moves the merchant's still-uncategorized transactions, creates a bank rule so future ones follow, and returns {"updated": 12, "counterparty": "Uber"}.
  • Let AI do it: POST /api/v1/transactions/categorize-all runs the full categorization workflow (bank rules → history → LLM) over all uncategorized transactions, auto-applies high-confidence results, and returns a workflow ID immediately. POST /api/v1/transactions/suggest-categories returns suggestions without applying them; it takes query parameters limit (1-100, default 20), only_uncategorized (default true) and include_ai (default false; pattern layers only unless set).
  • Rename the counterparty in bulk: PATCH /api/v1/transactions/batch/counterparty with {"transaction_ids": [...], "counterparty": "Acme Corp", "remember": true}. remember also saves the rename for the business's other rows under the same name.
  • Edit many rows, a different change each: PATCH /api/v1/transactions/batch takes up to 500 rows of {transaction_id, description (or memo), ledger_id, counterparty, review_status} plus remember and dry_run. One invalid row rejects the whole request; write-time failures are reported per row. A description edit keeps the counterparty and marks the row hand-edited so bank syncs do not overwrite it. Through the assistant or MCP, bulk_edit_transactions also accepts an uploaded file (s3_key) instead of rows, matching by transaction_id or date + amount (up to 2,000 rows) and applying what matches.
  • Split across categories: PATCH /api/v1/transactions/{transaction_id}/split:
bash
curl -X PATCH "https://api.ondayzero.com/api/v1/transactions/{transaction_id}/split" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "split": [
      { "ledger_id": "MEALS_LEDGER_UUID", "allocation_type": "percent", "value": 7000 },
      { "ledger_id": "TRAVEL_LEDGER_UUID", "allocation_type": "remainder" }
    ]
  }'

allocation_type is percent (value in basis points — 7000 = 70%), amount (cents) or remainder. Each leg may carry a project_id.

If you do need a hand-built journal entry (for a transaction with no bank feed, an accrual, a correction), see Journal Entries.

Review and Reconcile

  • PATCH /api/v1/transactions/batch/review-status with {"transaction_ids": [...]} marks those transactions reviewed (it only moves toward reviewed; use PUT to set a row back to unreviewed); POST /api/v1/transactions/batch/mark-reviewed-by-period with {"period_start": "2026-03-01", "period_end": "2026-03-31"} does it for every unreviewed transaction in a period and returns marked_count.
  • PATCH /api/v1/transactions/batch/reconciliation-status with {"transaction_ids": [...], "reconciled": true} sets reconciled in bulk (false clears it).
  • PUT /api/v1/transactions/{transaction_id} updates a single transaction's counterparty (add "remember": true to save it as a business-wide rename), description, review_status, reconciled, personal flag, and so on. A field the request does not list (for example counterparty_name or vendor) is rejected with 400, never ignored.
  • GET /api/v1/transactions/{transaction_id}/suggested-matches lists invoices and bills this transaction probably pays; link them from the Invoices and Bills payment endpoints.

Delete and Restore

DELETE /api/v1/transactions/{transaction_id} hides a Plaid- or Xero-synced transaction on the DayZero ledger engine (soft delete; POST …/restore brings it back) and permanently removes a manual or CSV-uploaded one. Other integration sources are rejected with 400; check can_be_deleted on the transaction first. POST /api/v1/transactions/batch/delete with {"transaction_ids": [...]} applies the same rules per ID and returns deleted, hidden and a failed list. DELETE /api/v1/transactions/by-date-range?start_date=…&end_date=… clears an imported range — preview with GET …/by-date-range/preview first.

The date-range delete is permanent. The preview reports the transactions and journal entries that would go, reconciled rows, severed invoice/bill payment links, per-ledger counts and any closed_period_conflict. Add ledger_id to either call to limit it to one bank account.

Receipts

  • PATCH /api/v1/transactions/{transaction_id}/attachment uploads a receipt as multipart form data (file: PDF, PNG, JPG, JPEG, WEBP, HEIC or HEIF). The new entry is the last item of meta.attachments and carries an id. DELETE …/attachments/{attachment_id} removes it, POST …/attachments/{attachment_id}/extract reads the merchant, totals and line items from it, and POST …/attachments/import-ramp pulls the receipts a Ramp cardholder uploaded.
  • GET /api/v1/transactions/receipt-policy and PATCH …/receipt-policy (admin) read and set the policy: enabled, threshold_cents (default 7500; expenses at or above it need a receipt), reminder_days (default 3) and max_reminders (default 3).
  • GET /api/v1/transactions/receipt-coverage?start_date=…&end_date=… returns required_count, attached_count, requested_count, missing_count, exempt_count, coverage_pct and the outstanding amounts in cents. List the gaps with GET /api/v1/transactions?receipt_status=missing.
  • PATCH /api/v1/transactions/batch/receipt-exempt with {"transaction_ids": [...], "exempt": true, "reason": "Bank fee"} marks rows as needing no receipt. POST /api/v1/transactions/batch/receipt-requests asks the client for receipts (up to 200 transactions; firm staff on a firm-managed business only).

Common errors

Status Code Cause Fix
400 VALIDATION_570 A bulk upload has rows whose category names no ledger Fix the category names (the preview's row_errors lists the nearest ledger names), or send allow_unmatched_categories: true
400 VALIDATION_615 A cursor from one sort_by replayed under another Drop cursor and start from the first page
400 closed-period message DELETE /api/v1/transactions/by-date-range overlaps a closed or locked accounting period Narrow the range, or reopen the period first. The preview's closed_period_conflict shows it beforehand
400 — PUT /api/v1/transactions/{transaction_id} with a field it does not list, or a delete on a source that cannot be deleted Send only listed fields; check can_be_deleted first
422 — cursor does not end in a valid UUID, or a body field has the wrong type (for example a dollar amount instead of cents) Pass next_cursor back exactly as received; send amounts as integer cents