API and webhooks
The Inolab API connects the customers, invoices and payments in Inolab with other systems, such as an online store, a point-of-sale app or accounting software. All requests and responses are JSON over HTTPS.
Base URL: https://app.inolab.id/api/v1 · OpenAPI 3.1 specification: openapi.yaml
Authentication
Create an API key in the app under Settings › Integrations. Only business owners and admins can create keys. A key is shown once; store it somewhere safe and never put it in code that runs in a browser or mobile app.
Send the key in the Authorization header of every request:
curl "https://app.inolab.id/api/v1/invoices?status=overdue" \
-H "Authorization: Bearer 12|inolab_xxxxxxxxxxxxxxxxxxxx" \
-H "Accept: application/json"
- A key works for one business, the one it was created in. The
X-Tenant-Idheader is not needed. - A key acts on behalf of the person who created it. It is limited to the scopes chosen and never exceeds that person's role. If they leave the business, the key stops working.
- Keys can be revoked at any time from the same page.
API key scopes
| Scope | Allows |
|---|---|
customers:read | View customers |
customers:write | Add, edit and delete customers |
invoices:read | View invoices and the overview |
invoices:write | Create, edit, issue and cancel invoices |
payments:write | Record and delete invoice payments |
expenses:read | View expenses |
expenses:write | Record, edit and delete expenses |
Account, subscription and notification endpoints are for the Inolab apps only and cannot be used with an API key.
Endpoints
| Method | Path | Scope | Description |
|---|---|---|---|
GET |
/customers |
customers:read |
List customers; ?q=, ?type=company|individual, ?per_page= (max. 100) |
GET |
/customers/{id} |
customers:read |
One customer |
POST |
/customers |
customers:write |
Add customer |
PUT |
/customers/{id} |
customers:write |
Edit customer |
DELETE |
/customers/{id} |
customers:write |
Delete a customer with no open invoices |
GET |
/invoices |
invoices:read |
List invoices; ?status=draft|open|overdue|paid|void, ?q=, ?customer_id= |
GET |
/invoices/{id} |
invoices:read |
One invoice with its items and payments |
GET |
/invoices/{id}/pdf |
invoices:read |
Invoice PDF (application/pdf) |
GET |
/dashboard |
invoices:read |
Receivables figures and overdue invoices |
POST |
/invoices |
invoices:write |
Create a draft invoice; totals are calculated by the server |
PUT |
/invoices/{id} |
invoices:write |
Edit a draft |
DELETE |
/invoices/{id} |
invoices:write |
Delete draft |
POST |
/invoices/{id}/send |
invoices:write |
Issue; email_customer=true to send the email |
POST |
/invoices/{id}/void |
invoices:write |
Cancel an invoice with no payments; reason is optional |
POST |
/invoices/{id}/payments |
payments:write |
Record a payment: amount, paid_on, method, reference, notes |
DELETE |
/invoices/{id}/payments/{paymentId} |
payments:write |
Delete payment record |
GET |
/expenses |
expenses:read |
List expenses; ?from=, ?to=, ?category=, ?q= |
GET |
/expenses/{id} |
expenses:read |
One expense |
POST |
/expenses |
expenses:write |
Record an expense: spent_on, category, description, amount, tax_amount, method |
PUT |
/expenses/{id} |
expenses:write |
Edit expense |
DELETE |
/expenses/{id} |
expenses:write |
Delete expense |
Adding a customer:
curl -X POST "https://app.inolab.id/api/v1/customers" \
-H "Authorization: Bearer 12|inolab_xxxxxxxxxxxxxxxxxxxx" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"type": "company", "name": "PT Maju Bersama", "email": "keuangan@majubersama.co.id"}'
- Money is whole Rupiah without decimals, for example
5550000. - Dates use
YYYY-MM-DD; times use ISO 8601 with a time zone. - Invoice tax rates:
0,11or12percent. Numbers, subtotal, tax and total are calculated by the server. - Payment methods:
bank_transfer,qris,ewallet,cash,card,other.
Responses and errors
Every response uses the same envelope. Paginated lists include meta.pagination.
{
"success": true,
"message": "Permintaan berhasil.",
"data": [ ... ],
"meta": { "pagination": { "current_page": 1, "per_page": 25, "total": 42, "last_page": 2 } }
}
{
"success": false,
"message": "Kunci API ini tidak memiliki izin untuk tindakan ini.",
"code": "missing_scope"
}
| Status | Meaning |
|---|---|
401 | The key is missing, wrong, revoked or expired. |
403 | The key's scopes are not enough (code: missing_scope), or the key creator's role does not allow it. |
404 | The record does not exist or belongs to another business. |
422 | Invalid data; details per field in errors. |
429 | Too many requests. The limit is 120 requests a minute per user; wait as long as the Retry-After header says. |
Send Accept-Language: en for messages in English. The default is Indonesian.
Webhooks
Webhooks tell your system when data changes, so you do not have to poll the API. Add a receiving URL under Settings › Integrations and choose the events to receive. The URL must be public HTTPS on port 443; internal network addresses are refused.
| Event | Sent when |
|---|---|
customer.created | Customer added |
customer.updated | Customer updated |
customer.deleted | Customer deleted |
invoice.created | Draft invoice created |
invoice.updated | Draft invoice updated |
invoice.sent | Invoice issued |
invoice.paid | Invoice paid |
invoice.voided | Invoice cancelled |
invoice.deleted | Draft invoice deleted |
invoice.payment_recorded | Invoice payment recorded |
invoice.payment_deleted | Payment record deleted |
Each event is sent as a JSON POST:
POST /webhooks/inolab HTTP/1.1
Content-Type: application/json
User-Agent: Inolab-Webhooks/1.0
Inolab-Event: invoice.paid
Inolab-Event-Id: evt_01k7c3m0q4f9w2v8t6r5y1x3za
Inolab-Delivery: 01k7c3m0r8a2b4c6d8e0f2g4h6
Inolab-Signature: t=1791520200,v1=5f2b9c…
{
"id": "evt_01k7c3m0q4f9w2v8t6r5y1x3za",
"type": "invoice.paid",
"created_at": "2026-10-09T14:30:00+07:00",
"tenant_id": "01k6xq5a9d3m7p2r4t6v8x0z2b",
"data": {
"id": "01k7a0b2c4d6e8f0g2h4j6k8m0",
"number": "INV/2026/0042",
"status": "paid",
"customer": { "id": "01k6…", "name": "PT Maju Bersama", "email": "keuangan@majubersama.co.id" },
"issue_date": "2026-10-01",
"due_date": "2026-10-15",
"currency": "IDR",
"subtotal": 5000000,
"tax_rate": 11,
"tax_amount": 550000,
"total": 5550000,
"amount_paid": 5550000,
"balance_due": 0,
"items": [ { "description": "Jasa desain kemasan", "quantity": 1, "unit_price": 5000000, "amount": 5000000 } ],
"payments": [ ... ],
"paid_at": "2026-10-09T14:30:00+07:00"
}
}
dataholds an object shaped like the API response. Payment events containdata.paymentanddata.invoice.- Reply with a
2xxstatus within 10 seconds. Any other status, a timeout or a redirect counts as a failure. - Failed deliveries are retried up to 5 times over about 9 hours, then marked failed. You can see and resend them in the app.
- An event may arrive more than once and order is not guaranteed. Use the event
id(Inolab-Event-Id) to ignore duplicates. - The Send test button sends a
pingevent.
Verifying signatures
The Inolab-Signature header holds the send time (t, Unix seconds) and v1, an HMAC-SHA256 of t, a dot and the raw body, keyed with the webhook's signing secret. Reject requests whose signature does not match or whose time is more than 5 minutes away from now.
// $secret: the signing secret shown when the webhook was created.
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_INOLAB_SIGNATURE'] ?? ''), $signature);
$expected = hash_hmac('sha256', ($signature['t'] ?? '').'.'.$body, $secret);
$fresh = abs(time() - (int) ($signature['t'] ?? 0)) <= 300;
if (! $fresh || ! hash_equals($expected, $signature['v1'] ?? '')) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
// Use $event['id'] to ignore an event you have already handled.
http_response_code(200);
import crypto from 'node:crypto';
// rawBody: the request body exactly as received (a string, not parsed JSON).
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')));
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
return fresh
&& typeof parts.v1 === 'string'
&& parts.v1.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}
The signing secret is shown once when the webhook is created. If it leaks, create a new secret from the webhook page; the old one stops working immediately.
Not available yet
- OAuth for third-party apps and official SDKs. For now, use a per-business API key.
- Report endpoints (receivables aging, revenue); that data can be downloaded as CSV from the app.
Technical questions: support@inolab.id.