Oman Retail and E-commerce Sales
When an Omani online store or shop counter takes a payment from a consumer, it issues a simplified tax invoice carrying a Fawtara QR code. Two endpoints do that: one for the sale, one for the refund. Both return the QR code and the invoice reference in the same response, so a store can show them on the order-confirmation page while the customer is still looking at it.
This is a consumer (B2C) path. A sale made through it is never transmitted over the Peppol network โ see What happens to the invoice afterwards. To invoice another business, use Send Invoice instead.
All four endpoints on this page are documented here from the product source code, and all four were confirmed present on both the production service and the test service on 19 September 2026. Each answered HTTP 401 without a key, while an invented path under the same prefix answered 404 and 405 on the same hosts in the same check.
The endpointsโ
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/pos/sale | A customer has paid. Issue the simplified tax invoice and return its QR code. |
POST | /api/v1/pos/refund | A customer has been refunded. Issue a credit note against the original sale. |
GET | /api/v1/pos/usage | How many retail documents this organisation has issued in a calendar month. See How many documents have I issued this month. |
POST | /api/v1/pos/station-alert | A till or forecourt station went offline, or is degraded. Record the interruption and get back where the held receipts sit against the 24-hour window. See Reporting a station outage. |
The two issuing calls โ /api/v1/pos/sale and /api/v1/pos/refund โ require the
invoices:create permission and the create_invoice feature. If the feature is
switched off for your organisation the call is refused with HTTP 403 and the
message Feature 'create_invoice' is not enabled for this organization.
The usage call is different: it needs the invoices:read permission and no
feature, so a reporting key that cannot issue anything can still read the count.
The station-alert call needs the invoices:create permission and, unlike the
two issuing calls, no feature. A key whose organisation cannot issue a
document can still report that a station went dark.
Your organisation's country must be OM. The two issuing calls serve only
an organisation whose country is Oman, because what they issue is an Oman
Fawtara receipt. Any other country โ and an organisation with no country set at
all โ is refused with HTTP 422 and the error code
POS_COUNTRY_NOT_SUPPORTED. Nothing is recorded and no QR is returned, so there
is nothing to print. The check reads only the calling organisation's country;
there is no setting that turns it off, and the same refusal applies to the fuel
and money-exchange counter endpoints in the API reference. The usage call and
the station-alert call are not affected: a reporting or monitoring key
belonging to an organisation outside Oman works exactly as before.
Authenticate with your API key in the X-API-Key header, as everywhere else on
the API.
Issue a saleโ
curl -X POST "https://app.goroute.ai/peppol-api/api/v1/pos/sale" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"seller_name": "Muscat Coffee Traders LLC",
"seller_vat": "OM1100017479",
"order_reference": "WC-10432",
"issued_at": "2026-08-20T13:41:22+04:00",
"currency": "OMR",
"lines": [
{
"description": "Espresso beans 1kg",
"quantity": 2,
"unit_price": 12.500,
"tax_rate": 5,
"sku": "ESP-1KG"
}
],
"gross_amount": 26.250
}'
Required fieldsโ
| Field | Meaning |
|---|---|
seller_name | Your registered business name. |
seller_vat | Your Omani VAT identification number, for example OM1100017479. |
order_reference | Your own order number, for example WC-10432. Maximum 50 characters. |
issued_at | The moment the customer paid, as a date and time. |
lines | The cart. At least one line, at most 200. |
gross_amount | The total including VAT that the customer actually paid. Read the totals rule before you fill this in. |
Optional fields, and what they default toโ
| Field | Default | Notes |
|---|---|---|
currency | OMR | Three-letter currency code. |
channel | web | Where the sale happened: web, pos or app. |
seller_scheme | 0248 | The identifier scheme for your VAT number. Four digits. |
seller_city | Muscat | |
seller_street | empty | |
seller_country | OM | Two-letter country code. |
customer | omitted | A name and an email, kept for your own records. If you supply a name it appears as the buyer name on the document; if you do not, the document is issued to Cash Customer. No Peppol address is needed either way. |
buyer | omitted | Accepted in the same shape as the fuel endpoint's buyer, for a till that reuses that payload. A retail sale is always a simplified (B2C) invoice, so the buyer is a named consumer: its name appears as the buyer name, the same as customer.name (if you send both, customer.name is used). A buyer name that is empty or only spaces is refused with 422. A buyer with a vat_number or a peppol_id is refused with 422, because a VAT-registered business needs a full tax invoice. Send those through Send Invoice. |
Each cart lineโ
| Field | Required | Default | Notes |
|---|---|---|---|
description | yes | Up to 500 characters. | |
quantity | yes | Must be greater than zero. | |
unit_price | yes | The net price per unit, excluding VAT. | |
unit | no | EA | Unit of measure. |
tax_rate | no | 5 | Percentage, between 0 and 100. |
sku | no | none | Your stock code. |
The totals rule (the one that causes support tickets)โ
Send net, VAT-exclusive unit prices. GoRoute computes the document totals from the lines themselves:
- each line's net amount is
quantity ร unit_price, - VAT is that net amount times the line's
tax_rate, - the gross total is the two added together.
All three are rounded to three decimal places, because the rial divides into a thousand baisa.
The gross_amount you send is not used to build the document. It is a
cross-check. If it differs from the computed total by more than one baisa per
cart line, the call is refused with HTTP 422 and the response explains that
net unit prices are expected. Nothing is issued.
This is deliberate: a tax document whose totals contradict the customer's basket is worse than a refused API call. A store that sends VAT-inclusive prices will hit this on its very first call โ the computed gross will come out roughly 5% above the figure sent.
In the example above, two units at a net 12.500 give 25.000 net, 1.250 VAT and
26.250 gross, which is what gross_amount says. It reconciles, so the sale is
accepted.
What comes backโ
A new sale returns HTTP 201 and a body like this:
{
"invoice_reference": "INV-WC-10432",
"invoice_uuid": "03a1e43a-3a94-521c-ae4e-6bf2f1f975c1",
"invoice_type": "simplified",
"status": "validated",
"transaction_id": "โฆ",
"transaction_status": "delivered",
"seller_vat": "OM1100017479",
"issued_at": "2026-08-20T13:41:22+04:00",
"qr": {
"tlv_base64": "โฆ",
"image_data_uri": "data:image/png;base64,โฆ",
"tags": { "โฆ": "โฆ" },
"verify_url": "https://app.goroute.ai/app/verify.html#โฆ",
"verify_image_data_uri": "data:image/png;base64,โฆ"
},
"totals": { "net": "25.000", "vat": "1.250", "gross": "26.250", "currency": "OMR" },
"tdd": { "status": "queued", "channel": "ota_fawtara_tdd", "deadline": "โฆ", "note": "โฆ" },
"validation_score": 100,
"validation_errors": []
}
| Field | What it is |
|---|---|
invoice_reference | The invoice number. For a sale it is INV- followed by your order_reference; for a refund, CN- followed by it. |
invoice_uuid | The invoice's unique identifier. Keep it โ a refund needs it. |
invoice_type | simplified for these endpoints. |
status | validated, or validation_failed if the document broke a business rule. |
transaction_id | The record on your Transactions page. Always present on a 200 or 201: a sale that could not be saved is answered 503 instead, or 409 if the identifier it computes to is already taken (see When something is wrong). Older platform versions answered success with null here โ if you ever see null, treat the sale as not recorded and resubmit it. |
transaction_status | delivered, queued or failed. |
qr | Two QR codes. See The two QR codes. |
totals | The computed net, VAT and gross, as strings with three decimals. |
tdd | The tax-reporting position. See What happens to the invoice afterwards. |
validation_score | A score out of 100 from the validation stack. |
validation_errors | The business rules that failed, each with a code and a message. Empty when status is validated. |
If the document fails business validation, the endpoint still answers 201,
but status reads validation_failed, validation_errors lists what went
wrong and transaction_status reads failed. Check the status field, not
only the HTTP code.
The one exception to that ordering: a request that fails the totals cross-check, or that is missing a required field, never gets that far. It is rejected with 422 before anything is issued.
Retries are safe: 201 for a new sale, 200 for a repeatโ
The invoice's identifier is derived deterministically. It is computed from your seller scheme and VAT number, the document type, the invoice number, the issue date, the VAT amount and the gross amount. Send the same order twice and the same identifier comes out both times, so the second call returns the record already stored instead of issuing a second invoice.
The outcomes are told apart by the HTTP status:
- 201 โ this sale is newly recorded.
- 200 โ GoRoute already had it, and you are being handed the original.
- 503 with
error_codePOS_SALE_NOT_RECORDEDโ the sale could not be confirmed as recorded. WaitRetry-Afterseconds and send the same sale again, with the same invoice number; that retry is the same safe resubmission. Never re-key it under a new number. - 409 with
error_codePOS_SALE_CONFLICTโ the identifier this sale computes to is already held by a different document of your organisation. Nothing was recorded and no QR was returned, so there is no receipt to print. This is the one case where sending the same sale again cannot help: issue it under a new invoice number instead. See When something is wrong.
A double-clicked checkout button, or a payment gateway retrying after a timeout, is therefore safe. It is also the reason a repeat cannot inflate what you are billed.
When a 409 happens. Only when two different documents of the same
organisation compute the same identifier. That takes either two counters issuing
the same invoice number, or the same number issued once as a business (B2B) sale
and once as a consumer (B2C) one. The case a retailer can design around is a fuel
forecourt: a fuel receipt sent with station_id INV and receipt_no set to an
order number becomes the invoice number INV-<order>, which is exactly the shape
a retail sale's reference takes, so the two counters can collide. GoRoute refuses
the second document rather than attaching it to the first, because two records
under one identifier would report the same invoice to the Oman Tax Authority
twice.
Two cautions worth knowing before you rely on it:
- Because the totals and the issue date are part of the identifier, resending the same order reference with different amounts or a different date is a different document, and it will be issued as one.
- The 200 case is not listed in the machine-readable API description, which documents 201, 409, 422 and 503 only. It is the behaviour of the code; a client that treats any 2xx as success is unaffected either way.
Refund a saleโ
A refund is the same shape as the sale, plus a reference to the sale it
reverses. Both of the fields below come from the original sale's response:
preceding_invoice_number is its invoice_reference and
preceding_invoice_uuid is its invoice_uuid.
curl -X POST "https://app.goroute.ai/peppol-api/api/v1/pos/refund" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"seller_name": "Muscat Coffee Traders LLC",
"seller_vat": "OM1100017479",
"order_reference": "WC-10432-R1",
"issued_at": "2026-08-21T09:12:00+04:00",
"currency": "OMR",
"lines": [
{
"description": "Espresso beans 1kg (returned)",
"quantity": 1,
"unit_price": 12.500,
"tax_rate": 5
}
],
"gross_amount": 13.125,
"preceding_invoice_number": "INV-WC-10432",
"preceding_invoice_uuid": "03a1e43a-3a94-521c-ae4e-6bf2f1f975c1"
}'
Three things differ from a sale:
- Amounts stay positive. A credit note credits an amount; it does not carry negative lines.
- A partial refund sends only the lines being refunded, with the quantities actually returned.
- A reason may be given.
credit_reason_codedefaults toRETandcredit_reason_texttoCustomer refund.
The response has exactly the same shape as a sale. The credit note gets its own invoice reference, its own identifier and its own QR code, and it is reported in its own right.
The two QR codesโ
Both endpoints return two images, each as a data: URI you can put straight
into an image tag on a receipt or a confirmation page.
| Field | What it is |
|---|---|
qr.image_data_uri | The strict Oman tax QR code. It encodes the ten-tag Fawtara payload (Architecture v1.0.3) โ QR version, invoice type, invoice number, seller name, VAT number, date, time, total including VAT, VAT amount and the seller identifier. This is the one the tax rules are about. |
qr.verify_image_data_uri | A convenience QR code. It encodes a link to a GoRoute page that displays the invoice details, so a phone camera opens something a person can read. |
qr.tlv_base64 is the exact value encoded in the tax QR, if you would rather
render the image yourself. qr.tags is that same payload decoded into readable
fields.
The payload is derived from the finished invoice, so it is the invoice: the totals are the document's own, rounded to two decimals as PINT OM requires, the time is the sale's, and the seller identifier at tag 10 is the same value the document carries as BTOM-004. Print this QR, not one built from the till's figures.
To build your own "check this receipt" feature rather than relying on the GoRoute page,
call GET /api/v1/public/verify-receipt with the invoice reference and the gross total โ
the two values printed on the receipt. It needs no API key. See
Public Verification.
What happens to the invoice afterwardsโ
This is the part to read carefully, because it is a compliance statement.
A consumer sale is never sent over the Peppol network. There is no buyer Peppol address involved and none is required. The compliance record is the QR receipt plus a Tax Data Document, the artefact reported to the Oman Tax Authority.
The tdd object in the response describes that position:
| Field | Meaning |
|---|---|
channel | ota_fawtara_tdd โ reported to the tax authority, not transmitted over Peppol. |
deadline | 24 hours after the sale. That is the Oman reporting window for a consumer sale. |
status | Always queued in the sale response. The declared set is queued, submitted or not_required, but the sale response is written at the moment of the sale, before anything has been reported, so queued is the only value it returns. Do not wait on this field for the report's real state โ use clearance_status on GET /api/v1/transactions/{transaction_id}/reporting, described below. |
note | A sentence describing the current position in words. |
GoRoute builds, validates and stores a Tax Data Document for every consumer sale, and it is auditable. Whether that report is transmitted to the Oman Tax Authority depends on two things, and the report is held if either one is not satisfied:
- whether the environment your organisation runs on is configured to report to the authority at all, and
- where an enabled-reporter list is in use, whether your taxpayer identifier is on it. Such a list restricts transmission to the taxpayers named on it, and every other taxpayer's report is held exactly as if reporting were switched off.
A held report keeps the original issue time of its invoice, so the 24-hour obligation stays evidenced per invoice. Held reports are not released by themselves: GoRoute releases them by running a job, and that job refuses to run while the switch is off, or while reports are still addressed to a placeholder rather than the authority's real participant. Do not tell a merchant their sale has already been reported to the authority.
To see which of these applies to your own environment, call
GET /api/v1/transactions/{transaction_id}/reporting for one of your Oman
invoice transactions. ota_live says whether the environment is configured to
report to the authority, and ota_target_is_testbed says whether reports are
addressed to the authority's test participant rather than a production one โ
both describe the environment, not one sale. On the same call,
clearance_status is the field to trust for a report's own state, and
prepared_awaiting_authority is the held state. The enabled-reporter list is a
deployment setting, and no API call exposes it. These fields are documented in
full in the Oman country
guide.
How many documents have I issued this monthโ
A store plan bills on the number of documents the retail counter has issued, and
GET /api/v1/pos/usage is how you read that number.
curl "https://app.goroute.ai/peppol-api/api/v1/pos/usage?period=2026-08" \
-H "X-API-Key: YOUR_API_KEY"
period is optional. It takes a calendar month as YYYY-MM and defaults to the
current month. Anything else is rejected.
{
"period": "2026-08",
"from": "2026-08-01T00:00:00+00:00",
"to": "2026-09-01T00:00:00+00:00",
"issued_documents": 1284,
"sales": 1191,
"credit_notes": 93,
"note": "Retries and duplicate submissions never count twice; failed validations are excluded."
}
| Field | What it is |
|---|---|
period | The month counted, as YYYY-MM. |
from / to | The exact window counted. from is included, to is not. |
issued_documents | Sales plus credit notes โ the billable figure. |
sales | Sale invoices in the window. |
credit_notes | Refund credit notes in the window. sales and credit_notes add up to issued_documents. |
note | A fixed sentence repeating the two rules below. |
A repeated order does not count twice. Because a retry returns the transaction already stored rather than issuing a new one, the count follows the same rule as retries.
Three limits to know before you reconcile against itโ
- It counts the retail family only. Documents issued through this page's two
endpoints are counted. Fuel-counter documents, recorded as
oman_pos_fuel, and money-exchange counter documents, recorded asexchange_counter, are not counted, even though those counters are served by the same part of the API as the two calls on this page. This count is the exception, though: the Oman POS Report goes the other way and totals all three counters together โ fuel, retail and money exchange โ by station, tender and product. So the two figures are not comparable, and that report is not a way to get a retail-only or fuel-only number unless you separate the counters yourself, which that page explains how to do. - The month is a UTC month. The window runs from midnight UTC on the first to midnight UTC on the first of the next month. Oman is four hours ahead, so a sale made in Muscat between midnight and 04:00 on the first of a month falls into the previous month's count.
- Only failed documents are excluded. A document that is still in flight is already counted. If it fails later it stops being counted, so a figure you read mid-month can go down as well as up.
Reporting a station outageโ
This call is for an on-site agent at a physical counter that can be cut off from the network โ an unattended fuel forecourt is the case it was built for. If you are integrating an online store you do not need it: a web checkout has no offline mode, and a failed submission is covered by the retry behaviour described above.
A till or a forecourt station that loses its line keeps selling, and the
receipts it issues while it is dark are unreported until it can talk again. On
an Omani forecourt that is the normal case rather than the exception.
POST /api/v1/pos/station-alert is how the station tells GoRoute what happened,
and how you get back a plain answer about where the oldest held receipt sits
against the 24-hour window in which a supplier must get an invoice to their
Access Point.
curl -X POST "https://app.goroute.ai/peppol-api/api/v1/pos/station-alert" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"station_id": "PUMP-07",
"station_name": "Al Khoud Forecourt โ Pump 7",
"event": "recovered",
"offline_seconds": 5400,
"held_receipts": 38,
"oldest_receipt_age_seconds": 68400,
"detail": "ADSL line down; agent buffered locally",
"agent_version": "1.4.2"
}'
What you sendโ
| Field | Required | What it is |
|---|---|---|
station_id | โ | Your identifier for the station. Up to 32 characters. |
station_name | โ | The name a human will recognise. Up to 120 characters. |
event | โ | recovered โ the station is back and its held sales have been transmitted โ or degraded, meaning it is still impaired. Only recovered is matched exactly; any other value is treated as degraded. |
offline_seconds | optional | How long the station was unreachable. Zero or more. |
held_receipts | optional | How many receipts were issued while it was dark. Defaults to 0. |
oldest_receipt_age_seconds | optional | Age of the oldest held receipt. This is the one field that decides the reporting-window verdict; leave it out and the verdict is unknown. |
detail | optional | Free text about the cause. Up to 400 characters. |
agent_version | optional | Version of the edge agent reporting. Up to 32 characters. |
What comes backโ
{
"ok": true,
"emailed": true,
"to": "<the address GoRoute has configured>",
"actionable": true,
"reporting_window": "AT RISK",
"note": "The oldest held receipt is 19 h 0 min old, leaving 5 h 0 min of the 24-hour window in which a supplier must send an invoice to their Access Point."
}
to is shown as a placeholder on purpose. The real value is a single address
GoRoute sets for the whole platform, and this page does not publish it.
| Field | What it is |
|---|---|
ok | Always true when the alert was accepted. |
reporting_window | within window, AT RISK, BREACHED, or unknown. See the table below. |
note | The same verdict in a sentence, with the age and the time remaining spelled out. |
actionable | true when reporting_window is AT RISK or BREACHED. |
emailed | Whether an alert email was actually sent. Only an actionable alert is emailed. |
to | The address that alert went to, and null when the alert was not actionable. Read who is emailed before you rely on this. |
The verdict is judged on the oldest held receipt against a 24-hour window, because that is the receipt closest to its deadline. An average would hide the only one that matters.
reporting_window | When you get it |
|---|---|
within window | The oldest held receipt is less than 18 hours old โ under 75% of the window. |
AT RISK | The oldest held receipt has consumed 75% or more of the 24 hours, but the window has not closed. |
BREACHED | The oldest held receipt is 24 hours old or older. The window has closed and this needs review. |
unknown | You sent no oldest_receipt_age_seconds, so no window could be judged. The note says that no reporting window was consumed. |
Who gets the email, and who does notโ
When an alert is actionable, GoRoute emails one address that GoRoute
configures for the whole platform. It is not your address, it is not derived
from the organisation making the call, and there is no way to set a per-customer
recipient. The to field in the response tells you where it went.
So: the call records the outage and hands you the compliance position in the response, and GoRoute is notified when the window is at risk or breached. If you need your own people alerted, build that from the response โ do not assume the merchant or the till vendor has been told.
A within window or unknown alert sends no email at all. That is deliberate:
a station going dark on an old forecourt line is a normal condition nobody can
act on while it is dark, and mailing about every one of them teaches the
recipient to ignore the address. What is actionable is a held receipt near or
past the window.
When something is wrongโ
| Status | What happened |
|---|---|
| 422 | The request was rejected before anything was issued: a required field is missing, or gross_amount does not reconcile with the lines. The message names the problem. |
422 POS_COUNTRY_NOT_SUPPORTED | Your organisation's country is not OM, or no country is set for it. The sale or refund was not recorded and no QR is returned: do not print a receipt. Besides error_code and message, the body carries organization_country (your organisation's country, or null when none is set) and supported_countries (the list of countries these endpoints serve โ today, OM alone). Retrying will not help; the country on the organisation has to change. The usage and station-alert calls are not affected. |
| 403 | Either your key lacks the invoices:create permission, or the create_invoice feature is off for your organisation. |
201 with status: validation_failed | The document was issued as a record but broke a business rule. validation_errors lists each one. |
503 POS_SALE_NOT_RECORDED | GoRoute could not confirm the sale was recorded โ a temporary fault on our side, such as a database failover. There is no QR in the response, so do not print or show a receipt. Resubmit the same sale, with the same invoice number, after the number of seconds in the Retry-After header โ never re-key it under a new number, which would be a second sale and a second tax report. Because the invoice identifier is deterministic, the resubmission is recorded and reported once and never duplicated; in the rare case where the save did reach the database but its confirmation was lost, the resubmission simply returns the original (200). The body carries error_code, message, retry_after, and the invoice_uuid and invoice_reference the resubmission will get. Do not confuse this with the 409 below: a 503 means send the same invoice number again, a 409 means never send it again. |
409 POS_SALE_CONFLICT | The identifier this sale computes to is already held by a different document of your organisation โ a sale issued by another counter, or the same invoice number already issued as a business (B2B) sale rather than a consumer (B2C) one. Nothing was recorded and there is no QR in the response, so do not print a receipt. Resubmitting the same sale will not help, however long you wait; re-issue the sale under a new invoice number. Besides error, error_code and message, the body carries invoice_uuid and invoice_reference (the identifier and number that collided) and conflicting_transaction_id, the transaction that already holds that identifier โ look at that record before you re-issue, because it may be the sale you were trying to make. Retries are safe explains how two counters end up computing one identifier. |
Rules that only apply to sending a document over the Peppol network โ such as the requirement for a buyer Peppol address โ are deliberately not applied to a consumer sale, so a valid cash receipt is not marked as failed for lacking something it can never have.
Next Stepsโ
- Send Invoice โ invoicing another business over Peppol
- Send Credit Note โ the Peppol equivalent of a refund
- Validation โ what the validation layers check
- Oman Country Guide โ Oman e-invoicing in general
- Tracking โ following documents after they are issued