Receive invoice status updates with webhooks
Last reviewed: 2026-10-12
Scope: Setting up a webhook to be notified when an invoice changes status: endpoint requirements, creating and changing webhooks in JomeInvoice or through the API, limiting a webhook to selected source channels, branches or API clients, what each notification contains and the status values it can carry, verifying the signature with the webhook's secret, retry behaviour, rotating and removing a webhook, and troubleshooting.
Update: Added delivery scopes by source channel, branch and API client, the Webhook Subscription panel with names and editable URLs, secret rotation through the API, and the related error messages.
A webhook lets JomeInvoice send your system an HTTP notification whenever one of your invoices changes status: when LHDN accepts it, why it was rejected, when a buyer disputes it. You can send every invoice update to one destination, or limit a destination to selected source channels, branches, or API clients.
This guide is for the developer connecting your system to JomeInvoice. It covers creating a webhook in JomeInvoice or through the API, choosing which invoices it receives, what each notification contains, how to confirm a notification is genuine, and what to do when notifications stop arriving. The full request and response schemas are in the API Reference in the sidebar.
You need an HTTPS endpoint you control, and either a JomeInvoice user with the webhook permissions (to use the API Keys & Webhooks tab) or your workspace's API credentials. Your API credentials are in the same tab, under API Credential. An API key whose Data access is set to Reporting can list webhooks but cannot create, change, rotate, or delete them.
Before you start
Your endpoint must meet all of these, or the webhook is refused:
- It uses HTTPS. Plain
http://addresses are not accepted. - It is reachable from the internet at a public hostname. Private network addresses,
localhost, IP addresses that are not publicly routable, and hostnames that do not resolve are refused. - The URL contains no username or password, such as
https://user:pass@example.com/webhooks. - It does not point to JomeInvoice or a JomeInvoice subdomain.
The same rules apply whenever you change a webhook's URL.
Each webhook gets its own Client ID and signing secret for verifying notifications. They are separate from your API client credentials: do not use your API client secret to verify webhooks.
Create a webhook in JomeInvoice
- Go to Settings → Workspace Settings and open the API Keys & Webhooks tab. You need the webhook permissions to see the Webhook Subscription section; workspace administrators have them. See User roles and permissions.
- Click Add new webhook.
- Enter a Source System name, such as the name of the system that will receive the notifications. Each webhook in your workspace needs a different name.
- Enter the Destination URL.
- Leave Set Webhook Status on Active to start receiving notifications straight away, or switch it off if your endpoint is not ready yet.
- Under Delivery scope, choose which invoices this webhook receives, or leave every filter empty to receive all of them. See Choose which invoices a webhook receives.
- Click Save.
- The Save Your Webhook Signing Secret window shows the webhook's Client ID and Signing secret. Copy both and store the secret securely now: it is not displayed again. Click I've saved the secret when you have.
Each webhook in the Webhook list shows its name, whether it is Active or Inactive, and its URL. Details shows its action (Invoice status updates), when it was created, its Client ID, and its Delivery scope.
Your workspace can have a limited number of webhooks, and inactive webhooks count towards the limit. When you reach it, Add new webhook is unavailable; delete a webhook you no longer use to free a place.
Create a webhook through the API
Create a webhook with POST /api/webhooks. This example sends notifications only for SFTP invoices issued by one branch:
{
"url": "https://api.example.com/jomeinvoice/status",
"type": "invoices.status_update",
"status": "ENABLED",
"scope": {
"sources": ["SFTP"],
"branch_ids": ["01ARZ3NDEKTSV4RRFFQ69G5FAV"]
}
}
typeis alwaysinvoices.status_update, the one event JomeInvoice sends.statuscan beENABLEDorDISABLED. Create the webhookDISABLEDif your endpoint is not ready yet.- Omit
scope, or send an empty object, to receive notifications for all invoices. To restrict a filter, send a non-empty array; do not send empty arrays ornullvalues when creating a webhook. See Choose which invoices a webhook receives.
A successful response is 201 and includes the webhook's id, client_id, and its one-time client_secret:
{
"id": "01ARZ3NDEKTSV4RRFFQ69G5FAX",
"name": "Webhook 1",
"url": "https://api.example.com/jomeinvoice/status",
"type": "invoices.status_update",
"client_id": "client_0123456789abcdef0123456789abcdef",
"client_secret": "example-one-time-secret",
"last_secret_used_at": null,
"status": "ENABLED",
"is_deleted": false,
"created_at": "2026-10-08T08:00:00Z",
"updated_at": "2026-10-08T08:00:00Z",
"scope": {
"sources": ["SFTP"],
"branch_ids": ["01ARZ3NDEKTSV4RRFFQ69G5FAV"]
}
}
Store id, client_id, and client_secret securely. The secret is returned only when the webhook is created or its secret is rotated; it is never included when you list, retrieve, or update webhooks.
A webhook created through the API is named Webhook 1, Webhook 2, and so on. You can rename it in JomeInvoice with Edit.
To see what is registered, GET /api/webhooks lists your workspace's webhooks and GET /api/webhooks/{id} returns one. Both include the saved scope.
Choose which invoices a webhook receives
Without a delivery scope, a webhook receives a notification for every invoice in your workspace. A scope narrows that. It has three filters, and a filter you leave empty allows everything:
| Filter | In JomeInvoice | In the API | What it restricts |
|---|---|---|---|
| Source channels | Source channels | sources |
How the invoice reached JomeInvoice |
| Branches | Branches | branch_ids |
The branch that issued the invoice |
| API clients | Third-party API clients | api_client_ids |
Which API key submitted the invoice; applies only to invoices from the third-party API channel |
Values within one filter are alternatives: a webhook limited to Branch A and Branch B receives invoices from either. Filters combine: a webhook limited to Branch A and to SFTP receives only SFTP invoices from Branch A.
Source channels
| Value | In JomeInvoice | Includes |
|---|---|---|
MANUAL_WEB |
Manual Web | Invoices created or uploaded in the JomeInvoice web application |
SFTP |
SFTP | Invoices received through an SFTP feed |
MYINVOIS |
MyInvois Portal | Invoices whose source is the MyInvois Portal |
THIRD_PARTY_API |
Third-party API | Invoices submitted with an API key, including through connected point-of-sale and e-commerce integrations |
The API-client filter only narrows third-party API invoices. If you also restrict the source channels, include THIRD_PARTY_API, or the request is refused. If you leave the source channels unrestricted, the API-client filter narrows third-party API invoices while invoices from every other channel still reach the webhook.
If JomeInvoice cannot tell an invoice's source channel, it is not sent to a webhook that restricts source channels or API clients. An invoice with no issuing branch is not sent to a webhook that restricts branches.
Get the current options
In the API, call GET /api/webhooks/scope-options before creating or editing a scoped webhook. It returns the values your workspace can use:
{
"sources": ["MANUAL_WEB", "SFTP", "MYINVOIS", "THIRD_PARTY_API"],
"branches": [
{
"id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"name": "Example HQ",
"code": "HQ",
"is_hq": true
}
],
"api_clients": [
{
"id": "01ARZ3NDEKTSV4RRFFQ69G5FAW",
"name": "Example ERP",
"client_id": "client_example0000000000000000000000",
"is_active": true
}
],
"policies": [
{ "key": "sources", "enabled": true },
{ "key": "branch_ids", "enabled": true },
{ "key": "api_client_ids", "enabled": true }
],
"maximum_webhooks": 5
}
- Use
branches[].idandapi_clients[].idin a scope. The API clientidis not the same as itsclient_idcredential. - Only active API keys are listed.
policiesshows which filters are currently available. You cannot add or change a restriction on a filter that is not enabled. A webhook that already restricts such a filter receives no notifications until the filter is available again or you remove that restriction.maximum_webhooksis the number of webhooks your workspace can have.
Two webhooks at the same URL
Webhooks at different URLs can overlap freely, and each matching webhook receives the notification. Two webhooks at the same URL must not be able to receive the same invoice: for example, one for Branch A and one for Branch B is fine, but an unrestricted webhook conflicts with any other webhook at that URL. Inactive webhooks count in this check.
If you delete a branch or an API key
A scope keeps a deleted branch or deactivated API key in its list; the webhook does not widen to everything. JomeInvoice warns you, and names the webhooks affected, before you delete a branch or deactivate an API key that a delivery scope uses.
Change a webhook
In JomeInvoice, click Edit on the webhook to change its Source System, Destination URL, Set Webhook Status, or Delivery scope, then click Save. A changed scope applies to status changes from then on; earlier notifications are not resent.
Through the API, PATCH /api/webhooks/{id} changes the status and the scope. The URL and name cannot be changed through the API: change them in JomeInvoice, or delete the webhook and create a new one. The event type cannot be changed.
Send the webhook's current status with every update, even when you only change the scope. Within scope:
- Omit a filter to keep its current setting.
- Send a non-empty array to replace that filter's list.
- Send
nullfor a filter to remove that restriction. - Send
"scope": nullto remove every restriction. - Do not send an empty array or an empty
scopeobject.
This request replaces the source channels and keeps the branch and API-client filters as they are:
{
"status": "ENABLED",
"scope": {
"sources": ["SFTP", "THIRD_PARTY_API"]
}
}
This request removes only the branch restriction:
{
"status": "ENABLED",
"scope": {
"branch_ids": null
}
}
If you can view the activity trail, Activity history on a webhook shows when it was created and each change to its URL, name, status, scope, or secret, with who made it.
What a notification contains
Every notification is an HTTP POST to your URL with a JSON body and these headers:
| Header | What it carries |
|---|---|
Content-Type |
application/json |
X-Webhook-Signature |
A signature you use to confirm the notification is genuine; see Confirm a notification is genuine |
X-Webhook-Timestamp |
When the notification was sent, as Unix seconds |
X-Client-Id |
The Client ID of the webhook the notification belongs to |
The body is the same shape for every status. All values below are made up:
{
"type": "01",
"uuid": "01JEXAMPLEINVOICEULID0000",
"total": "1060.00",
"status": "valid",
"due_date": "2026-10-30",
"buyer_name": "Example Sdn Bhd",
"issue_date": "2026-10-08",
"failure_reason": null,
"invoice_number": "INV-0001",
"lhdn_unique_id": "EXAMPLELONGID",
"lhdn_verify_url": "https://myinvois.example/EXAMPLE-UUID/share/EXAMPLELONGID",
"rejected_reason": null,
"digital_signature": "…",
"branch_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"source": "SFTP",
"api_client_id": null
}
The fields you will use most:
uuid: the invoice's JomeInvoice identifier, the same one the API returned when the invoice was created. Use it to find the invoice in your own records.invoice_number: your invoice number.type: the e-invoice document type code, for example01for an invoice or02for a credit note.status: the invoice's new status. The values are listed below.failure_reason: why LHDN rejected the invoice, filled in whenstatusisinvalidorerror; otherwisenull.rejected_reason: the buyer's reason, filled in whenstatusisrejected; otherwisenull.lhdn_unique_idandlhdn_verify_url: LHDN's identifier and the public validation link. Both arenulluntil LHDN has validated the invoice.total: the total payable, as a string.branch_id: the branch that issued the invoice, ornullif it has none.source: the invoice's source channel, one of the four values in Source channels, ornullif it cannot be determined.api_client_id: theidof the API key that submitted the invoice, as listed in scope options;nullfor invoices not submitted through the API.
Status values
status carries one of these values. Not every invoice passes through every status; which ones you see depends on how the invoice was submitted and what LHDN and the buyer did with it.
| Value | Meaning |
|---|---|
pending_validation |
JomeInvoice is checking the invoice before submission |
pending_user_approval |
The invoice is waiting for approval in JomeInvoice |
pending_lhdn_submission |
Queued for submission to LHDN |
processing_lhdn_submission |
Submitted; waiting for LHDN's response |
valid |
LHDN validated the invoice |
invalid |
LHDN rejected the invoice; see failure_reason |
error |
Submission failed; see failure_reason |
pending_cancellation |
A cancellation has been requested |
cancelled |
The invoice was cancelled |
pending_rejection |
The buyer has requested rejection |
rejected |
The invoice was rejected by the buyer; see rejected_reason |
For what each status means for your business, see Understanding invoice statuses.
Expect several notifications for one invoice. A single invoice typically produces a notification for each status it passes through, for example pending_lhdn_submission, then processing_lhdn_submission, then valid, and they can arrive within seconds of each other. Because a notification can also be delivered more than once (see If your endpoint is unavailable), treat each uuid and status pair as the thing to act on once, and ignore repeats.
Confirm a notification is genuine
Anyone who knows your URL can send it a request. Before you act on a notification, check its signature.
Each webhook has its own signing secret, shown once: in the Save Your Webhook Signing Secret window, in the client_secret of the API response when the webhook is created, or when the secret is rotated. It cannot be retrieved later; if you lose it, rotate it. The X-Client-Id header is the webhook's Client ID, so it tells you which webhook, and which secret, a notification belongs to.
To verify:
- Take the
X-Webhook-Timestampheader value and the request body exactly as you received it: the raw bytes, before your framework parses them into an object. - Build the string
{timestamp}.{body}: the timestamp, a full stop, then the body. - Compute an HMAC-SHA256 of that string using the webhook's signing secret, as lowercase hexadecimal.
- Compare it to
X-Webhook-Signatureusing a constant-time comparison. If they differ, do not act on the notification. - Reject notifications whose timestamp is older than your integration allows, to guard against replays.
Do not re-serialise the body before hashing: any change to spacing or key order produces a different signature.
import hashlib
import hmac
def is_genuine(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
message = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature.lower())
Respond quickly, then process
Reply with any 2xx status as soon as you have verified and stored the notification, and do the real work afterwards. JomeInvoice waits up to 30 seconds for your reply; a slow endpoint is treated as a failed delivery.
If your endpoint is unavailable
If your endpoint cannot be reached, times out, or replies with anything other than a 2xx status, JomeInvoice retries the notification three more times, after roughly 1, 2 and 4 minutes. After the third retry it stops. There is no way to request a notification again once that happens; the next time the invoice's status changes, you receive a new notification as normal.
Two consequences:
- A notification may reach you more than once, for example if your reply was sent but not received. This is why de-duplicating on
uuidandstatusmatters. - If your endpoint is down for longer than about seven minutes, you miss the notifications sent in that window. Use the API to check the status of invoices you were expecting updates for.
If you want JomeInvoice to stop retrying a particular notification, because you will never be able to process it, reply with 410 Gone. That is the only status that stops retries for that notification. Everything else, including other 4xx errors, is retried.
Pause, rotate or remove a webhook
In API Keys & Webhooks:
- Pause: switch the webhook off. Notifications stop immediately. Nothing is held while a webhook is inactive; when you switch it back on, you receive notifications for status changes from then on, not for the ones you missed.
- Rotate the secret: Rotate, then Yes, Rotate. The current secret stops working immediately and the new one is shown once, so update your receiving application straight away.
- Remove: Delete, then Yes, Delete. JomeInvoice stops sending notifications to that destination; this cannot be undone.
Through the API:
- Pause or resume:
PATCH /api/webhooks/{id}with{"status": "DISABLED"}or{"status": "ENABLED"}. - Rotate the secret:
POST /api/webhooks/{id}/regenerate. The response includes the new one-timeclient_secret; theclient_idstays the same, and the previous secret stops working immediately. - Remove:
DELETE /api/webhooks/{id}. The webhook is removed and its signing secret stops working.
Troubleshooting
Error responses from the webhook API carry the reason in detail.
The URL is refused
Error message: Webhook URL must use HTTPS, Webhook hostname could not be resolved, or another message about the URL.
Where you will encounter it: a 422 response when you create a webhook or change its URL, or an error in the Add New Webhook or Edit Webhook window.
Steps to fix:
- Change the address to
https://if it is not. - If the hostname could not be resolved, check it is spelt correctly and has a public DNS record. If you created the record in the last few minutes, wait a minute or two and try again.
- Otherwise, check the address against every rule in Before you start: publicly reachable, no credentials in the URL, not a private or local address, not a JomeInvoice address.
The scope is refused
Error message: for example scope.branch_ids contains unavailable selection(s) for this company: … or sources must include THIRD_PARTY_API when api_client_ids is restricted.
Where you will encounter it: a 422 response when you create or update a webhook.
Steps to fix:
- Call
GET /api/webhooks/scope-optionsagain and use only the values it lists, with theidof each branch and API client. - Remove empty arrays. When creating, leave out a filter you do not want to restrict; when updating, send
nullto remove a restriction. - If you restrict both
sourcesandapi_client_ids, includeTHIRD_PARTY_APIinsources. - If the message says a filter is disabled, check
policiesin the scope options and leave that filter unchanged.
The webhook conflicts or the limit is reached
Error message: This endpoint URL already has an overlapping scope for the selected webhook type., This scope overlaps another webhook using the same URL and type., Maximum webhook limit of … reached for this company., or A webhook named '…' already exists in this workspace.
Where you will encounter it: a 409 response when you create or update a webhook.
Steps to fix:
- For an overlap, list your webhooks with
GET /api/webhooks, including inactive ones, and either use a different URL or make the scopes at that URL exclusive. See Two webhooks at the same URL. - For the limit, delete a webhook you no longer use. Inactive webhooks count.
- For a duplicate name, choose a different Source System name.
You cannot manage webhooks with your API key
Error message: Reporting credentials are read-only and cannot modify data.
Where you will encounter it: a 403 response when you create, update, rotate or delete a webhook through the API.
Steps to fix:
- Use an API key whose Data access is not Reporting, or manage the webhook in JomeInvoice.
No notifications arrive
Where you will encounter it: your endpoint's logs show no requests from JomeInvoice.
Steps to fix:
- Check the webhook is Active in JomeInvoice, or that
GET /api/webhooks/{id}returnsstatusENABLED. - Compare the webhook's delivery scope with the invoice's source channel, branch and API key. An invoice outside the scope is never sent to that webhook.
- Check that the invoice you are waiting on has actually changed status: open it in JomeInvoice or retrieve it through the API.
- Confirm your endpoint is reachable from the internet, not only from inside your network.
- Check your endpoint replies within 30 seconds with a
2xxstatus. Repeated failures use up the retries and the notification is dropped.
The signature never matches
Where you will encounter it: your verification step rejects every notification.
Steps to fix:
- Confirm you are using the webhook's signing secret, the one shown when the webhook was created or last rotated, and not your API client secret.
X-Client-Idis the webhook's Client ID, shown under Details in the Webhook list. - Hash the raw request body bytes, before any parsing or re-serialisation.
- Check the string you hash is the timestamp, then a full stop, then the body, with nothing else.
- Compare the hex digests case-insensitively.
- If the secret may have been lost or rotated, rotate it and update your receiving application with the new one.
The same notification arrives twice
This is expected when a delivery was retried. De-duplicate on uuid and status.