API Reference
Integrate EasyInvois directly into your ERP, accounting system, or platform. Programmatically create organisations, manage customers, and submit LHDN e-invoices — no dashboard required.
Overview
The EasyInvois REST API lets you build full e-invoicing workflows on top of our platform. All requests and responses use JSON. There are two levels of access — each with a different scope and a separate set of credentials:
- 1Platform key — Issued by EasyInvois to your business (contact us to request one). Can only be used to provision new organisations via
POST /api/v1/organizations. You get one platform key for your entire business — it does not change per organisation. When you create an organisation, the response immediately returns that organisation's own API key. - 2Organisation API key — Scoped to a single organisation. Used for all invoice, customer, and data operations. Can be generated from the organisation's Developer settings in the dashboard, or is returned automatically when an organisation is created via the platform key.
Typical platform onboarding flow
- Call
POST /api/v1/organizationswith your platform key → creates the org, returns an org API key - Store the returned org API key against that merchant in your system
- Use the org API key for all subsequent calls — create customers, create invoices, submit to LHDN, etc.
Base URL
The API is hosted on the same domain as the application. Choose the environment that matches your integration:
https://www.easyinvoisapp.com/api/v1https://staging.easyinvoisapp.com/api/v1To call an endpoint, append its path to the base URL. For example:
# Create an invoice (production)
POST https://www.easyinvoisapp.com/api/v1/invoices
# List customers (staging)
GET https://staging.easyinvoisapp.com/api/v1/customers
# Get a specific invoice
GET https://www.easyinvoisapp.com/api/v1/invoices/f1a2b3c4-...Authentication
Every request must include an Authorization header. Combine your Client ID and Client Secret with a colon:
Authorization: Bearer <client_id>:<client_secret>Client ID
Public identifier. Safe to log. Shown in your key list in Developer settings.
Client Secret
Shown once at creation. Store it in an environment variable — never hard-code it in source code.
Rate Limits
Organisation API endpoints are limited to 60 requests per minute per API key. When the limit is exceeded, the server returns 429 Too Many Requests. Wait until the next minute window before retrying.
HTTP/1.1 429 Too Many Requests
{
"status": "error",
"error": "Rate limit exceeded",
"detail": "You have exceeded 60 requests per minute. Please slow down."
}Error Format
All error responses share the same JSON structure:
{
"status": "error",
"error": "Short error title",
"detail": "A human-readable explanation of what went wrong."
}Common HTTP status codes used across all endpoints:
| HTTP Status | Description |
|---|---|
| 200 | Success (GET / update requests) |
| 201 | Created (resource created successfully) |
| 400 | Malformed JSON or body is not an object |
| 401 | Missing or invalid API key / platform key |
| 403 | Access denied — resource not in your organisation, or limit reached |
| 404 | Resource not found |
| 409 | Conflict — duplicate email, TIN, or slug |
| 422 | Validation error — see detail for the specific field |
| 429 | Rate limit exceeded |
| 500 | Unexpected server error |
| 502 | Upstream service error (e.g. LHDN MyInvois unreachable) |
Endpoints
/api/v1/organizationsPlatform keyCreates a new organisation and owner account in a single call. The account is immediately active — email is auto-verified and an organisation API key is returned. Use this when onboarding a new merchant onto your platform.
client_secret is shown once only. Store it immediately — it cannot be retrieved again.Request body
POST /api/v1/organizations
Authorization: Bearer ci_platform123...:sk_platform456...
Content-Type: application/json
{
"email": "owner@acme.com",
"password": "SecurePass123",
"full_name": "Ahmad bin Ali",
"organization_name": "Acme Sdn Bhd",
"tin": "C1234567890",
"brn": "202301012345",
"msic_code": "62010",
"address_line1": "123 Jalan Bukit",
"city": "Kuala Lumpur",
"state": "WP Kuala Lumpur",
"postal_code": "50450"
}| Field | Type | Required | Description |
|---|---|---|---|
| string | Required | Owner email address. | |
| password | string | Required | Min 8 chars. Must contain uppercase, lowercase, and a digit. Max 72 chars. |
| full_name | string | Required | Owner full name. 2–100 characters. |
| organization_name | string | Required | Organisation display name. 2–150 characters. |
| slug | string | Optional | URL-friendly identifier used as the invoice number prefix (e.g. slug "acme" → invoice "ACME-2026-0001"). Recommended: pass explicitly to control the prefix. If omitted, auto-derived from organisation_name by stripping legal entity suffixes (Sdn Bhd, Berhad, Bhd, Enterprise/s, Corporation, Corp, PLT, LLP, & Sons) — e.g. "Acme Sdn Bhd" → "acme", "Ali Brothers Construction" → "ali-brothers-construction". If the derived slug is already taken, a random suffix is appended (e.g. "acme-3f9a"). Lowercase letters, numbers, hyphens only; 2–60 chars. |
| tin | string | Required | LHDN Tax Identification Number. Format: 1–2 uppercase letters followed by digits (e.g. C1234567890). Required to validate customer TINs and submit invoices to MyInvois. |
| brn | string | Required | Business Registration Number (SSM). Alphanumeric with hyphens (e.g. 202301012345). Required by LHDN. |
| msic_code | string | Required | MSIC industry activity code. Must be exactly 5 digits (e.g. 62010). Required by LHDN. |
| address_line1 | string | Required | First line of business address. Required by LHDN. |
| address_line2 | string | Optional | Second line of business address. |
| city | string | Required | City. Required by LHDN. |
| state | string | Required | State (e.g. WP Kuala Lumpur). Required by LHDN. |
| postal_code | string | Required | Postal code. Required by LHDN. |
| sst_registration_no | string | Optional | SST registration number. Semicolons are supported for multiple registration numbers (e.g. "A12-3456-78900001; B98-7654-32100002"). Max 50 characters. |
| tourism_tax_registration_no | string | Optional | Tourism tax registration number. |
Response fields
HTTP/1.1 201 Created
{
"status": "success",
"data": {
"organization": {
"id": "f1a2b3c4-...",
"name": "Acme Sdn Bhd",
"slug": "acme",
"tin": "C1234567890",
"brn": "202501234567",
"created_at": "2025-04-13T10:00:00Z"
},
"user": {
"id": "a1b2c3d4-...",
"email": "owner@acme.com",
"full_name": "Ahmad bin Ali"
},
"api_key": {
"client_id": "ci_abc123...",
"client_secret": "sk_def456..."
},
"warning": "Copy the client_secret now. It will not be shown again."
}
}Returns an object with three nested objects: organization, user, and api_key.
organization
| Field | Type | Description |
|---|---|---|
| id | uuid | Unique identifier of the newly created organisation. |
| name | string | Organisation display name. |
| slug | string | URL-friendly identifier used in the dashboard. |
| tin | string | Organisation TIN registered with LHDN. |
| brn | string | Business registration number. |
| created_at | datetime | Organisation creation timestamp. |
user
| Field | Type | Description |
|---|---|---|
| id | uuid | Unique identifier of the owner account. |
| string | Owner email address. | |
| full_name | string | Owner full name. |
api_key
| Field | Type | Description |
|---|---|---|
| client_id | string | Public key identifier. Use this as the first part of your Authorization header. |
| client_secret | string | Secret key. Shown once only — store it immediately. Cannot be retrieved again. |
warning string is also included in data reminding you to save the client_secret.Error codes
| HTTP Status | Description |
|---|---|
| 400 | Malformed JSON or body is not an object |
| 401 | Missing or invalid platform key |
| 403 | Platform key revoked |
| 403 | Organisation limit reached for this platform key |
| 409 | Email already registered |
| 409 | Slug already taken (when slug is explicitly provided) |
| 422 | email invalid or missing |
| 422 | password too short (min 8), too long (max 72), or missing uppercase/lowercase/digit |
| 422 | full_name missing or out of range (2–100 chars) |
| 422 | organization_name missing or out of range (2–150 chars) |
| 422 | slug invalid format |
| 422 | tin missing or invalid format (must be 1–2 uppercase letters followed by digits) |
| 422 | brn missing or contains invalid characters |
| 422 | msic_code missing or not exactly 5 digits |
| 422 | address_line1, city, state, or postal_code missing |
| 429 | Rate limit exceeded — too many requests. Retry after 60 seconds. |
| 500 | Free plan not configured (contact support) |
/api/v1/customersOrg API keyReturns a list of customers belonging to your organisation. Supports search by name or email.
Example request
# List latest 50 customers
GET /api/v1/customers
Authorization: Bearer ci_abc123...:sk_def456...
# Search by name or email
GET /api/v1/customers?search=Ahmad
Authorization: Bearer ci_abc123...:sk_def456...
# Custom limit
GET /api/v1/customers?limit=100
Authorization: Bearer ci_abc123...:sk_def456...Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| search | string | Optional | Search term. Partial match against customer name or email. Returns up to 20 results. |
| limit | integer | Optional | Number of results when not searching. Range 1–200. Default 50. |
Response fields
HTTP/1.1 200 OK
{
"status": "success",
"data": {
"customers": [
{
"id": "c1d2e3f4-...",
"name": "Ahmad bin Ali",
"email": "ahmad@example.com",
"tin": "C1234567890",
"id_type": "NRIC",
"id_value": "900101-14-1234",
"mobile_prefix_id": null,
"phone_number": null,
"tin_validation_status": "valid",
"tin_validated_at": "2025-04-13T10:00:00Z",
"tin_validation_error": null,
"tin_last_validated_tin": "C1234567890",
"tin_last_validated_id_type": "NRIC",
"tin_last_validated_id_value": "900101-14-1234",
"address_line1": "123 Jalan Bukit",
"address_line2": null,
"city": "Kuala Lumpur",
"state": "WP Kuala Lumpur",
"postal_code": "50450",
"created_at": "2025-04-13T10:00:00Z",
"updated_at": "2025-04-13T10:00:00Z"
}
]
}
}Returns customers — an array of objects. Each object contains:
| Field | Type | Description |
|---|---|---|
| id | uuid | Unique identifier of the customer. |
| name | string | Customer name. |
| string | null | Customer email address. | |
| tin | string | null | LHDN Tax Identification Number. |
| id_type | string | null | Identity document type: NRIC, BRN, PASSPORT, or ARMY. |
| id_value | string | null | Identity document number. |
| mobile_prefix_id | uuid | null | UUID of the mobile country prefix. Cross-reference with GET /api/v1/mobile-prefixes. |
| phone_number | string | null | Phone number. |
| tin_validation_status | string | null | LHDN TIN validation result. validTIN confirmed by LHDN.invalidTIN does not match the identity document.errorValidation failed — see tin_validation_error.skippedNot attempted — TIN or ID details not provided.not_checkedTIN present but not yet validated. |
| tin_validated_at | datetime | null | Timestamp of last LHDN validation. |
| tin_validation_error | string | null | Reason for validation failure. |
| tin_last_validated_tin | string | null | TIN used in last validation. |
| tin_last_validated_id_type | string | null | id_type used in last validation. |
| tin_last_validated_id_value | string | null | id_value used in last validation. |
| address_line1 | string | null | First line of address. |
| address_line2 | string | null | Second line of address. |
| city | string | null | City. |
| state | string | null | State. |
| postal_code | string | null | Postal code. |
| created_at | datetime | Creation timestamp. |
| updated_at | datetime | Last update timestamp. |
Error codes
| HTTP Status | Description |
|---|---|
| 401 | Missing or invalid API key |
| 429 | Rate limit exceeded |
/api/v1/customersOrg API keyCreates a new customer. If tin, id_type, and id_value are all provided, the TIN is automatically validated against LHDN in the background — the customer is saved immediately andtin_validation_status in the response reflects the result.
Request body
POST /api/v1/customers
Authorization: Bearer ci_abc123...:sk_def456...
Content-Type: application/json
{
"name": "Ahmad bin Ali",
"email": "ahmad@example.com",
"tin": "C1234567890",
"id_type": "NRIC",
"id_value": "900101-14-1234",
"address_line1": "123 Jalan Bukit",
"city": "Kuala Lumpur",
"state": "WP Kuala Lumpur",
"postal_code": "50450"
}| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Customer name. Max 200 characters. |
| string | Optional | Customer email address. | |
| tin | string | Optional | LHDN TIN. Max 20 characters. Must be unique within your organisation. |
| id_type | string | Optional | Identity document type: NRIC, BRN, PASSPORT, or ARMY. |
| id_value | string | Optional | Identity document number. Max 50 characters. |
| mobile_prefix_id | uuid | Optional | UUID of the mobile country prefix. Get UUIDs from GET /api/v1/mobile-prefixes ↓ |
| phone_number | string | Optional | Customer phone number. Max 20 characters. |
| address_line1 | string | Optional | First line of address. |
| address_line2 | string | Optional | Second line of address. |
| city | string | Optional | City. |
| state | string | Optional | State. |
| postal_code | string | Optional | Postal code. |
Response fields
HTTP/1.1 201 Created
{
"status": "success",
"data": {
"customer": {
"id": "c1d2e3f4-...",
"name": "Ahmad bin Ali",
"email": "ahmad@example.com",
"tin": "C1234567890",
"id_type": "NRIC",
"id_value": "900101-14-1234",
"mobile_prefix_id": null,
"phone_number": null,
"tin_validation_status": "valid",
"tin_validated_at": "2025-04-13T10:00:00Z",
"tin_validation_error": null,
"tin_last_validated_tin": "C1234567890",
"tin_last_validated_id_type": "NRIC",
"tin_last_validated_id_value": "900101-14-1234",
"address_line1": "123 Jalan Bukit",
"address_line2": null,
"city": "Kuala Lumpur",
"state": "WP Kuala Lumpur",
"postal_code": "50450",
"created_at": "2025-04-13T10:00:00Z",
"updated_at": "2025-04-13T10:00:00Z"
}
}
}| Field | Type | Description |
|---|---|---|
| id | uuid | Unique identifier of the customer. |
| name | string | Customer name. |
| string | null | Customer email address. | |
| tin | string | null | LHDN Tax Identification Number. |
| id_type | string | null | Identity document type. NRICMalaysian identity cardBRNBusiness registration numberPASSPORTPassportARMYMilitary identity card |
| id_value | string | null | Identity document number. |
| mobile_prefix_id | uuid | null | UUID of the mobile country prefix. Cross-reference with GET /api/v1/mobile-prefixes. |
| phone_number | string | null | Phone number. |
| tin_validation_status | string | null | Result of LHDN TIN validation. validLHDN confirmed the TIN matches the identity document.invalidLHDN says the TIN does not match the identity document.errorValidation could not be completed — see tin_validation_error.skippedTIN, id_type, or id_value was not provided — validation was not attempted.not_checkedTIN exists but has not been validated yet. |
| tin_validated_at | datetime | null | Timestamp of the last successful LHDN validation call. |
| tin_validation_error | string | null | Human-readable reason when tin_validation_status is error or invalid. |
| tin_last_validated_tin | string | null | The TIN value that was used in the last validation call. |
| tin_last_validated_id_type | string | null | The id_type used in the last validation call. |
| tin_last_validated_id_value | string | null | The id_value used in the last validation call. |
| address_line1 | string | null | First line of address. |
| address_line2 | string | null | Second line of address. |
| city | string | null | City. |
| state | string | null | State. |
| postal_code | string | null | Postal code. |
| created_at | datetime | Timestamp when the customer was created. |
| updated_at | datetime | Timestamp of the last update. |
Error codes
| HTTP Status | Description |
|---|---|
| 400 | Malformed JSON or body is not an object |
| 401 | Missing or invalid API key |
| 409 | Duplicate TIN — another customer in your organisation already has this TIN |
| 409 | Duplicate ID document — same id_type + id_value already exists for this organisation |
| 422 | name missing |
| 422 | name exceeds 200 characters |
| 422 | email invalid format |
| 422 | id_type invalid (must be NRIC, BRN, PASSPORT, or ARMY) |
| 422 | mobile_prefix_id is not a valid UUID |
| 422 | mobile_prefix_id UUID does not exist — call GET /api/v1/mobile-prefixes for valid IDs |
| 429 | Rate limit exceeded |
Create Invoice
/api/v1/invoicesOrg API keyCreates a new invoice. Can be saved as a draft or immediately finalized.
- If
customeris provided withtin+id_type+id_value: EasyInvois validates the TIN against LHDN. If validation fails, the invoice is rejected — fix the customer details and retry. If the TIN already exists in your organisation, the existing customer record is reused automatically. - If no customer is provided, or customer has no TIN: A
buyer_linkis returned when the invoice is finalized. Share this URL with your customer so they can fill in their TIN details. The link expires in 72 hours.
See Invoice Scenarios ↓ for example responses for each case.
Request body
POST /api/v1/invoices
Authorization: Bearer ci_abc123...:sk_def456...
Content-Type: application/json
{
"customer": {
"name": "Ahmad bin Ali",
"email": "ahmad@example.com",
"tin": "C1234567890",
"id_type": "NRIC",
"id_value": "900101-14-1234",
"phone_number": "123456789",
"address_line1": "123 Jalan Bukit",
"city": "Kuala Lumpur",
"state": "WP Kuala Lumpur",
"postal_code": "50450"
},
"items": [
{
"name": "Consulting Service",
"quantity": 1,
"unit_price": 500.00,
"tax_rate": 8,
"tax_type_code": "02",
"sku": "CS-3F7A2B"
}
],
"issue_date": "2025-04-13",
"due_date": "2025-04-27",
"reference": "PO-20250413"
}| Field | Type | Required | Description |
|---|---|---|---|
| customer_id | uuid | Optional | UUID of an existing customer. Use this OR customer — not both. |
| customer | object | Optional | Create a new customer inline instead of referencing an existing one. Use this OR customer_id — not both. If neither is provided, the invoice is created with no customer and buyer_status will be "pending" when finalized. |
| customer.name | string | Optional | Customer name. Required if providing customer object. Max 200 characters. |
| customer.email | string | Optional | Customer email address. |
| customer.tin | string | Optional | LHDN TIN. If provided together with id_type and id_value, LHDN TIN validation is performed and must pass — invoice creation is rejected if validation fails. If the TIN already exists in your organisation, the existing customer is reused. |
| customer.id_type | string | Optional | Identity document type: NRIC, BRN, PASSPORT, or ARMY. Required if tin is provided. |
| customer.id_value | string | Optional | Identity document number. Required if tin is provided. |
| customer.mobile_prefix_id | uuid | Optional | UUID of the mobile country prefix. Get UUIDs from GET /api/v1/mobile-prefixes ↓ |
| customer.phone_number | string | Optional | Phone number. Max 20 characters. |
| customer.address_line1 | string | Optional | First line of address. |
| customer.address_line2 | string | Optional | Second line of address. |
| customer.city | string | Optional | City. |
| customer.state | string | Optional | State. |
| customer.postal_code | string | Optional | Postal code. |
| invoice_status | string | Optional | "finalized" (default) or "draft". Finalized invoices are ready for LHDN submission. Use "draft" if you want to review before finalizing. |
| items | array | Required | Array of line items. 1–100 items. |
| items[].name | string | Required | Item description. Max 500 characters. |
| items[].quantity | number | Required | Quantity. Range 0.01–100,000. Max 2 decimal places. |
| items[].unit_price | number | Required | Unit price in RM. Range 0–9,999,999.99. Max 2 decimal places. |
| items[].tax_rate | number | Required | Tax percentage. Range 0–100. Max 2 decimal places. |
| items[].tax_type_code | string | Required | LHDN tax type code. Required when tax_rate > 0 — a 422 error is returned if tax_rate is non-zero and tax_type_code resolves to 06. If the SKU matches a saved product, the product's tax type is used as the default before this check. Valid values: 01 Sales Tax, 02 Service Tax, 03 Tourism Tax, 04 High-Value Goods, 05 LVG Sales Tax, 06 Not Applicable (default, only valid when tax_rate = 0), E Exempt. |
| items[].sku | string | Optional | Your internal SKU or product code. Optional — if matched, inherits the product's tax type, classification code, and unit code. |
| items[].classification_code | string | Optional | LHDN classification code. Defaults to 022 (Others). See Classification Codes ↓ |
| items[].unit_code | string | Optional | LHDN unit of measurement code. Defaults to C62 (Unit / Each). See Unit Codes ↓ |
| issue_date | string | Optional | Invoice date in YYYY-MM-DD format (Malaysia time). Defaults to today (MYT). |
| due_date | string | Optional | Payment due date in YYYY-MM-DD format (Malaysia time). Must be on or after issue_date. |
| reference | string | Optional | Your internal reference (e.g. PO number). Max 255 characters. |
Response fields
HTTP/1.1 201 Created
{
"status": "success",
"data": {
"invoice": {
"id": "i1j2k3l4-...",
"customer_id": "c1d2e3f4-...",
"invoice_number": "ACME-2025-0001",
"invoice_status": "finalized",
"lhdn_status": "not_submitted",
"delivery_status": "not_sent",
"buyer_status": "not_required",
"tin_validation_status": "valid",
"tin_validation_error": null,
"issue_date": "2025-04-13",
"due_date": "2025-04-27",
"currency_code": "MYR",
"total_amount": "500.00",
"tax_amount": "30.00",
"grand_total": "530.00",
"reference": "PO-20250413",
"buyer_info_expires_at": null,
"created_at": "2025-04-13T10:00:00Z"
}
}
}| Field | Type | Description |
|---|---|---|
| id | uuid | Unique identifier of the invoice. |
| customer_id | uuid | UUID of the customer this invoice belongs to. |
| invoice_number | string | Auto-generated invoice number (e.g. ACME-2025-0001). |
| invoice_status | string | Current invoice status. draftInvoice saved but not yet finalised. Cannot be submitted to LHDN.finalizedInvoice is finalised and ready for LHDN submission.voidInvoice has been cancelled. |
| lhdn_status | string | LHDN MyInvois submission status. not_submittedNot yet sent to LHDN.submittedSent to LHDN, awaiting validation result.validLHDN accepted and validated the invoice.invalidLHDN rejected the invoice — check lhdn_status_reason.cancellingCancellation has been submitted to LHDN and is awaiting confirmation.cancelledInvoice was cancelled via LHDN.errorSubmission failed due to a technical error. Can be resubmitted. |
| delivery_status | string | Email delivery status. not_sentEmail not sent.sentEmail delivered to customer.failedEmail delivery failed. |
| buyer_status | string | Buyer TIN collection status. not_requiredCustomer already has a validated TIN — no buyer action needed.pendingAwaiting buyer to fill in their TIN via the buyer_link.submittedBuyer has submitted their TIN details.expiredBuyer info request has expired (72 hours elapsed without submission). |
| buyer_link | string | null | URL for the buyer to fill in their TIN. Only present when buyer_status is pending. Share this link or generate a QR code from it. Expires in 72 hours. |
| tin_validation_status | string | null | Result of customer TIN validation at invoice creation. validTIN confirmed by LHDN at time of invoice creation.invalidTIN did not match — buyer_link generated instead.errorValidation call failed.skippedCustomer has no TIN — buyer_link generated instead.not_checkedDraft invoice — TIN not checked. |
| tin_validation_error | string | null | Reason when tin_validation_status is error. |
| issue_date | string | Invoice date in YYYY-MM-DD format. |
| due_date | string | null | Payment due date in YYYY-MM-DD format. |
| currency_code | string | Currency code. Always MYR. |
| total_amount | string | Pre-tax subtotal in RM (e.g. "500.00"). |
| tax_amount | string | Total tax amount in RM. |
| grand_total | string | Total including tax in RM. |
| reference | string | null | Your internal reference number (e.g. PO number). Null if not provided. |
| buyer_info_expires_at | datetime | null | When the buyer_link expires. Set to 72 hours after invoice creation when buyer_status is pending. |
| created_at | datetime | Invoice creation timestamp. |
buyer_status is "pending". You can generate a QR code from this URL and display it to your customer, or send them the link directly. It expires after 72 hours.Error codes
| HTTP Status | Description |
|---|---|
| 400 | Malformed JSON or body is not an object |
| 401 | Missing or invalid API key |
| 403 | customer_id does not belong to your organisation |
| 422 | Inline customer TIN validation failed — TIN does not match the provided id_type/id_value (LHDN rejected) |
| 502 | Inline customer TIN validation error — LHDN could not be reached or returned an error |
| 422 | Both customer_id and customer provided — use one only |
| 422 | customer.name missing or exceeds 200 characters |
| 422 | customer.email invalid format |
| 422 | customer.id_type invalid (must be NRIC, BRN, PASSPORT, or ARMY) |
| 422 | customer.mobile_prefix_id is not a valid UUID |
| 403 | Monthly invoice limit reached — upgrade your plan |
| 422 | customer_id is not a valid UUID |
| 422 | invoice_status invalid (must be "draft" or "finalized") |
| 422 | items is not an array or is empty |
| 422 | items exceeds maximum of 100 |
| 422 | items[].name missing or exceeds 500 characters |
| 422 | items[].quantity, unit_price, or tax_rate out of range or has more than 2 decimal places |
| 422 | items[].tax_type_code missing or not one of: 01, 02, 03, 04, 05, 06, E |
| 422 | items[].tax_type_code is 06 (Not Applicable) but tax_rate > 0 — must specify a valid tax type |
| 422 | Grand total exceeds RM 99,999,999.99 |
| 422 | issue_date or due_date invalid (must be YYYY-MM-DD) |
| 422 | due_date is before issue_date |
| 429 | Rate limit exceeded |
Get Invoice
/api/v1/invoices/:idOrg API keyReturns the current status and full metadata of a single invoice by its UUID. Use this endpoint to poll for LHDN submission status after submitting an invoice.
Example request
GET /api/v1/invoices/i1j2k3l4-...
Authorization: Bearer ci_abc123...:sk_def456...Response fields
HTTP/1.1 200 OK
{
"status": "success",
"data": {
"invoice": {
"id": "i1j2k3l4-...",
"customer_id": "c1d2e3f4-...",
"invoice_number": "ACME-2025-0001",
"invoice_type_code": "01",
"original_invoice_id": null,
"invoice_status": "finalized",
"lhdn_status": "valid",
"lhdn_status_reason": null,
"cancellation_reason": null,
"lhdn_submission_uid": "sub_abc123...",
"lhdn_uuid": "lhdn_uuid_...",
"lhdn_long_id": "lhdn_long_id_...",
"lhdn_date_time_received": "2025-04-13T10:01:00Z",
"lhdn_date_time_validated": "2025-04-13T10:02:00Z",
"lhdn_cancelled_at": null,
"delivery_status": "sent",
"sent_at": null,
"buyer_status": "not_required",
"buyer_info_expires_at": null,
"tin_validation_status": "valid",
"tin_validation_error": null,
"paid_at": null,
"issue_date": "2025-04-13",
"due_date": "2025-04-27",
"currency_code": "MYR",
"total_amount": "500.00",
"tax_amount": "30.00",
"grand_total": "530.00",
"reference": "PO-20250413",
"created_at": "2025-04-13T10:00:00Z"
}
}
}| Field | Type | Description |
|---|---|---|
| id | uuid | Unique identifier of the invoice. |
| customer_id | uuid | null | UUID of the associated customer. |
| invoice_number | string | Human-readable invoice number (e.g. ACME-2025-0001). |
| invoice_type_code | string | LHDN invoice type. 01 = Invoice, 02 = Credit Note, 03 = Debit Note. |
| original_invoice_id | uuid | null | For credit/debit notes, the UUID of the original invoice. |
| invoice_status | string | Invoice lifecycle status. draftSaved but not finalised. Cannot be submitted to LHDN.finalizedFinalised and ready for LHDN submission.voidCancelled. |
| lhdn_status | string | LHDN MyInvois submission status. not_submittedNot yet sent to LHDN.submittedSent, awaiting LHDN validation.validAccepted and validated by LHDN.invalidRejected by LHDN — see lhdn_status_reason.cancellingCancellation has been submitted to LHDN and is awaiting confirmation.cancelledCancelled via LHDN.errorSubmission failed due to a technical error. Can be resubmitted. |
| lhdn_status_reason | string | null | Reason from LHDN when status is invalid or cancelled. |
| cancellation_reason | string | null | Reason provided by the user when the invoice was cancelled via the MyInvois API. Null if not cancelled or no reason given. |
| lhdn_submission_uid | string | null | MyInvois submission UID. Available after submission. |
| lhdn_uuid | string | null | MyInvois document UUID. Available after validation. |
| lhdn_long_id | string | null | MyInvois long ID. Use with lhdn_uuid to construct the QR verification URL. |
| lhdn_date_time_received | datetime | null | When MyInvois received the document. |
| lhdn_date_time_validated | datetime | null | When MyInvois validated the document. |
| lhdn_cancelled_at | datetime | null | When the invoice was cancelled at LHDN. |
| delivery_status | string | Email delivery status. not_sentEmail not sent.sentEmail delivered.failedEmail delivery failed. |
| buyer_status | string | Buyer TIN collection status. not_requiredNo buyer action needed — customer has a validated TIN.pendingWaiting for buyer to submit their TIN via buyer_link.submittedBuyer has submitted their TIN.expiredBuyer info request has expired (72 hours elapsed without submission). |
| buyer_link | string | null | URL for buyer to fill in TIN. Only present when buyer_status is pending. |
| buyer_info_expires_at | datetime | null | When the buyer_link expires (72 hours after finalization). Only set when buyer_status is pending. |
| tin_validation_status | string | null | Customer TIN validation result. validTIN confirmed by LHDN.invalidTIN did not match the identity document.errorValidation failed — see tin_validation_error.skippedNo TIN provided — not validated.not_checkedTIN present but not yet validated. |
| tin_validation_error | string | null | Reason when tin_validation_status is error. |
| paid_at | datetime | null | When the invoice was marked as paid. |
| issue_date | string | Invoice date (YYYY-MM-DD). |
| due_date | string | null | Payment due date (YYYY-MM-DD). |
| currency_code | string | Currency code. Always MYR. |
| total_amount | string | Pre-tax subtotal in RM (e.g. "500.00"). |
| tax_amount | string | Total tax in RM. |
| grand_total | string | Total including tax in RM. |
| reference | string | null | Your internal reference. |
| sent_at | string | null | ISO 8601 timestamp when the invoice was emailed to the customer. null if not yet sent. |
| created_at | datetime | Invoice creation timestamp. |
Error codes
| HTTP Status | Description |
|---|---|
| 401 | Missing or invalid API key |
| 404 | Invoice not found or does not belong to your organisation |
| 422 | id is not a valid UUID |
| 429 | Rate limit exceeded |
Finalize Invoice
/api/v1/invoices/:idOrg API keyFinalizes a draft invoice. Once finalized, TIN validation runs against LHDN and the invoice is ready for submission. If the customer has no TIN, buyer_link is returned.
draft invoices can be finalized. Finalized and voided invoices cannot be changed.Request body
PATCH /api/v1/invoices/i1j2k3l4-...
Authorization: Bearer ci_abc123...:sk_def456...
Content-Type: application/json
{
"invoice_status": "finalized"
}| Field | Type | Required | Description |
|---|---|---|---|
| invoice_status | string | Required | Must be "finalized". This is the only accepted value. |
Response fields
Returns the same fields as Get Invoice.
HTTP/1.1 200 OK
{
"status": "success",
"data": {
"invoice": {
"id": "i1j2k3l4-...",
"customer_id": "c1d2e3f4-...",
"invoice_number": "ACME-2025-0001",
"invoice_type_code": "01",
"original_invoice_id": null,
"invoice_status": "finalized",
"lhdn_status": "not_submitted",
"lhdn_status_reason": null,
"lhdn_submission_uid": null,
"lhdn_uuid": null,
"lhdn_long_id": null,
"lhdn_date_time_received": null,
"lhdn_date_time_validated": null,
"lhdn_cancelled_at": null,
"delivery_status": "not_sent",
"sent_at": null,
"buyer_status": "not_required",
"buyer_info_expires_at": null,
"tin_validation_status": "valid",
"tin_validation_error": null,
"paid_at": null,
"issue_date": "2025-04-13",
"due_date": null,
"currency_code": "MYR",
"total_amount": "500.00",
"tax_amount": "30.00",
"grand_total": "530.00",
"reference": null,
"created_at": "2025-04-13T10:00:00Z"
}
}
}Error codes
| HTTP Status | Description |
|---|---|
| 400 | Malformed JSON or body is not an object |
| 401 | Missing or invalid API key |
| 404 | Invoice not found or does not belong to your organisation |
| 422 | id is not a valid UUID |
| 422 | invoice_status is not "finalized" |
| 422 | Invoice is already finalized or voided |
| 429 | Rate limit exceeded |
Query Invoices
/api/v1/invoices/queryOrg API keyFetches up to 50 invoices by their UUIDs in a single request. Use this for bulk status polling. Invoices not found or not belonging to your organisation are silently omitted. Results are returned in the same order as the input IDs.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| ids | string[] | Required | Array of invoice UUIDs. 1–50 entries. Duplicates are deduplicated. |
Example request
POST /api/v1/invoices/query
Authorization: Bearer ci_abc123...:sk_def456...
Content-Type: application/json
{
"ids": [
"i1j2k3l4-...",
"m5n6o7p8-..."
]
}Response fields
Returns invoices — an array of full invoice objects, each containing the same fields as Get Invoice. Invoices not found or not belonging to your organisation are silently omitted.
HTTP/1.1 200 OK
{
"status": "success",
"data": {
"invoices": [
{
"id": "i1j2k3l4-...",
"customer_id": "c1d2e3f4-...",
"invoice_number": "ACME-2025-0001",
"invoice_type_code": "01",
"original_invoice_id": null,
"invoice_status": "finalized",
"lhdn_status": "valid",
"lhdn_status_reason": null,
"lhdn_submission_uid": "sub_abc123...",
"lhdn_uuid": "lhdn_uuid_...",
"lhdn_long_id": "lhdn_long_id_...",
"lhdn_date_time_received": "2025-04-13T10:01:00Z",
"lhdn_date_time_validated": "2025-04-13T10:02:00Z",
"lhdn_cancelled_at": null,
"delivery_status": "sent",
"sent_at": null,
"buyer_status": "not_required",
"buyer_info_expires_at": null,
"tin_validation_status": "valid",
"tin_validation_error": null,
"paid_at": null,
"issue_date": "2025-04-13",
"due_date": "2025-04-27",
"currency_code": "MYR",
"total_amount": "500.00",
"tax_amount": "30.00",
"grand_total": "530.00",
"reference": "PO-20250413",
"created_at": "2025-04-13T10:00:00Z"
}
]
}
}Error codes
| HTTP Status | Description |
|---|---|
| 400 | Malformed JSON or body is not an object |
| 401 | Missing or invalid API key |
| 422 | ids missing, empty, or contains no valid UUIDs |
| 422 | ids exceeds maximum of 50 unique IDs |
| 429 | Rate limit exceeded |
Submit Invoices
/api/v1/invoices/submitOrg API keySubmits one or more finalized invoices to LHDN MyInvois in a single batch. After a successful call the invoices move to lhdn_status = "submitted" — LHDN validates asynchronously. Poll GET /api/v1/invoices/:id or listen to theinvoice.lhdn_status_changed webhook for the final result.
POST /api/v1/invoices/consolidate/:id/submit instead. Child invoices that have been added to a consolidated invoice also cannot be submitted individually — withdraw them first.Request body
| Field | Type | Required | Description |
|---|---|---|---|
| invoice_ids | uuid[] | Required | Array of invoice UUIDs to submit. Pass one UUID to submit a single invoice, or multiple to submit in a batch. Maximum 100 per request. Duplicates are ignored. |
Example request
POST /api/v1/invoices/submit
Authorization: Bearer ci_abc123...:sk_def456...
Content-Type: application/json
// Single invoice
{ "invoice_ids": ["i1j2k3l4-..."] }
// Multiple invoices
{ "invoice_ids": ["i1j2k3l4-...", "i5j6k7l8-..."] }Response fields
HTTP/1.1 202 Accepted
{
"status": "success",
"data": {
"status": "success",
"submitted": [
{ "id": "2ea78328-...", "invoice_number": "ACME-2026-0001", "lhdn_status": "submitted" },
{ "id": "58a7ec3f-...", "invoice_number": "ACME-2026-0002", "lhdn_status": "submitted" }
],
"failed": [],
"submission_id": "s1u2b3m4-...",
"submission_uid": "LHDN-UID-12345"
}
}When some invoices fail pre-validation and others succeed, status is "partial". When all fail, status is "failed" and the HTTP status is 422.
| Field | Type | Description |
|---|---|---|
| status | string | "success" — all submitted. "partial" — some submitted, some failed. "failed" — none submitted (HTTP 422). |
| submitted | array | Invoices accepted by LHDN. LHDN validates asynchronously — poll GET /api/v1/invoices/:id for the final valid or invalid result. |
| submitted[].id | uuid | Invoice UUID. |
| submitted[].invoice_number | string | Invoice number. |
| submitted[].lhdn_status | string | Always "submitted" at this point. |
| failed | array | Invoices that could not be submitted — either failed pre-validation or were rejected by LHDN. |
| failed[].id | uuid | Invoice UUID. |
| failed[].invoice_number | string | Invoice number. |
| failed[].reason | string | Why this invoice was not submitted. |
| submission_id | uuid | null | Internal EasyInvois submission UUID. Present when at least one invoice was submitted. |
| submission_uid | string | null | LHDN submission UID. Present when at least one invoice was submitted. |
Error codes
| HTTP Status | Description |
|---|---|
| 400 | Malformed JSON or body is not an object |
| 401 | Missing or invalid API key |
| 404 | One or more invoice IDs not found or do not belong to your organisation |
| 409 | A submission is already in progress for one or more of these invoices — wait and retry |
| 422 | invoice_ids missing, empty, or all values are invalid UUIDs |
| 422 | Batch exceeds 100 invoices after deduplication |
| 422 | All invoices failed — see failed[] array in response body for per-invoice reasons |
| 422 | Organisation TIN or BRN not configured |
| 422 | Document build failed — invoice data is malformed |
| 429 | Rate limit exceeded (60 requests per minute) |
| 502 | LHDN upstream error |
Submit Single Invoice
/api/v1/invoices/:id/submitOrg API keySubmits a single invoice to LHDN by passing its UUID in the URL path. No request body required. Handles regular invoices (type 01) as well as credit notes (type 02) and debit notes (type 03) — for CN/DN the document is submitted to LHDN as an adjustment document. Explicitly rejects consolidated invoices — use POST /api/v1/invoices/consolidate/:id/submit for those.
Example request
POST /api/v1/invoices/i1j2k3l4-.../submit
Authorization: Bearer ci_abc123...:sk_def456...Response fields
HTTP/1.1 202 Accepted
{
"status": "success",
"data": {
"submission_id": "s1u2b3m4-...",
"submission_uid": "LHDN-UID-12345",
"invoice_count": 1,
"lhdn_status": "submitted"
}
}| Field | Type | Description |
|---|---|---|
| submission_id | uuid | Internal EasyInvois submission UUID. |
| submission_uid | string | LHDN submission UID returned by MyInvois. |
| invoice_count | number | Always 1 for this endpoint. |
| lhdn_status | string | Always "submitted" on success. Poll GET /api/v1/invoices/:id for the final valid or invalid result. |
Error codes
| HTTP Status | Description |
|---|---|
| 401 | Missing or invalid API key |
| 404 | Invoice not found or does not belong to your organisation |
| 409 | A submission is already in progress for this invoice — wait and retry |
| 422 | id is not a valid UUID |
| 422 | Invoice is not finalized (invoice_status must be "finalized") |
| 422 | Invoice has already been submitted to LHDN |
| 422 | Invoice is a consolidated invoice — use POST /api/v1/invoices/consolidate/:id/submit instead |
| 422 | Invoice belongs to a consolidated invoice — withdraw it from the consolidated invoice first |
| 422 | Customer has an unvalidated TIN |
| 422 | Organisation TIN or BRN not configured |
| 422 | Document build failed — invoice data is malformed |
| 422 | LHDN rejected the submission |
| 429 | Rate limit exceeded (60 requests per minute) |
| 502 | LHDN upstream error |
Consolidate Invoices
/api/v1/invoices/consolidateOrg API keyGroups multiple finalized invoices into a single consolidated invoice for LHDN submission. Consolidated invoices are used for B2C transactions where the buyer has no TIN — instead of submitting each invoice individually, you batch them into one document.
Each original invoice becomes one line item in the consolidated document: the line description is the original invoice number, and the unit price is the original grand total. The original invoices remain visible and are linked to the consolidated invoice — they cannot be submitted individually until withdrawn.
invoice_ids must satisfy all of the following:- invoice_status is
finalized - lhdn_status is
not_submitted,invalid, orerror - Not already part of another consolidated invoice
- Customer has no TIN (B2C only — invoices with a validated buyer TIN are not eligible)
- Same currency as all other invoices in the batch
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| invoice_ids | uuid[] | Required | Array of invoice UUIDs to consolidate. Minimum 1. All must belong to your organisation and satisfy the eligibility rules above. |
Example request
POST /api/v1/invoices/consolidate
Authorization: Bearer ci_abc123...:sk_def456...
Content-Type: application/json
{
"invoice_ids": [
"i1j2k3l4-...",
"i5j6k7l8-...",
"i9j0k1l2-..."
]
}Response fields
HTTP/1.1 200 OK
{
"status": "success",
"data": {
"consolidated_invoice_id": "c1o2n3s4-...",
"consolidated_invoice_number": "CONS-2025-0001",
"original_invoice_count": 3
}
}| Field | Type | Description |
|---|---|---|
| consolidated_invoice_id | uuid | UUID of the newly created consolidated invoice. Use this with POST /api/v1/invoices/consolidate/:id/submit to submit to LHDN. |
| consolidated_invoice_number | string | Auto-generated consolidated invoice number in the format CONS-YYYY-NNNN (e.g. CONS-2025-0001). Separate sequence from regular invoices. |
| original_invoice_count | number | Number of original invoices grouped into this consolidated invoice. |
Error codes
| HTTP Status | Description |
|---|---|
| 400 | Malformed JSON or body is not an object |
| 401 | Missing or invalid API key |
| 404 | One or more invoice IDs not found or do not belong to your organisation |
| 422 | invoice_ids is missing or empty |
| 422 | One or more invoice_ids is not a valid UUID |
| 422 | One or more invoices is not finalized |
| 422 | One or more invoices has already been submitted to LHDN |
| 422 | One or more invoices is already part of another consolidated invoice |
| 422 | One or more invoices has a customer with a TIN — only invoices without a buyer TIN can be consolidated |
| 422 | Invoices have mixed currencies — all must share the same currency |
| 429 | Rate limit exceeded (60 requests per minute) |
Submit Consolidated Invoice
/api/v1/invoices/consolidate/:id/submitOrg API keySubmits a consolidated invoice to LHDN. Only accepts a consolidated invoice — regular invoices are rejected. After a successful call the consolidated invoice moves to lhdn_status = "submitted". Poll GET /api/v1/invoices/:id or listen to the invoice.lhdn_status_changed webhook for the final result.
No request body required — the consolidated invoice ID is passed in the URL path.
Example request
POST /api/v1/invoices/consolidate/c1o2n3s4-.../submit
Authorization: Bearer ci_abc123...:sk_def456...Response fields
HTTP/1.1 202 Accepted
{
"status": "success",
"data": {
"submission_id": "s1u2b3m4-...",
"submission_uid": "LHDN-UID-12345",
"invoice_count": 1,
"lhdn_status": "submitted"
}
}| Field | Type | Description |
|---|---|---|
| submission_id | uuid | Internal EasyInvois submission UUID. |
| submission_uid | string | LHDN submission UID returned by MyInvois. |
| invoice_count | number | Always 1 — one consolidated document submitted. |
| lhdn_status | string | Always "submitted" on success. Poll GET /api/v1/invoices/:id for the final valid or invalid result. |
Error codes
| HTTP Status | Description |
|---|---|
| 401 | Missing or invalid API key |
| 404 | Consolidated invoice not found or does not belong to your organisation |
| 409 | A submission is already in progress for this invoice — wait and retry |
| 422 | id is not a valid UUID |
| 422 | Invoice is not a consolidated invoice — use POST /api/v1/invoices/:id/submit or POST /api/v1/invoices/submit instead |
| 422 | Consolidated invoice has already been submitted to LHDN |
| 422 | Organisation TIN or BRN not configured |
| 422 | Document build failed — consolidated invoice data is malformed |
| 422 | LHDN rejected the submission |
| 429 | Rate limit exceeded (60 requests per minute) |
| 502 | LHDN upstream error |
Create Credit Note
/api/v1/invoices/:id/credit-noteOrg API keyCreates a Credit Note (LHDN type 02) against an existing LHDN-validated invoice. The credit note is created as finalized and pre-filled with all line items from the original invoice. Call POST /api/v1/invoices/:cn_id/submit to submit it to LHDN immediately.
The original invoice must have lhdn_status = "valid" and must not be voided. Self-billed invoices (type 11) are not supported.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
| id | uuid | Required | EasyInvois UUID of the original invoice to credit. This is the id field returned when you created the invoice — not the LHDN UUID. |
Example request
POST /api/v1/invoices/8f3a1c2d-.../credit-note
Authorization: Bearer ci_live_...:cs_live_...Response fields
HTTP 201 — returns the newly created credit note.
| Field | Type | Description |
|---|---|---|
| id | uuid | UUID of the newly created credit note. |
| invoice_number | string | Auto-generated credit note number (e.g. CN-2025-0001). |
Error codes
| HTTP Status | Description |
|---|---|
| 401 | Missing or invalid API key |
| 404 | Original invoice not found or does not belong to your organisation |
| 422 | id in URL is not a valid UUID |
| 422 | Original invoice is not LHDN-validated (lhdn_status must be "valid") |
| 422 | Original invoice is voided |
| 422 | Original invoice has no LHDN UUID |
| 422 | Original invoice has no line items |
| 422 | Self-billed invoice adjustments are not supported |
| 429 | Rate limit exceeded (60 requests per minute) |
Create Debit Note
/api/v1/invoices/:id/debit-noteOrg API keyCreates a Debit Note (LHDN type 03) against an existing LHDN-validated invoice. Identical behaviour to Create Credit Note above, but issued when additional charges need to be applied to the buyer. The debit note is created as finalized and pre-filled with all line items from the original invoice. Call POST /api/v1/invoices/:dn_id/submit to submit it to LHDN immediately.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
| id | uuid | Required | EasyInvois UUID of the original invoice to debit. This is the id field returned when you created the invoice — not the LHDN UUID. |
Example request
POST /api/v1/invoices/8f3a1c2d-.../debit-note
Authorization: Bearer ci_live_...:cs_live_...Response fields
HTTP 201 — returns the newly created debit note.
| Field | Type | Description |
|---|---|---|
| id | uuid | UUID of the newly created debit note. |
| invoice_number | string | Auto-generated debit note number (e.g. DN-2025-0001). |
Error codes
| HTTP Status | Description |
|---|---|
| 401 | Missing or invalid API key |
| 404 | Original invoice not found or does not belong to your organisation |
| 422 | id in URL is not a valid UUID |
| 422 | Original invoice is not LHDN-validated (lhdn_status must be "valid") |
| 422 | Original invoice is voided |
| 422 | Original invoice has no LHDN UUID |
| 422 | Original invoice has no line items |
| 422 | Self-billed invoice adjustments are not supported |
| 429 | Rate limit exceeded (60 requests per minute) |
Cancel Invoice
/api/v1/invoices/:id/cancelOrg API keyCancels an invoice. Behaviour depends on the invoice's LHDN status:
- Not submitted to LHDN (
lhdn_status: "not_submitted") — voided locally immediately. No LHDN call is made. - LHDN-validated (
lhdn_status: "valid") — cancels via the MyInvois API. LHDN only allows cancellation within 72 hours of validation.
Request body
POST /api/v1/invoices/i1j2k3l4-.../cancel
Authorization: Bearer ci_abc123...:sk_def456...
Content-Type: application/json
{
"reason": "Wrong amount entered"
}| Field | Type | Required | Description |
|---|---|---|---|
| reason | string | Required | Reason for cancellation. Sent to LHDN when cancelling a validated invoice. Max 255 characters. |
Response fields
HTTP/1.1 200 OK
{
"status": "success",
"data": {
"invoice": {
"id": "i1j2k3l4-...",
"invoice_number": "ACME-2025-0001",
"invoice_status": "void",
"lhdn_status": "cancelled",
"cancellation_reason": "Wrong amount entered",
"lhdn_cancelled_at": "2025-04-13T12:00:00Z"
}
}
}Returns invoice with the updated fields after cancellation.
| Field | Type | Description |
|---|---|---|
| id | uuid | Unique identifier of the invoice. |
| invoice_number | string | Human-readable invoice number. |
| invoice_status | string | Always void after successful cancellation. voidInvoice has been cancelled. |
| lhdn_status | string | LHDN status after cancellation. cancelledLHDN confirmed the cancellation (was previously valid).not_submittedInvoice was voided locally without submitting to LHDN. |
| cancellation_reason | string | null | The cancellation reason provided in the request body, if any. |
| lhdn_cancelled_at | datetime | null | Timestamp when LHDN recorded the cancellation. Null if not submitted to LHDN. |
Error codes
| HTTP Status | Description |
|---|---|
| 400 | Malformed JSON or body is not an object |
| 401 | Missing or invalid API key |
| 404 | Invoice not found or does not belong to your organisation |
| 409 | Invoice status changed during the request — retry |
| 409 | Cancellation in progress — a cancellation is already in progress for this invoice |
| 422 | id is not a valid UUID |
| 422 | reason is required |
| 422 | reason exceeds 255 characters |
| 422 | Invoice is already voided |
| 422 | Invoice lhdn_status cannot be cancelled (must be "valid" or "not_submitted") |
| 422 | Cancellation window expired (72 hours from LHDN validation) |
| 422 | Organisation TIN not configured |
| 422 | Missing LHDN UUID — the invoice does not have an LHDN UUID |
| 422 | Missing validation timestamp — the invoice does not have a validation timestamp |
| 429 | Rate limit exceeded |
| 502 | LHDN returned an error during cancellation |
Invoice Scenarios
Common scenarios when creating invoices via POST /api/v1/invoices.
1. Customer with valid TIN — invoice ready to submit
When you pass tin + id_type + id_value and LHDN validates successfully, the invoice is immediately ready for LHDN submission.
// Response — HTTP 201
{
"status": "success",
"data": {
"invoice": {
"invoice_number": "ACME-2026-0001",
"invoice_status": "finalized",
"lhdn_status": "not_submitted",
"buyer_status": "not_required",
"tin_validation_status": "valid",
"tin_validation_error": null,
"buyer_link": null
}
}
}2. No customer provided — buyer fills in TIN via link
When no customer is provided (or customer has no TIN), a buyer_link is returned. Share this URL with your customer so they can fill in their TIN. The link expires in 72 hours.
// Response — HTTP 201
{
"status": "success",
"data": {
"invoice": {
"invoice_number": "ACME-2026-0002",
"invoice_status": "finalized",
"lhdn_status": "not_submitted",
"buyer_status": "pending",
"tin_validation_status": "skipped",
"tin_validation_error": null,
"buyer_link": "https://app.easyinvoisapp.com/buyer/a1b2c3...",
"buyer_info_expires_at": "2026-06-25T10:00:00Z"
}
}
}3. TIN does not match identity document — invoice rejected
When the TIN and id_type/id_value combination is rejected by LHDN (e.g. NRIC number doesn't match the TIN), the invoice is not created. Fix the customer details and retry.
// Response — HTTP 422
{
"status": "error",
"title": "Invalid customer TIN",
"description": "The customer TIN does not match the provided identity document."
}4. TIN already exists — existing customer reused
If the TIN you provide already belongs to a customer in your organisation, that customer is reused automatically. No duplicate is created.
// Response — HTTP 201
{
"status": "success",
"data": {
"invoice": {
"invoice_number": "ACME-2026-0003",
"customer_id": "c1d2e3f4-...",
"invoice_status": "finalized",
"lhdn_status": "not_submitted",
"buyer_status": "not_required",
"tin_validation_status": "valid",
"tin_validation_error": null
}
}
}5. LHDN credentials error — invoice rejected
If your MyInvois intermediary credentials are invalid or not authorised for the customer's TIN, LHDN validation fails and the invoice is not created.
// Response — HTTP 502
{
"status": "error",
"title": "TIN validation failed",
"description": "LHDN rejected your intermediary credentials. Please check your MyInvois Client ID and Client Secret in Organisation Settings."
}Webhooks
Webhooks
EasyInvois can push real-time event notifications to your server whenever an invoice status changes. Instead of polling GET /api/v1/invoices/:id, register a webhook URL and EasyInvois will POST a signed JSON payload to it automatically.
There are two webhook scopes — you may use one or both simultaneously:
Organisation webhook
Fires for invoices belonging to your organisation. Configure your webhook URL in the dashboard settings using your organisation API key.
Platform webhook
Fires for all organisations under your platform. Configure your platform webhook URL in the dashboard settings using your platform key.
whs_...). Store it securely — it is required to verify that incoming webhook requests genuinely came from EasyInvois. If the secret is compromised, contact us to rotate it.Events & Payloads
Every webhook POST uses the same outer envelope structure. The data object contains the event-specific fields and differs per event type.
Envelope fields (all events)
| Field | Type | Description |
|---|---|---|
| event | string | Event type identifier. Tells you what happened. See event types below. |
| created_at | datetime | ISO 8601 UTC timestamp of when the event was dispatched by EasyInvois. |
| data | object | Event-specific payload. Fields inside vary by event type — see each event below. |
invoice.lhdn_status_changed
Fired whenever LHDN updates the status of an invoice — when it is accepted (valid), rejected (invalid), or cancelled (cancelled). The data fields present depend on which status was set — see the table below.
| Field | Type | Present when | Description |
|---|---|---|---|
| invoice_id | uuid | always | UUID of the invoice whose LHDN status changed. |
| invoice_number | string | always | Human-readable invoice number (e.g. ACME-2025-0001). |
| organization_id | uuid | always | UUID of the organisation that owns the invoice. |
| lhdn_status | string | always | New LHDN status. One of: "valid", "invalid", "cancelled". |
| lhdn_uuid | string | valid only | Unique document UUID assigned by LHDN upon validation. Use this to look up the invoice on the MyInvois portal. |
| lhdn_long_id | string | valid only | LHDN long ID string — a human-readable reference for the validated document on the MyInvois portal. |
| lhdn_date_time_validated | datetime | valid only | ISO 8601 UTC timestamp of when LHDN validated the invoice. The 72-hour cancellation window starts from this time. |
| lhdn_status_reason | string | invalid only | LHDN rejection reason returned by MyInvois when the invoice is invalid. |
| cancellation_reason | string | cancelled only | The reason provided by the user when the invoice was cancelled via the MyInvois API. |
| lhdn_cancelled_at | datetime | cancelled only | ISO 8601 UTC timestamp of when the invoice was cancelled. |
// Example: lhdn_status = "valid"
{
"event": "invoice.lhdn_status_changed",
"created_at": "2025-04-13T10:30:45.123Z",
"data": {
"invoice_id": "i1j2k3l4-...",
"invoice_number": "ACME-2025-0001",
"organization_id": "o9p8q7r6-...",
"lhdn_status": "valid",
"lhdn_uuid": "f1e2d3c4-...",
"lhdn_long_id": "LHDN_LONG_ID_STRING",
"lhdn_date_time_validated": "2025-04-13T10:30:00.000Z"
}
}
// Example: lhdn_status = "invalid"
{
"event": "invoice.lhdn_status_changed",
"created_at": "2025-04-13T10:31:00.000Z",
"data": {
"invoice_id": "i1j2k3l4-...",
"invoice_number": "ACME-2025-0001",
"organization_id": "o9p8q7r6-...",
"lhdn_status": "invalid",
"lhdn_status_reason": "TIN does not match the provided ID document"
}
}
// Example: lhdn_status = "cancelled"
{
"event": "invoice.lhdn_status_changed",
"created_at": "2025-04-13T10:35:00.000Z",
"data": {
"invoice_id": "i1j2k3l4-...",
"invoice_number": "ACME-2025-0001",
"organization_id": "o9p8q7r6-...",
"lhdn_status": "cancelled",
"cancellation_reason": "Wrong amount entered",
"lhdn_cancelled_at": "2025-04-13T10:35:00.000Z"
}
}invoice.buyer_submitted
Fired when a buyer successfully submits their identity (TIN + ID document) via the buyer link and LHDN validates their TIN. Use this event to know when it is safe to finalize and submit the invoice to LHDN — the buyer's TIN is now confirmed and linked to the invoice.
| Field | Type | Description |
|---|---|---|
| invoice_id | uuid | UUID of the invoice that the buyer submitted their details for. |
| invoice_number | string | Human-readable invoice number (e.g. ACME-2025-0001). |
| organization_id | uuid | UUID of the organisation that issued the invoice. |
| buyer_status | string | Always "submitted". Confirms the buyer has completed the TIN verification step. |
| id_type | string | Type of identity document the buyer verified with. One of: "NRIC", "BRN", "PASSPORT", "ARMY". |
{
"event": "invoice.buyer_submitted",
"created_at": "2025-04-13T10:28:00.000Z",
"data": {
"invoice_id": "i1j2k3l4-...",
"invoice_number": "ACME-2025-0001",
"organization_id": "o9p8q7r6-...",
"buyer_status": "submitted",
"id_type": "NRIC"
}
}GET /api/v1/invoices/:id — the invoice will have a linked customer record with the validated information.Signature Verification
Every webhook request is signed with HMAC-SHA256 using your webhook secret. Always verify the signature before processing the payload to ensure the request came from EasyInvois and was not tampered with.
Request headers
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-EasyInvois-Event | Event type, e.g. invoice.lhdn_status_changed |
| X-EasyInvois-Timestamp | Unix timestamp in seconds (integer as string) |
| X-EasyInvois-Signature | sha256=<hex digest> |
How to verify
Compute HMAC-SHA256(secret, "{timestamp}.{rawBody}") and compare the hex digest to the value in X-EasyInvois-Signature (after stripping the sha256= prefix). Also reject requests where the timestamp is more than 5 minutes old to prevent replay attacks.
// Node.js / TypeScript example
import { createHmac, timingSafeEqual } from 'crypto'
function verifyWebhook(
rawBody: string, // raw request body string (do NOT parse first)
timestamp: string, // X-EasyInvois-Timestamp header
signature: string, // X-EasyInvois-Signature header
secret: string, // your webhook_secret (whs_...)
): boolean {
// 1. Reject stale requests (> 5 minutes old)
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (age > 300) return false
// 2. Compute expected signature
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
// 3. Constant-time comparison
const sigHex = signature.replace(/^sha256=/, '')
return timingSafeEqual(Buffer.from(sigHex), Buffer.from(expected))
}Delivery & Retries
EasyInvois attempts delivery immediately after an event fires. If your endpoint does not respond with HTTP 2xx within 10 seconds, the delivery is retried with exponential backoff up to 4 times before being abandoned.
HTTP 4xx responses are treated as permanent failures and are not retried — they indicate a configuration problem on your side (wrong URL, auth failure, etc.). HTTP 5xx responses and timeouts are retried.
Retry schedule
| Attempt | Delay after failure |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 10 minutes |
| 3rd retry | 1 hour |
| 4th retry | 6 hours |
| Abandoned | No further attempts |
invoice_id and event type more than once if earlier attempts timed out. Use the invoice_id + event combination to deduplicate processing on your side.Reference
/api/v1/mobile-prefixesNo auth requiredReturns all supported mobile country prefixes. Use the id from this response as the mobile_prefix_id when creating customers. No authentication is required — call this once and cache the result.
HTTP/1.1 200 OK
{
"status": "success",
"data": {
"mobile_prefixes": [
{
"id": "uuid-...",
"country_code": "MY",
"dial_code": "+60",
"country_name": "Malaysia"
},
{
"id": "uuid-...",
"country_code": "SG",
"dial_code": "+65",
"country_name": "Singapore"
}
]
}
}Supported countries:
| Country | country_code | dial_code |
|---|---|---|
| Malaysia | MY | +60 |
| Singapore | SG | +65 |
| Brunei | BN | +673 |
| Indonesia | ID | +62 |
| Thailand | TH | +66 |
| Philippines | PH | +63 |
| Vietnam | VN | +84 |
| Myanmar | MM | +95 |
| China | CN | +86 |
| Hong Kong | HK | +852 |
| Taiwan | TW | +886 |
| Japan | JP | +81 |
| South Korea | KR | +82 |
| India | IN | +91 |
| Australia | AU | +61 |
| New Zealand | NZ | +64 |
| United States | US | +1 |
| United Kingdom | GB | +44 |
| UAE | AE | +971 |
| Saudi Arabia | SA | +966 |
| Pakistan | PK | +92 |
| Bangladesh | BD | +880 |
id (UUID) for each prefix differs between production and staging environments. Always call this endpoint to get the correct UUIDs — do not hardcode them.Classification Codes
LHDN MyInvois classification codes for items[].classification_code. Default is 022 (Others).
| Code | Description |
|---|---|
| 001 | Breastfeeding equipment |
| 002 | Child care centres and kindergartens fees |
| 003 | Computer, smartphone or tablet |
| 004 | Consolidated e-Invoice |
| 005 | Construction materials |
| 006 | Disbursement |
| 007 | Donation |
| 008 | e-Commerce - e-Invoice to buyer / purchaser |
| 009 | e-Commerce - Self-billed e-Invoice to seller, logistics, etc. |
| 010 | Education fees |
| 011 | Goods on consignment (Consignor) |
| 012 | Goods on consignment (Consignee) |
| 013 | Gym membership |
| 014 | Insurance - Education and medical benefits |
| 015 | Insurance - Takaful or life insurance |
| 016 | Interest and financing expenses |
| 017 | Internet subscription |
| 018 | Land and building |
| 019 | Medical examination for learning disabilities and early intervention or rehabilitation treatments of learning disabilities |
| 020 | Medical examination or vaccination expenses |
| 021 | Medical expenses for serious diseases |
| 022 | Others (default) |
| 023 | Petroleum operations |
| 024 | Private retirement scheme or deferred annuity scheme |
| 025 | Motor vehicle |
| 026 | Subscription of books / journals / magazines / newspapers / other similar publications |
| 027 | Reimbursement |
| 028 | Rental of motor vehicle |
| 029 | EV charging facilities (Installation, rental, sale / purchase or subscription fees) |
| 030 | Repair and maintenance |
| 031 | Research and development |
| 032 | Foreign income |
| 033 | Self-billed - Betting and gaming |
| 034 | Self-billed - Importation of goods |
| 035 | Self-billed - Importation of services |
| 036 | Self-billed - Others |
| 037 | Self-billed - Monetary payment to agents, dealers or distributors |
| 038 | Sports equipment, rental / entry fees for sports facilities, registration in sports competition or sports training fees |
| 039 | Supporting equipment for disabled person |
| 040 | Voluntary contribution to approved provident fund |
| 041 | Dental examination or treatment |
| 042 | Fertility treatment |
| 043 | Treatment and home care nursing, daycare centres and residential care centers |
| 044 | Vouchers, gift cards, loyalty points, etc |
| 045 | Self-billed - Non-monetary payment to agents, dealers or distributors |
Unit Codes
UN/CEFACT unit of measure codes for items[].unit_code. Default is C62 (Unit / Each).
| Code | Description |
|---|---|
| C62 | Unit / Each (default) |
| HUR | Hour |
| DAY | Day |
| MON | Month |
| ANN | Year / Annual |
| KGM | Kilogram |
| GRM | Gram |
| TNE | Tonne |
| MTR | Metre |
| CMT | Centimetre |
| MMT | Millimetre |
| KMT | Kilometre |
| MTK | Square metre |
| MTQ | Cubic metre |
| LTR | Litre |
| MLT | Millilitre |
| SET | Set |
| PR | Pair |
| DZN | Dozen |
| BX | Box |
| CT | Carton |
| PK | Package |
| RL | Roll |