EzFin REST API
Version: 1.1 · Last verified: 2026-10-03
Base URL: https://ezfin.app/api
Authentication: Laravel Sanctum bearer tokens
Format: JSON (Accept: application/json)
Every endpoint in this document is covered by the automated suite in tests/Feature/Api (50 tests, 669 assertions), which exercises the request and response shapes described here.
Table of Contents
- Conventions
- Authentication
- User Profile
- Accounts
- Categories
- Transactions
- Recurring Transactions
- Budgets
- Financial Goals
- Customers & Suppliers
- Accounts Receivable & Payable
- Payments
- Reports
- Settings
- Dashboard
- Notifications
- Mobile
- Admin API
- Endpoint Index
- Quick Start
Conventions
Headers
| Header | Value | When |
|---|---|---|
Accept |
application/json |
Always. Without it, errors may come back as HTML redirects instead of JSON. |
Content-Type |
application/json |
Requests with a JSON body |
Content-Type |
multipart/form-data |
File uploads (transaction attachment, avatar) |
Authorization |
Bearer {token} |
Every endpoint except /test, /register and /login |
Identifiers
All IDs are UUID strings (for example "01a1029e-d31c-700e-bab1-474f5458ea23"), never integers.
Data types
- Money. Amounts stored in the database (
amount,initial_balance,target_amount,paid_amount, …) are returned as decimal strings such as"1200.00". Values computed by the API (current_balance,spent,progress, report totals, …) are JSON numbers. Parse both as decimals. - Dates. Send dates as
YYYY-MM-DDand times asHH:mm. Date fields come back as ISO 8601 timestamps ("2026-03-06T00:00:00.000000Z"). Report labels andupcomingdates are plainYYYY-MM-DDstrings. - Booleans. Query-string flags accept
1/0andtrue/false.
Success responses
{
"success": true,
"data": { },
"message": "Optional human-readable message"
}
201 Created is returned when a resource is created; everything else that succeeds returns 200 OK.
Pagination
There are two pagination shapes.
Laravel paginator. Used by /transactions, /accounts-receivable, /accounts-payable, /payments and /reports/saved:
{
"success": true,
"data": {
"current_page": 1,
"data": [ ],
"first_page_url": "https://ezfin.app/api/transactions?page=1",
"from": 1,
"last_page": 4,
"last_page_url": "https://ezfin.app/api/transactions?page=4",
"links": [ ],
"next_page_url": "https://ezfin.app/api/transactions?page=2",
"path": "https://ezfin.app/api/transactions",
"per_page": 25,
"prev_page_url": null,
"to": 25,
"total": 92
}
}
Compact. Used by /notifications and the admin lists:
{
"success": true,
"data": {
"items_key": [ ],
"pagination": { "current_page": 1, "per_page": 15, "total": 25, "last_page": 2 }
}
}
Use ?page=N to move between pages. per_page is clamped to the range 1–100.
Errors
| Status | Meaning | Body |
|---|---|---|
401 |
Missing, invalid or revoked token | {"message": "Unauthenticated."} |
403 |
Resource belongs to someone else | {"success": false, "message": "Unauthorized"} |
403 |
Not an admin (on /admin/*) |
{"message": "Unauthorized action."} |
403 |
Account suspended | {"success": false, "message": "Your account has been suspended."} |
404 |
Unknown ID or route | {"message": "No query results for model [App\\Models\\Account] {id}"} |
400 |
Request understood but not allowed (business rule) | {"success": false, "message": "..."} |
422 |
Validation failed | see below |
500 |
Unexpected server error | {"message": "Server Error"} |
Validation errors come in one of two shapes, depending on the endpoint:
{ "success": false, "errors": { "email": ["The email field must be a valid email address."] } }
{ "message": "The provided credentials are incorrect.", "errors": { "email": ["The provided credentials are incorrect."] } }
The second shape is used by /login, /mobile/sync and every /admin/* endpoint. Both shapes always include an errors object keyed by field name.
Supported values
- Languages:
en,pt-br,es - Currencies:
USD,EUR,BRL,GBP,CAD,AUD,JPY,CHF,CNY,MXN - Themes:
light,dark,system
Rate limiting
No request rate limit is currently enforced. If you receive 429 Too Many Requests, back off and retry.
Health check
GET /api/test
No authentication. Returns {"message": "API is working", "timestamp": "..."}.
Authentication
Register
POST /api/register
| Field | Type | Required | Rules |
|---|---|---|---|
name |
string | yes | max 255 |
email |
string | yes | valid e-mail, unique |
password |
string | yes | min 8 |
password_confirmation |
string | yes | must match password |
language |
string | no | en (default), pt-br, es |
currency |
string | no | a supported currency, default USD (stored as preferred_currency) |
theme |
string | no | light (default), dark, system |
Registering through the API behaves like signing up on the website: the account gets the default categories and a welcome e-mail.
201
{
"success": true,
"data": {
"user": {
"id": "01a1029e-d2c7-711e-8442-841f018a1a39",
"name": "John Doe",
"email": "john@example.com",
"language": "en",
"preferred_currency": "USD",
"created_at": "2026-10-03T16:35:25.000000Z",
"updated_at": "2026-10-03T16:35:25.000000Z"
},
"access_token": "01a1029e-d2df-710e-90cd-d0d9e261c9a9|t1Gnr0LjNcUfLnA9AfOfxJWmX2L0NFGxph09YHRa72d80122",
"token_type": "Bearer"
},
"message": "User registered successfully"
}
Login
POST /api/login
| Field | Type | Required | Notes |
|---|---|---|---|
email |
string | yes | |
password |
string | yes | |
device_name |
string | no | Stored as the token name (defaults to the User-Agent) |
revoke_existing |
boolean | no | Revoke every other token of this user first |
200. Same data shape as Register (user, access_token, token_type), with the message "Logged in successfully".
422. Wrong e-mail or password:
{ "message": "The provided credentials are incorrect.", "errors": { "email": ["The provided credentials are incorrect."] } }
403. The account was suspended by an administrator.
Current user
GET /api/user
GET /api/profile (alias)
Returns the user together with their setting record.
{
"success": true,
"data": {
"id": "01a1029e-d2a7-72e2-8251-84cfc64c6df1",
"name": "Jane Doe",
"email": "jane@example.com",
"phone": null,
"language": "en",
"preferred_currency": "USD",
"is_admin": false,
"avatar": null,
"suspended_at": null,
"setting": {
"language": "en",
"currency": "USD",
"date_format": "Y-m-d",
"time_format": "H:i",
"theme": "light",
"notifications_enabled": true,
"notification_preferences": null,
"device_token": null,
"device_type": null
}
}
}
Logout
POST /api/logout
Revokes the token used for the request. 200 {"success": true, "message": "Logged out successfully"}
Refresh token
POST /api/refresh
Revokes the current token and issues a new one with the same name.
{ "success": true, "data": { "access_token": "…|…", "token_type": "Bearer" }, "message": "Token refreshed successfully" }
List tokens
GET /api/tokens
{
"success": true,
"data": [
{
"id": "01a1029e-d30f-7259-999f-36fc83cce91b",
"name": "iPhone 15",
"abilities": ["*"],
"last_used_at": null,
"expires_at": null,
"created_at": "2026-10-03T16:35:25.000000Z"
}
]
}
Create token
POST /api/tokens
| Field | Type | Required |
|---|---|---|
name |
string | yes, max 255 |
201
{ "success": true, "data": { "token": "01a1029e-…|SbWn0qeh…", "token_id": "01a1029e-d30f-7259-999f-36fc83cce91b" }, "message": "Token created successfully" }
The plain-text token is shown only once.
Delete token
DELETE /api/tokens/{token_id}
200 {"success": true, "message": "Token deleted successfully"}. Returns 404 if the token does not exist or belongs to another user.
User Profile
Update profile
PUT /api/profile
Send only the fields you want to change.
| Field | Type | Rules |
|---|---|---|
name |
string | max 255 |
email |
string | valid e-mail, unique |
language |
string | en, pt-br, es |
preferred_currency |
string | supported currency |
phone |
string|null | max 20 |
settings |
object | optional, see below |
settings.theme |
string | light, dark, system |
settings.date_format |
string | Y-m-d, d/m/Y, m/d/Y, d-m-Y, m-d-Y |
settings.time_format |
string | H:i, h:i A |
settings.notifications_enabled |
boolean | |
settings.notification_preferences |
object|null |
Unknown keys inside settings are ignored. 200 returns the user with setting, and the message "Profile updated successfully".
Change password
POST /api/profile/password
| Field | Type | Required | Notes |
|---|---|---|---|
current_password |
string | yes | |
password |
string | yes | min 8, must be confirmed |
password_confirmation |
string | yes | |
revoke_other_sessions |
boolean | no | Revoke every token except the current one |
200 {"success": true, "message": "Password updated successfully"}. A wrong current_password returns 422 with errors.current_password.
Upload avatar
POST /api/profile/avatar
Content-Type: multipart/form-data
| Field | Type | Rules |
|---|---|---|
avatar |
file | jpeg, png, jpg or gif, max 2 MB |
{ "success": true, "data": { "avatar_url": "https://ezfin.app/storage/avatars/abc123.jpg" }, "message": "Avatar uploaded successfully" }
Uploading a new avatar replaces and deletes the previous file.
Delete account
POST /api/profile/delete-account
| Field | Type | Rules |
|---|---|---|
password |
string | the current password |
confirmation |
string | must be exactly DELETE |
200 {"success": true, "message": "Account deleted successfully"}. This permanently removes the user, every record they own, and all of their tokens. A wrong password returns 422.
Accounts
Accounts can be owned by the user or shared with them by another user. A shared account carries a role: viewer (read-only) or editor (can edit the account and add or edit its transactions). Only the owner can delete an account.
Account object
| Field | Type | Notes |
|---|---|---|
id |
uuid | |
name |
string | |
type |
string | checking, savings, cash, credit_card, investment, other |
initial_balance |
decimal | |
currency |
string | 3 letters |
icon / color |
string|null | Material icon name / hex colour |
is_active |
boolean | |
include_in_totals |
boolean | Counted in /accounts/balances and the dashboard total |
closing_day / due_day |
int|null | Credit cards only (1–31) |
current_balance |
number | initial_balance + income − expense. Transfers are not counted. |
user_role |
string | owner, or the role on a shared account |
is_owner / is_shared |
boolean | |
owner_name / owner_email |
string | Shared accounts only |
List accounts
GET /api/accounts
| Query | Type | Description |
|---|---|---|
active |
true |
Only active accounts |
include_balance |
boolean | Add current_balance to every account (default false) |
include_shared |
boolean | Include accounts shared with the user (default true) |
{
"success": true,
"data": {
"accounts": [
{
"id": "01a1029e-d31c-700e-bab1-474f5458ea23",
"name": "My Checking",
"type": "checking",
"initial_balance": "1000.00",
"currency": "USD",
"icon": "account_balance",
"color": "#4CAF50",
"is_active": true,
"include_in_totals": true,
"closing_day": null,
"due_day": null,
"user_role": "owner",
"is_owner": true,
"is_shared": false,
"current_balance": 5950
}
],
"owned_count": 2,
"shared_count": 0,
"total_count": 2
}
}
Create account
POST /api/accounts
| Field | Type | Required | Rules |
|---|---|---|---|
name |
string | yes | max 255 |
type |
string | yes | see the account object |
initial_balance |
number | yes | |
currency |
string | yes | exactly 3 characters |
icon |
string | no | max 50 |
color |
string | no | max 50 |
is_active |
boolean | no | default true |
include_in_totals |
boolean | no | default true |
closing_day |
int | no | 1–31, kept only when type is credit_card |
due_day |
int | no | 1–31, kept only when type is credit_card |
201 returns the account, with the message "Account created successfully".
Get account
GET /api/accounts/{id}
| Query | Type | Description |
|---|---|---|
include_members |
boolean | Add owner, member_count and members[] (id, name, email, role, joined_at) |
Always includes current_balance, user_role and is_owner. Owners and shared users can read the account; anyone else gets 403.
Update account
PUT /api/accounts/{id}
Accepts the same fields as create, all optional. Changing type away from credit_card clears closing_day and due_day. Requires the owner or an editor of the account.
Delete account
DELETE /api/accounts/{id}
Owner only. Returns 422 "Cannot delete account that has transactions" if the account still has transactions.
Balances
GET /api/accounts/balances
Totals across the user's own active accounts that have include_in_totals = true. Shared accounts are not included.
{
"success": true,
"data": {
"by_type": { "checking": { "USD": 5950 }, "savings": { "USD": 500 } },
"by_currency": { "USD": 6450 }
}
}
Categories
List categories
GET /api/categories
| Query | Description |
|---|---|
type |
income or expense |
{
"success": true,
"data": [
{ "id": "01a1029e-d322-736a-952e-9fd296e05e6d", "name": "Food", "type": "expense", "icon": "restaurant", "color": "#FF6384", "is_default": false }
]
}
Create category
POST /api/categories
| Field | Type | Required | Rules |
|---|---|---|---|
name |
string | yes | max 255 |
type |
string | yes | income or expense |
icon |
string | no | max 50 |
color |
string | no | max 50 |
201 returns the category, with the message "Category created successfully".
Get / update / delete
GET /api/categories/{id}
PUT /api/categories/{id} (name, type, icon, color — all optional)
DELETE /api/categories/{id}
Only the owner can read or change a category (403 otherwise). Default categories (is_default: true) cannot be edited or deleted, and categories used by transactions cannot be deleted. Both cases return 422.
Transactions
Transaction object
| Field | Type | Notes |
|---|---|---|
id |
uuid | |
account_id / category_id |
uuid | category_id may be null |
type |
string | income, expense, transfer |
amount |
decimal | always positive; type gives the direction |
date |
date | |
time |
datetime|null | the time of day is the meaningful part |
description |
string|null | |
attachment |
string|null | path on the public disk, served at https://ezfin.app/storage/{attachment} |
transfer_transaction_id |
uuid|null | the other leg of a transfer |
is_recurring |
boolean | created by a recurring transaction |
account / category |
object | embedded in list, show, create and update responses |
List transactions
GET /api/transactions
Returns transactions from every account the user can access, including shared accounts.
| Query | Type | Description |
|---|---|---|
account_id |
uuid | |
category_id |
uuid | |
type |
string | income, expense, transfer |
start_date / end_date |
date | Inclusive range on date |
min_amount / max_amount |
number | |
search |
string | Substring match on description |
order_by |
string | date (default), amount, description, created_at |
order_dir |
string | desc (default) or asc |
per_page |
int | default 25, max 100 |
page |
int |
Returns a Laravel paginator of transactions, each with account and category embedded.
Create transaction
POST /api/transactions
| Field | Type | Required | Rules |
|---|---|---|---|
account_id |
uuid | yes | an account the user owns or can edit |
type |
string | yes | income, expense, transfer |
amount |
number | yes | min 0.01 |
date |
date | yes | |
time |
string | no | HH:mm |
category_id |
uuid | no | must be one of the user's own categories |
description |
string | no | max 255 |
attachment |
file | no | max 10 MB (send as multipart/form-data) |
transfer_account_id |
uuid | if type=transfer |
an account the user can edit, different from account_id |
201 returns the transaction with account and category.
Transfers. Creating a transfer creates two linked transfer transactions: "Transfer to {destination}" on the source account and "Transfer from {source}" on the destination. Updating one leg keeps the amount, date and time of the other in sync, and deleting either leg deletes both.
Errors. 403 if the account, the category or the transfer destination is not one the user can use. 422 for validation errors.
Get transaction
GET /api/transactions/{id}
Includes account, category and transfer_transaction.account.
Update transaction
PUT /api/transactions/{id}
Accepts account_id, category_id, type, amount, date, time, description and attachment, all optional. To upload a new attachment, send POST /api/transactions/{id} as multipart/form-data with _method=PUT, because PHP does not parse multipart bodies on real PUT requests.
Delete transaction
DELETE /api/transactions/{id}
Deletes the attachment and, for transfers, the counterpart transaction as well.
Statistics
GET /api/transactions/statistics
| Query | Required | Values |
|---|---|---|
start_date |
yes | date |
end_date |
yes | date ≥ start_date |
group_by |
yes | day, week, month, year, category |
type |
no | income, expense, both (default) |
Transfers are excluded. Grouping by time returns chart-ready datasets (label formats: 2026-03-06, 2026-W10, 2026-03, 2026):
{
"success": true,
"data": {
"labels": ["2026-01", "2026-02"],
"datasets": [
{ "label": "Income", "data": [5000, 0], "backgroundColor": "#4CAF50" },
{ "label": "Expense", "data": [300, 200], "backgroundColor": "#F44336" }
]
}
}
group_by=category returns:
{ "success": true, "data": { "income": [{ "label": "Salary", "value": "5000.00" }], "expense": [{ "label": "Food", "value": "500.00" }] } }
Recurring Transactions
List
GET /api/recurring-transactions
| Query | Description |
|---|---|
account_id, category_id |
Filters |
type |
income or expense |
active |
true: only items with no end_date, or with an end_date today or later |
order_by / order_dir |
Results are sorted by next_date (asc by default; order_dir=desc reverses) |
Each item has next_date (the next occurrence, or null) and embeds account and category.
Create
POST /api/recurring-transactions
| Field | Type | Required | Rules |
|---|---|---|---|
name |
string | no | max 255. Defaults to description, then to "Recurring Transaction" |
account_id |
uuid | yes | one of the user's own accounts |
category_id |
uuid | no | one of the user's own categories |
type |
string | yes | income or expense |
amount |
number | yes | min 0.01 |
description |
string | no | max 255 |
frequency |
string | yes | daily, weekly, bi_weekly, monthly, quarterly, semi_annually, annually |
day_of_month |
int | for monthly, quarterly, semi_annually and annually | 1–31 (clamped to the length of each month) |
day_of_week |
int | for weekly and bi_weekly | 0 (Sunday) – 6 (Saturday) |
start_date |
date | yes | |
end_date |
date | no | ≥ start_date |
auto_generate |
boolean | no | default true. The nightly job creates the transactions automatically |
201
{
"success": true,
"data": {
"id": "01a1029e-d362-73d4-9588-0cf24830e970",
"name": "Monthly Rent",
"type": "expense",
"amount": "1200.00",
"frequency": "monthly",
"day_of_month": 5,
"day_of_week": null,
"start_date": "2026-01-01T00:00:00.000000Z",
"end_date": null,
"auto_generate": true,
"next_date": "2026-10-05T00:00:00.000000Z",
"account": { },
"category": { }
},
"message": "Recurring transaction created successfully"
}
Get / update / delete
GET /api/recurring-transactions/{id}
PUT /api/recurring-transactions/{id}
DELETE /api/recurring-transactions/{id}
Update accepts the same fields as create, all optional.
Generate a transaction now
POST /api/recurring-transactions/{id}/generate
| Field | Type | Description |
|---|---|---|
date |
date | Optional. Defaults to the next occurrence |
Creates a real transaction (is_recurring: true) and records it as last_generated.
{ "success": true, "data": { "transaction": { }, "recurring_transaction": { "next_date": "2026-11-05T00:00:00.000000Z" } }, "message": "Transaction generated successfully" }
400 "No valid occurrence date found" once the series has ended.
Upcoming occurrences
GET /api/recurring-transactions/upcoming?days=30
days ranges from 1 to 365 (default 30). Returns every occurrence within that window, sorted by date:
{
"success": true,
"data": [
{
"id": "01a1029e-d362-73d4-9588-0cf24830e970",
"recurring_transaction_id": "01a1029e-d362-73d4-9588-0cf24830e970",
"name": "Monthly Rent",
"type": "expense",
"amount": "1200.00",
"description": null,
"date": "2026-10-05",
"auto_generate": true,
"account": { },
"category": { }
}
]
}
Budgets
List
GET /api/budgets
| Query | Description |
|---|---|
active |
true: start_date ≤ today and (end_date empty or ≥ today) |
category_id |
Filter |
period |
daily, weekly, monthly, quarterly, yearly, annually, custom |
Every budget includes:
spent: the expenses in the current period (this day, week, month, quarter or year; forcustom, the budget's own date range). Only the budget's category counts, or all expenses if it has no category.progress: the percentage ofamountspent, capped at 100.
{
"success": true,
"data": [
{
"id": "01a1029e-d378-7300-9c5e-6609e15fb1a6",
"name": "Food Budget",
"amount": "500.00",
"period": "monthly",
"start_date": "2026-01-01T00:00:00.000000Z",
"end_date": null,
"spent": 50,
"progress": 10,
"category": { }
}
]
}
Create
POST /api/budgets
| Field | Type | Required | Rules |
|---|---|---|---|
name |
string | yes | max 255 |
amount |
number | yes | min 0 |
period |
string | yes | see List |
start_date |
date | yes | |
end_date |
date | no | ≥ start_date |
category_id |
uuid | no | one of the user's own categories |
Get / update / delete
GET /api/budgets/{id} (includes spent and progress)
PUT /api/budgets/{id}
DELETE /api/budgets/{id}
Progress of active budgets
GET /api/budgets/progress
Every currently active budget, with spent and progress.
Financial Goals
Both prefixes are equivalent: /api/goals and /api/financial-goals.
List
GET /api/goals
| Query | Description |
|---|---|
is_completed |
true or false |
order_by |
name, target_date, target_amount, current_amount, created_at (default) |
order_dir |
desc (default) or asc |
Every goal includes progress (0–100). Goals with a target_date also include days_remaining (whole days; negative when past) and is_overdue.
{
"success": true,
"data": [
{
"id": "01a1029e-d38a-704f-8341-5caf85b1749e",
"name": "Emergency Fund",
"target_amount": "10000.00",
"current_amount": "6500.00",
"target_date": "2027-10-03T00:00:00.000000Z",
"is_completed": false,
"progress": 65,
"days_remaining": 364,
"is_overdue": false
}
]
}
Create
POST /api/goals
| Field | Type | Required | Rules |
|---|---|---|---|
name |
string | yes | max 255 |
target_amount |
number | yes | min 0.01 |
current_amount |
number | no | min 0, default 0 |
target_date |
date | no | today or later |
description |
string | no | |
icon / color |
string | no | max 50 |
A goal whose current_amount already reaches target_amount is marked is_completed: true.
Get / update / delete
GET /api/goals/{id}
PUT /api/goals/{id} (same fields as create, plus is_completed)
DELETE /api/goals/{id}
Add funds
POST /api/goals/{id}/add-funds
| Field | Type | Rules |
|---|---|---|
amount |
number | min 0.01 |
Returns the goal with the updated current_amount, progress and is_completed, and the message "Funds added to goal successfully".
Customers & Suppliers
Customers (/api/customers) and suppliers (/api/suppliers) have identical fields.
| Field | Type | Rules |
|---|---|---|
name |
string | required on create, max 255 |
document |
string | CPF/CNPJ/tax ID, max 20 |
email |
string | valid e-mail |
phone |
string | max 20 |
address |
string | |
city / state / country |
string | max 100 |
postal_code |
string | max 20 |
notes |
string | |
is_active |
boolean | default true |
GET /api/customers ?active=true|false ?search= (name, e-mail or document)
POST /api/customers
GET /api/customers/{id}
PUT /api/customers/{id}
DELETE /api/customers/{id}
The /api/suppliers routes are the same.
- List. Customers come with
total_receivable,overdue_receivableand theiraccounts_receivable. Suppliers come withtotal_payable,overdue_payableand theiraccounts_payable. - Show. Also embeds the payments of each receivable or payable.
- Delete. 422 while the customer still has unpaid receivables, or the supplier unpaid payables.
Accounts Receivable & Payable
Receivables (/api/accounts-receivable, linked to a customer_id) and payables (/api/accounts-payable, linked to a supplier_id) work the same way.
Object
| Field | Type | Notes |
|---|---|---|
customer_id / supplier_id |
uuid | |
invoice_number |
string|null | |
description |
string | |
amount |
decimal | |
paid_amount / remaining_amount |
decimal | maintained by the API |
issue_date / due_date |
date | |
status |
string | pending, partial, paid, overdue, computed automatically |
notes |
string|null |
Endpoints
GET /api/accounts-receivable paginated
POST /api/accounts-receivable
GET /api/accounts-receivable/summary
GET /api/accounts-receivable/{id} includes customer and payments (with their account)
PUT /api/accounts-receivable/{id}
DELETE /api/accounts-receivable/{id}
POST /api/accounts-receivable/{id}/payments
The /api/accounts-payable routes are the same, with supplier_id.
List filters: status, customer_id (or supplier_id), start_date/end_date (on due_date), per_page (default 25). Results are sorted by due_date ascending.
Create / update fields
| Field | Type | Required | Rules |
|---|---|---|---|
customer_id / supplier_id |
uuid | yes | must belong to the user |
description |
string | yes | max 255 |
amount |
number | yes | min 0.01 |
issue_date |
date | yes | |
due_date |
date | yes | ≥ issue_date |
invoice_number |
string | no | max 100 |
notes |
string | no |
paid_amount, remaining_amount, status and user_id cannot be set by the client. Changing amount recalculates remaining_amount and status. Deleting a receivable or payable that already has payments returns 422.
Summary
{
"success": true,
"data": {
"total": 5000,
"pending": 0,
"overdue": 0,
"partial": 3000,
"paid": 2000,
"count": 1,
"pending_count": 0,
"overdue_count": 0,
"paid_count": 0
}
}
In the summary, total is the sum of amount; pending, overdue and partial are sums of remaining_amount; paid is the sum of paid_amount.
Register a payment
POST /api/accounts-receivable/{id}/payments
POST /api/accounts-payable/{id}/payments
| Field | Type | Required | Rules |
|---|---|---|---|
amount |
number | yes | min 0.01, max = the current remaining_amount |
account_id |
uuid | yes | one of the user's own bank accounts |
description |
string | no | max 255, default "Payment for {invoice_number or description}" |
payment_date |
date | no | default today |
Every payment automatically creates a matching transaction on account_id: income for receivables, expense for payables. It also updates paid_amount, remaining_amount and status.
201
{
"success": true,
"data": {
"id": "01a1029e-d3a7-70a2-ae5f-7749effe04a6",
"type": "receivable",
"amount": "2000.00",
"payment_date": "2026-10-03T00:00:00.000000Z",
"description": "Payment for INV-001",
"transaction_id": "01a1029e-d3a8-7177-b409-5401ad4ebdec",
"account": { },
"receivable": { "status": "partial", "paid_amount": "2000.00", "remaining_amount": "3000.00", "customer": { } }
},
"message": "Payment received successfully."
}
For payables, the response has payable (with supplier) instead of receivable, and the message is "Payment made successfully.".
Payments
GET /api/payments
GET /api/payments/{id}
PUT /api/payments/{id}
DELETE /api/payments/{id}
List filters: type (receivable or payable), account_id, date_from, date_to, per_page (default 25). Results are sorted by payment_date descending, and each payment embeds account, receivable.customer and payable.supplier.
Show also embeds the linked transaction.
Update accepts amount (max = remaining_amount + this payment's amount), account_id (one of the user's own accounts), description and payment_date. The linked transaction and the receivable or payable totals are updated to match.
Delete reverses the paid amount on the receivable or payable, recalculates its status, and deletes the linked transaction.
Reports
All report endpoints take these common query parameters:
| Query | Values |
|---|---|
period |
daily (last 30 days, grouped by day), weekly (12 weeks), monthly (12 months, the default), yearly (5 years) |
start_date / end_date |
Override the range set by period |
accounts[] |
Restrict to some of the user's own account IDs, e.g. ?accounts[]=uuid1&accounts[]=uuid2 |
Reports read the user's own transactions. Transfers never count as income or expense.
Income vs expense
GET /api/reports/income-expense
Takes an extra group_by parameter (day, week, month or year) that overrides the grouping chosen by period.
{
"success": true,
"data": {
"labels": ["2026-09", "2026-10"],
"datasets": [
{ "label": "Income", "data": [5000, 5200], "backgroundColor": "#4CAF50" },
{ "label": "Expense", "data": [300, 350], "backgroundColor": "#F44336" }
],
"summary": { "total_income": 10200, "total_expense": 650, "net_amount": 9550 },
"period": { "start": "2025-10-03", "end": "2026-10-03", "group_by": "month" }
}
}
By category
GET /api/reports/category
Takes an extra type parameter: expense (default) or income.
{
"success": true,
"data": {
"labels": ["Food", "Transport"],
"datasets": [{ "data": [650, 120], "backgroundColor": ["#FF6384", "#36A2EB"] }],
"details": [
{ "category": "Food", "icon": "restaurant", "color": "#FF6384", "amount": 650, "count": 3, "percentage": 84.4 }
],
"summary": { "total": 770, "transaction_count": 5, "category_count": 2 },
"period": { "start": "2025-10-03", "end": "2026-10-03", "type": "expense" }
}
}
Cash flow
GET /api/reports/cash-flow
{
"success": true,
"data": {
"labels": ["Sep 2026", "Oct 2026"],
"datasets": [
{ "label": "Balance", "data": [4700, 9550], "type": "line", "backgroundColor": "#2196F3", "borderColor": "#2196F3" },
{ "label": "Income", "data": [5000, 5200], "backgroundColor": "#4CAF50" },
{ "label": "Expense", "data": [300, 350], "backgroundColor": "#F44336" }
],
"summary": { "initial_balance": 0, "final_balance": 9550, "total_income": 10200, "total_expense": 650, "net_change": 9550 },
"period": { "start": "2026-09-01", "end": "2026-10-03" }
}
}
initial_balance is the net of income and expense before start_date. Data points are grouped by the granularity of period.
Net worth
GET /api/reports/net-worth
Takes period, start_date and end_date. Account balances follow the same rule as current_balance (initial balance + income − expense, as of each date).
- Assets:
checking,savings,investmentandcashaccounts. - Liabilities:
credit_cardaccounts, counted by the absolute value of their balance.
{
"success": true,
"data": {
"labels": ["Aug 2026", "Sep 2026", "Oct 2026"],
"datasets": [
{ "label": "Assets", "data": [1000, 5700, 10700] },
{ "label": "Liabilities", "data": [0, 0, 150] },
{ "label": "Net Worth", "data": [1000, 5700, 10550], "type": "line" }
],
"summary": { "current_assets": 10700, "current_liabilities": 150, "current_net_worth": 10550, "change": 9550, "change_percentage": 955 },
"accounts": [
{ "id": "…", "name": "Checking", "type": "checking", "balance": 10700, "is_asset": true },
{ "id": "…", "name": "Card", "type": "credit_card", "balance": -150, "is_asset": false }
],
"period": { "start": "2026-08-03", "end": "2026-10-03" }
}
}
Saved reports
POST /api/reports/save
GET /api/reports/saved paginated, 10 per page
GET /api/reports/saved/{id} runs the report with its saved parameters
DELETE /api/reports/saved/{id}
Save body
| Field | Type | Required | Rules |
|---|---|---|---|
name |
string | yes | max 255 |
type |
string | yes | income_expense, category, cash_flow, net_worth |
parameters |
object | yes | the query parameters of that report, e.g. {"period": "monthly"}; may be empty |
201
{ "success": true, "data": { "id": "…", "name": "Monthly overview", "type": "income_expense", "parameters": { "period": "monthly" } }, "message": "Report saved successfully" }
GET /reports/saved/{id} returns exactly the same response as calling that report endpoint with the saved parameters. Reports saved from the website appear here too.
Settings
Get settings
GET /api/settings
Creates default settings if the user has none yet.
{
"success": true,
"data": {
"language": "en",
"currency": "USD",
"date_format": "Y-m-d",
"time_format": "H:i",
"theme": "light",
"notifications_enabled": true,
"notification_preferences": null,
"device_token": null,
"device_type": null
}
}
Update settings
PUT /api/settings
| Field | Rules |
|---|---|
language |
en, pt-br, es. Also updates the user's language |
currency |
3 characters |
date_format |
Y-m-d, d/m/Y, m/d/Y, d-m-Y, m-d-Y |
time_format |
H:i, h:i A |
theme |
light, dark, system |
notifications_enabled |
boolean |
notification_preferences |
a JSON-encoded string, e.g. "{\"email\":false}" |
Update theme
PATCH /api/settings/theme
Body: {"theme": "dark"}. Returns {"success": true, "data": {"theme": "dark"}, "message": "Theme updated successfully"}.
Dashboard
Overview
GET /api/dashboard
{
"success": true,
"data": {
"summary": {
"total_balance": 10550,
"formatted_balance": "$10,550.00",
"monthly_income": 5200,
"formatted_income": "$5,200.00",
"monthly_expense": 350,
"formatted_expense": "$350.00",
"net_flow": 4850,
"formatted_net_flow": "$4,850.00",
"income_change": 4,
"expense_change": 16.67
},
"accounts": [
{ "id": "…", "name": "Checking", "type": "checking", "balance": 10700, "currency": "USD", "formatted_balance": "$10,700.00" }
],
"budgets": [
{ "id": "…", "name": "Food", "amount": 1000, "category": "Food", "budget": "$1,000.00", "spent": 350, "remaining": 650, "percentage": 35 }
],
"goals": [
{ "id": "…", "name": "Trip", "target_amount": "1000.00", "current_amount": "250.00", "progress": 25, "target_date": null }
],
"recent_transactions": [
{ "id": "…", "type": "expense", "amount": "200.00", "formatted_amount": "$200.00", "description": "Groceries", "date": "2026-10-01", "category": "Food", "account": "Checking" }
]
}
}
total_balanceis the sum ofcurrent_balanceover the user's active,include_in_totalsaccounts.accountslists every account the user owns.- Monthly figures cover the current calendar month across all accessible accounts.
income_changeandexpense_changeare percentages against the previous month. budgets(up to 5) shows active budgets with this month's spending.goals(up to 3) shows incomplete goals.recent_transactionsholds the last 10 transactions.- The
formatted_*fields always use a$symbol. Format the numeric fields yourself for other currencies.
Widgets
GET /api/dashboard/widgets
{
"success": true,
"data": {
"counts": { "accounts": 2, "categories": 2, "transactions": 5, "budgets": 1, "goals": 1 },
"category_spending": [{ "category": "Food", "amount": 350, "count": 2 }]
}
}
category_spending holds the top 5 expense categories of the current month.
Recent activity
GET /api/dashboard/recent-activity
The 15 most recent transactions, as {type: "transaction", action, description, amount, date, data: {account, category}}.
Notifications
In-app notifications sent to the user.
List
GET /api/notifications
| Query | Description |
|---|---|
status |
pending, sent, delivered, read, failed |
channel |
in_app, email, sms, whatsapp |
unread_only |
boolean |
per_page |
default 15 |
Fetching the list marks the returned in_app notifications as delivered.
{
"success": true,
"data": {
"notifications": [
{
"id": "01a1029e-d3fc-73ca-972e-f6e260d15387",
"title": "Budget Alert",
"content": "Your Food budget is at 90%",
"channel": "in_app",
"status": "sent",
"read_at": null,
"sent_at": "2026-10-03T16:35:25.000000Z",
"delivered_at": null,
"created_at": "2026-10-03T16:35:25.000000Z",
"is_read": false
}
],
"pagination": { "current_page": 1, "per_page": 15, "total": 1, "last_page": 1 },
"summary": { "total": 1, "unread": 1, "in_app": 1, "email": 0 }
}
}
Actions
POST /api/notifications/{id}/read mark one as read
POST /api/notifications/read-all → {"message": "Marked 3 notifications as read"}
DELETE /api/notifications/{id}
A notification that belongs to another user returns 404.
Mobile
Sync
GET /api/mobile/sync?last_sync=2026-03-06T15:00:00Z
Without last_sync, returns everything. With it, returns only records whose updated_at is later than last_sync. last_sync must be a valid date (422 otherwise).
{
"success": true,
"data": {
"accounts": [ ],
"shared_accounts": [ ],
"categories": [ ],
"transactions": [ ],
"budgets": [ ],
"goals": [ ],
"sync_timestamp": "2026-10-03T16:35:25.000000Z"
}
}
Each account embeds its transactions, and each shared account adds user_role and is_shared. Store sync_timestamp and send it as last_sync next time.
Deleted records are not reported by an incremental sync, so run a full sync (no last_sync) periodically to drop items removed elsewhere.
Offline bundle
GET /api/mobile/offline-data
Returns the essentials for offline use: user (id, name, email, preferred_currency, language), accounts, shared_accounts, categories, the 100 most recent transactions in recent_transactions, currencies (a map of code to symbol, e.g. "BRL": "R$") and downloaded_at.
Register device token
POST /api/mobile/device-token
| Field | Rules |
|---|---|
device_token |
string, max 255 (FCM/APNs token) |
device_type |
ios, android, web |
{ "success": true, "message": "Device token updated successfully.", "data": { "device_token": "fcm_token_here", "device_type": "android" } }
Admin API
Every endpoint is under /api/admin and requires a token belonging to a user with is_admin = true. Anyone else receives 403 {"message": "Unauthorized action."}. Validation errors here use the {message, errors} shape.
Analytics endpoints accept period: 7days, 30days (default), 90days or 1year.
Dashboard
GET /api/admin/dashboard
GET /api/admin/dashboard/stats?period=30days
GET /api/admin/dashboard/charts
dashboardreturnsmetrics.users(total,active= users with transactions in the last 30 days,new_today,new_this_week,new_this_month,growth_rate),metrics.transactions(total,today,this_month,total_revenue,total_expenses,net_flow),metrics.whatsapp,metrics.subscriptions(active,revenue) andrecent_activity(the latest 10usersandtransactions).statsreturnsuser_growth[](date,count),transaction_volume[](date,count,income,expenses),category_usage[](top 10) andperiod.chartsreturnsmonthly_revenue[](the last 12 months),subscription_distribution[](Freefirst) andtransaction_types[]for the current month.
Users
GET /api/admin/users
POST /api/admin/users
GET /api/admin/users/{id}
PUT /api/admin/users/{id}
DELETE /api/admin/users/{id}
POST /api/admin/users/{id}/toggle-admin
POST /api/admin/users/{id}/suspend
POST /api/admin/users/{id}/activate
List filters: search (name or e-mail), is_admin, subscription_status (active or free), suspended (boolean), registered_after, registered_before, sort_by (name, email, created_at (default), updated_at, is_admin, language, preferred_currency), sort_direction, per_page (default 15).
Each user in the list has is_suspended, suspended_at, subscription and stats (accounts_count, transactions_count, total_balance). The list comes back as data.users[] plus data.pagination.
Show returns user, subscription, statistics (total_balance, monthly_income, monthly_expense, net_flow, and the number of accounts, categories, transactions, budgets and goals), accounts[], recent_transactions[] and notification_logs[].
Create (201, returns data.user)
| Field | Required | Rules |
|---|---|---|
name |
yes | max 255 |
email |
yes | unique |
password |
yes | min 8 |
phone |
no | max 20 |
language |
no | default en |
preferred_currency |
no | default USD |
is_admin |
no | boolean |
Update accepts the same fields, all optional.
Rules and side effects
- The last remaining admin cannot be demoted (
update,toggle-admin) or deleted. These attempts return 400. suspendsetssuspended_atand revokes every token of that user. A suspended user cannot log in (403) and is refused on every endpoint.activateclears the suspension.- Admins cannot suspend themselves.
{ "success": true, "message": "User suspended", "data": { "user_id": "…", "is_suspended": true, "suspended_at": "2026-10-03T16:35:25.000000Z" } }
Transactions
GET /api/admin/transactions
GET /api/admin/transactions/analytics?period=30days
GET /api/admin/transactions/{id}
DELETE /api/admin/transactions/{id}
List filters: search (description, user name or user e-mail), type, user_id, category_id, min_amount, max_amount, date_from, date_to, sort_by (date (default), amount, type, description, created_at), sort_direction, per_page.
The list returns transactions[] (each with user, account and category), pagination, and a global summary (total_income, total_expense, total_transactions).
analytics returns transaction_volume, top_categories, top_users, type_distribution, average_amounts, period and date_range.
Deleting a transfer leg also deletes its counterpart.
Categories
GET /api/admin/categories
GET /api/admin/categories/analytics?period=30days
GET /api/admin/categories/{id}
POST /api/admin/categories
PUT /api/admin/categories/{id}
DELETE /api/admin/categories/{id}
POST /api/admin/categories/bulk-actions
List filters: search, user_id, type (income or expense), usage (used or unused), per_page. Returns categories[] (each with user and transactions_count), pagination, and summary (total_categories, income_categories, expense_categories, used_categories, unused_categories).
Show returns category, statistics (total_transactions, total_amount, income_amount, expense_amount, average_amount, unique_users, first_used, last_used) and recent_transactions[].
Create takes name, type (income or expense), user_id (the owner) and, optionally, color (max 7) and icon (max 50). Update takes the same fields, all optional.
Delete returns 400 if the category has transactions.
Bulk actions
| Field | Required | Rules |
|---|---|---|
action |
yes | delete (refused with 400 if any category has transactions) or merge |
category_ids |
yes | array of category IDs |
target_category_id |
for merge |
the category that receives the transactions. The source categories are deleted. |
Notification templates
GET /api/admin/notifications
GET /api/admin/notifications/analytics?period=30days
GET /api/admin/notifications/{id}
POST /api/admin/notifications
PUT /api/admin/notifications/{id}
DELETE /api/admin/notifications/{id}
POST /api/admin/notifications/{id}/send
List filters: search, type, is_active, per_page. Returns templates[], pagination and summary.
Create / update body (on update, every required field must be sent again)
| Field | Required | Rules |
|---|---|---|
name |
yes | max 255. The slug is generated from it |
type |
yes | email, sms, whatsapp, in_app |
content |
yes | supports {{user_name}} |
target_audience |
yes | all, admin, subscribed, new_users |
subject |
no | max 255 |
trigger_event |
no | max 255 |
variables |
no | array |
is_active |
no | boolean, default true |
category |
no | max 100 |
settings |
no | object |
Send
| Field | Description |
|---|---|
user_ids |
array of user IDs |
send_to_all |
boolean. Sends to every user |
Send creates one pending notification log per user, with the content rendered for that user, and returns {"template_id": "…", "users_count": 4}. For in_app templates, the user sees the notification in GET /api/notifications.
Show returns template, statistics (sent, delivered, read, failed, delivery rate) and recent_logs[].
Analytics returns notification_volume, delivery_rates (per channel) and top_templates.
WhatsApp connections
GET /api/admin/whatsapp
POST /api/admin/whatsapp
GET /api/admin/whatsapp/{id}
PUT /api/admin/whatsapp/{id}
DELETE /api/admin/whatsapp/{id}
GET /api/admin/whatsapp/{id}/messages
POST /api/admin/whatsapp/{id}/test
POST /api/admin/whatsapp/messages/{message_id}/process
Create takes name, phone_number (max 20), business_account_id, access_token, webhook_verify_token and, optionally, settings. New connections start with status: "inactive". Update requires the same fields plus status (active, inactive or disconnected). Secrets (access_token, webhook_verify_token) are never returned.
List filters by search (name or phone) and status. Show returns connection, statistics and the 15 most recent messages.
Messages filters by status, direction (inbound or outbound), date_from, date_to, search and per_page.
test marks the connection as active and stamps last_connected_at. process marks a pending message as processed, and returns 400 if the message is not pending.
Endpoint Index
| Method | Path | Auth |
|---|---|---|
| GET | /test |
— |
| POST | /register |
— |
| POST | /login |
— |
| POST | /logout |
user |
| GET | /user · /profile |
user |
| POST | /refresh |
user |
| GET · POST | /tokens |
user |
| DELETE | /tokens/{id} |
user |
| PUT | /profile |
user |
| POST | /profile/password · /profile/avatar · /profile/delete-account |
user |
| GET · POST | /accounts |
user |
| GET | /accounts/balances |
user |
| GET · PUT · DELETE | /accounts/{id} |
user |
| GET · POST | /categories |
user |
| GET · PUT · DELETE | /categories/{id} |
user |
| GET · POST | /transactions |
user |
| GET | /transactions/statistics |
user |
| GET · PUT · DELETE | /transactions/{id} |
user |
| GET · POST | /recurring-transactions |
user |
| GET | /recurring-transactions/upcoming |
user |
| GET · PUT · DELETE | /recurring-transactions/{id} |
user |
| POST | /recurring-transactions/{id}/generate |
user |
| GET · POST | /budgets |
user |
| GET | /budgets/progress |
user |
| GET · PUT · DELETE | /budgets/{id} |
user |
| GET · POST | /goals · /financial-goals |
user |
| GET · PUT · DELETE | /goals/{id} · /financial-goals/{id} |
user |
| POST | /goals/{id}/add-funds · /financial-goals/{id}/add-funds |
user |
| GET · POST | /customers · /suppliers |
user |
| GET · PUT · DELETE | /customers/{id} · /suppliers/{id} |
user |
| GET · POST | /accounts-receivable · /accounts-payable |
user |
| GET | /accounts-receivable/summary · /accounts-payable/summary |
user |
| GET · PUT · DELETE | /accounts-receivable/{id} · /accounts-payable/{id} |
user |
| POST | /accounts-receivable/{id}/payments · /accounts-payable/{id}/payments |
user |
| GET | /payments |
user |
| GET · PUT · DELETE | /payments/{id} |
user |
| GET | /reports/income-expense · /reports/category · /reports/cash-flow · /reports/net-worth |
user |
| POST | /reports/save |
user |
| GET | /reports/saved |
user |
| GET · DELETE | /reports/saved/{id} |
user |
| GET · PUT | /settings |
user |
| PATCH | /settings/theme |
user |
| GET | /dashboard · /dashboard/widgets · /dashboard/recent-activity |
user |
| GET | /notifications |
user |
| POST | /notifications/{id}/read · /notifications/read-all |
user |
| DELETE | /notifications/{id} |
user |
| GET | /mobile/sync · /mobile/offline-data |
user |
| POST | /mobile/device-token |
user |
| GET | /admin/dashboard · /admin/dashboard/stats · /admin/dashboard/charts |
admin |
| GET · POST | /admin/users |
admin |
| GET · PUT · DELETE | /admin/users/{id} |
admin |
| POST | /admin/users/{id}/toggle-admin · /suspend · /activate |
admin |
| GET | /admin/transactions · /admin/transactions/analytics |
admin |
| GET · DELETE | /admin/transactions/{id} |
admin |
| GET · POST | /admin/categories |
admin |
| GET | /admin/categories/analytics |
admin |
| GET · PUT · DELETE | /admin/categories/{id} |
admin |
| POST | /admin/categories/bulk-actions |
admin |
| GET · POST | /admin/notifications |
admin |
| GET | /admin/notifications/analytics |
admin |
| GET · PUT · DELETE | /admin/notifications/{id} |
admin |
| POST | /admin/notifications/{id}/send |
admin |
| GET · POST | /admin/whatsapp |
admin |
| GET · PUT · DELETE | /admin/whatsapp/{id} |
admin |
| GET | /admin/whatsapp/{id}/messages |
admin |
| POST | /admin/whatsapp/{id}/test |
admin |
| POST | /admin/whatsapp/messages/{id}/process |
admin |
PUT endpoints also accept PATCH, except PUT /profile and PUT /settings.
Quick Start
# 1. Log in and keep the token
TOKEN=$(curl -s -X POST https://ezfin.app/api/login \
-H "Accept: application/json" -H "Content-Type: application/json" \
-d '{"email":"john@example.com","password":"secret123","device_name":"My App"}' \
| jq -r '.data.access_token')
# 2. Find an account
ACCOUNT=$(curl -s https://ezfin.app/api/accounts \
-H "Authorization: Bearer $TOKEN" -H "Accept: application/json" \
| jq -r '.data.accounts[0].id')
# 3. Record an expense
curl -X POST https://ezfin.app/api/transactions \
-H "Authorization: Bearer $TOKEN" -H "Accept: application/json" -H "Content-Type: application/json" \
-d "{\"account_id\":\"$ACCOUNT\",\"type\":\"expense\",\"amount\":42.50,\"date\":\"2026-03-06\",\"description\":\"Groceries\"}"
# 4. Dashboard
curl https://ezfin.app/api/dashboard -H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
# 5. Incremental sync for a mobile app
curl "https://ezfin.app/api/mobile/sync?last_sync=2026-03-06T15:00:00Z" \
-H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
JavaScript:
const api = async (path, { method = 'GET', body, token } = {}) => {
const res = await fetch(`https://ezfin.app/api${path}`, {
method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
},
body: body && JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw Object.assign(new Error(json.message ?? 'Request failed'), { status: res.status, errors: json.errors });
return json.data ?? json;
};
const { access_token } = await api('/login', { method: 'POST', body: { email, password, device_name: 'Web' } });
const dashboard = await api('/dashboard', { token: access_token });