Skip to content
Inolab

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-Id header 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

ScopeAllows
customers:readView customers
customers:writeAdd, edit and delete customers
invoices:readView invoices and the overview
invoices:writeCreate, edit, issue and cancel invoices
payments:writeRecord and delete invoice payments
expenses:readView expenses
expenses:writeRecord, edit and delete expenses

Account, subscription and notification endpoints are for the Inolab apps only and cannot be used with an API key.

Endpoints

MethodPathScopeDescription
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, 11 or 12 percent. 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"
}
StatusMeaning
401The key is missing, wrong, revoked or expired.
403The key's scopes are not enough (code: missing_scope), or the key creator's role does not allow it.
404The record does not exist or belongs to another business.
422Invalid data; details per field in errors.
429Too 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.

EventSent when
customer.createdCustomer added
customer.updatedCustomer updated
customer.deletedCustomer deleted
invoice.createdDraft invoice created
invoice.updatedDraft invoice updated
invoice.sentInvoice issued
invoice.paidInvoice paid
invoice.voidedInvoice cancelled
invoice.deletedDraft invoice deleted
invoice.payment_recordedInvoice payment recorded
invoice.payment_deletedPayment 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"
  }
}
  • data holds an object shaped like the API response. Payment events contain data.payment and data.invoice.
  • Reply with a 2xx status 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 ping event.

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.