API Documentation v1
Base URL /api/v1 — Bearer token, company-scoped, JSON only. Create a token at Portal → Settings → API applications.
1. Overview
The API exposes the same ledger-backed engine as the portal. Every request is scoped to the company that owns the Bearer token. Creating, syncing or voiding through the API writes the same transactions and audit logs as the UI.
- Auth:
Authorization: Bearer gb_<60-char> - Content-Type:
application/json— Accept:application/json - Pagination:
?page=1&per_page=15(max 100) - Health:
GET /up
2. Authentication — API applications
Portal → Settings → API applications (owner only). Click Create application, enter a name and optional expiry, and copy the token once. The token is stored hashed (SHA-256) and shown only once.
curl -H "Authorization: Bearer gb_abc..." https://app.example.com/api/v1/me
# 200 { "company_id": 1, "application": "Zapier" }
# 401 { "message": "Unauthenticated. Invalid API token." }
- Tokens are company-scoped — they cannot read or write another company even if the ID is guessed.
- Expired tokens return 401. Revoked (deleted) tokens return 401.
last_used_atis updated on every authenticated request.
3. Conventions
| Topic | Rule |
|---|---|
| Amounts | Integers in minor units (cents). Invoices item unit_price accepts decimal string ("50.00") — converted via Money to cents. |
| Dates | ISO-8601 YYYY-MM-DD |
| Pagination | Laravel paginator: data, current_page, last_page, total |
| Validation | 422 with errors object |
| Tenant isolation | Global scope on company_id via CompanyContext set by AuthenticateApiApplication |
4. Endpoints summary
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/me | Token info (company, app name) |
| GET/POST | /api/v1/customers, /api/v1/customers/{id} | List/create/show/update/delete customers |
| GET/POST | /api/v1/products, /api/v1/products/{id} | Products & services |
| GET/POST | /api/v1/quotes | Quotes/estimates |
| GET/POST | /api/v1/invoices, /api/v1/invoices/{id} | Invoices (draft-editable only) |
| GET/POST | /api/v1/payments, /api/v1/payments/{id}/void | Payments + void |
| GET/POST | /api/v1/expenses, /api/v1/expenses/{id}/void | Expenses + void |
| GET/POST | /api/v1/vendors | Vendors |
| GET/POST | /api/v1/transactions, /transfer, /income, /adjustment | Ledger |
5. Customers
GET /api/v1/customers?search=acme&per_page=15
POST /api/v1/customers { "name":"Acme Corp","email":"acme@example.com" }
GET /api/v1/customers/{id}
PUT /api/v1/customers/{id}
DELETE /api/v1/customers/{id} # soft delete
Fields: name*, email, phone, company_name, tax_id, billing_address, shipping_address
6. Products & Services
POST /api/v1/products { "name":"Widget","type":"product","unit_price":1000,"tax_rate":5 }
Types: product | service. unit_price is minor units.
7. Quotes
POST /api/v1/quotes {
"customer_id":1, "issue_date":"2026-09-25", "expiry_date":"2026-10-25",
"items":[{"description":"Service","quantity":2,"unit_price":"50.00"}]
}
8. Invoices
POST /api/v1/invoices {
"customer_id":1, "issue_date":"2026-09-25", "due_date":"2026-10-02",
"discount_type":"percent","discount_value":10,
"items":[
{"description":"Design","quantity":1,"unit_price":"500.00","tax_rate":10},
{"product_id":3,"description":"Hosting","quantity":2,"unit_price":"50.00"}
]
}
Invoices use InvoiceService + InvoiceCalculator — line subtotal → line discount → invoice discount → tax (inclusive/exclusive per company). Only draft invoices are update/delete-able.
PUT /api/v1/invoices/{id} # draft only
DELETE /api/v1/invoices/{id} # draft only -> 422 otherwise
9. Payments
POST /api/v1/payments {
"customer_id":1,"bank_account_id":2,"amount":5000,"payment_date":"2026-09-25",
"method":"bank_transfer",
"allocations":[{"invoice_id":10,"amount":5000}]
}
POST /api/v1/payments/{id}/void { "reason":"Duplicate" }
Unallocated amount becomes customer credit. Void reverses ledger via LedgerPostingService::reverse.
10. Expenses & Vendors
POST /api/v1/vendors { "name":"Acme Supplies" }
POST /api/v1/expenses {
"expense_category_id":1,"bank_account_id":1,"amount":2500,
"date":"2026-09-25","description":"Office supplies"
}
POST /api/v1/expenses/{id}/void { "reason":"Wrong amount" }
11. Transactions (ledger)
GET /api/v1/transactions?type=transfer
POST /api/v1/transactions/transfer { "from_account_id":1,"to_account_id":2,"amount":2000,"date":"2026-09-25" }
POST /api/v1/transactions/income { "bank_account_id":1,"ledger_account_id":5,"amount":10000,"date":"2026-09-25" }
POST /api/v1/transactions/adjustment { "bank_account_id":1,"amount":-500,"date":"2026-09-25","description":"Correction" }
All writes go through LedgerPostingService — the single ledger writer.
12. Finance SaaS integrations — two-way sync (10 providers)
Portal → Settings → Integrations (owner only). Each provider is managed as an Integration row (company_id + provider unique). Two-way sync means push (local → provider) and pull (provider → local).
| # | Provider | Label | API base | Docs |
|---|---|---|---|---|
| 1 | xero | Xero | https://api.xero.com/api.xro/2.0 | docs |
| 2 | quickbooks | QuickBooks Online | https://quickbooks.api.intuit.com/v3 | docs |
| 3 | freshbooks | FreshBooks | https://api.freshbooks.com | docs |
| 4 | hubspot | HubSpot | https://api.hubapi.com | docs |
| 5 | zoho_books | Zoho Books | https://www.zohoapis.com/books/v3 | docs |
| 6 | wave | Wave | https://gql.waveapps.com/graphql/public | docs |
| 7 | sage | Sage Business Cloud | https://api.sage.com | docs |
| 8 | netsuite | Oracle NetSuite | https://{accountId}.suitetalk.api.netsuite.com/services/rest | docs |
| 9 | myob | MYOB | https://api.myob.com/accountright | docs |
| 10 | kashoo | Kashoo | https://api.kashoo.com | docs |
Connect
POST /portal/settings/integrations { "provider":"xero","access_token":"...","refresh_token":"...","external_id":"org_123" }
Sync
POST /portal/settings/integrations/{id}/sync { "entity_type":"customer","entity_id":12,"direction":"push" }
POST /portal/settings/integrations/{id}/sync { "entity_type":"customer","entity_id":12,"direction":"pull" }
GET /portal/settings/integrations/{id}/logs # last 50 SyncLogs
SyncService::push formats the local entity for the provider, simulates Http::post(provider.apiBaseUrl()), stores an integration_sync_logs row and flips status to connected/error. pull fetches remote and upserts locally (e.g. customer). Replace the stubbed fetchFromProvider/formatForProvider with real HTTP per provider when credentials are available.
All syncs are company-scoped — pushing entity #5 from company A cannot read company B's row (verified by test sync isolates by company).
13. Errors & rate limiting
401 { "message": "Unauthenticated. Invalid API token." }
422 { "message": "The given data was invalid.", "errors": { "name": ["..."] } }
403 Forbidden (portal gates)
429 Too Many Requests (throttle)
API routes are throttled by Laravel's api limiter. Portal routes use customer + company middleware and Permission::ManageCompany for API app / integration management.
14. Test cases
Feature tests in tests/Feature/ApiApplicationTest.php, ApiEndpointTest.php, IntegrationTest.php — run via php artisan test.
| Test | What it proves | File |
|---|---|---|
| owner can create and revoke an API application | Token generated with gb_ prefix, hash stored, revoke deletes | ApiApplicationTest |
| staff cannot manage API applications | 403 via Gate | ApiApplicationTest |
| API token authenticates and scopes to company | 401 without/bad/expired, 200 with good token, /me | ApiApplicationTest |
| API token cannot access another company | Tenant isolation via CompanyContext | ApiApplicationTest |
| can CRUD customers via API | POST/GET/PUT/DELETE + search/pagination | ApiEndpointTest |
| can CRUD products via API | Product create/show | ApiEndpointTest |
| can create invoice via API with calculator | InvoiceService totals = 10000 cents | ApiEndpointTest |
| validates invoice payload | 422 on missing customer_id/items | ApiEndpointTest |
| can record payment and transfer via API | Payment + ledger transfer both via services | ApiEndpointTest |
| expenses API creates and voids | Expense record + void reversal | ApiEndpointTest |
| owner can connect and disconnect integrations | Xero connect, update, disconnect | IntegrationTest |
| staff cannot manage integrations | 403 | IntegrationTest |
| all ten providers are available | Count 10, labels, base URLs, two-way flag | IntegrationTest |
| sync service pushes and pulls with logs | Push/pull create integration_sync_logs, update last_sync_at | IntegrationTest |
| sync isolates by company | Pushing other company's entity fails | IntegrationTest |
| logs endpoint returns JSON | GET logs 200 | IntegrationTest |
php artisan test --filter=Api
php artisan test --filter=Integration
php artisan test # all 368 + new tests
15. Examples (curl)
# Create customer
curl -H "Authorization: Bearer gb_xxx" -H "Content-Type: application/json" \
-d '{"name":"Acme","email":"acme@example.com"}' https://app.example.com/api/v1/customers
# List invoices
curl -H "Authorization: Bearer gb_xxx" "https://app.example.com/api/v1/invoices?page=1&per_page=20"
# Create invoice with tax
curl -H "Authorization: Bearer gb_xxx" -H "Content-Type: application/json" -d '{
"customer_id":1,"issue_date":"2026-09-25","due_date":"2026-10-02",
"items":[{"description":"Consulting","quantity":10,"unit_price":"75.00","tax_rate":10}]
}' https://app.example.com/api/v1/invoices
# Void payment
curl -X POST -H "Authorization: Bearer gb_xxx" -H "Content-Type: application/json" \
-d '{"reason":"Duplicate entry"}' https://app.example.com/api/v1/payments/5/void