For developers
Connect your own system
Send invoices and credit notes from the system you already run, and receive the ones your suppliers send you. One API key, JSON over HTTPS, and the same accredited Access Point the product uses.
The same price as the screens
A document sent or received through the API counts exactly as one sent or received in the product: against the 200 included with a company's plan each year, then against its prepaid packs. There is no separate API fee, no per-call charge and no different rate. Drafts, documents that fail validation and documents rejected before transmission use none.
Before you start
Base URL
Every path below is relative to this host. The address shown is the test environment; confirm the production host with Tech Ventures before you go live, because it is a different one.
https://ca-einvoicing-api-uat.blackcoast-6e3be86c.uaenorth.azurecontainerapps.ioAuthentication
One header on every request. There is no login step, no token to exchange and nothing to refresh.
X-Api-Key: tvk_live_a1b2c3d4_<secret>- The key is generated once by the company's own administrator, from Company Information in the portal, and shown in full only at that moment. It cannot be read back afterwards — only regenerated.
- Regenerating a key invalidates the previous one immediately. If calls suddenly fail to authenticate, check which key is in use.
- A key belongs to exactly one company, permanently. Integrating several companies means a key for each.
- Send it whole, starting with tvk_live_, with no stray spaces. A truncated key is refused with the same message as a wrong one.
Requests and responses
- JSON in and JSON out, as application/json.
- Dates on the sales side are MM/DD/YYYY.
- Field names are camelCase and case-sensitive; they must match exactly.
- Failures are JSON too, with success: false and an explanation. There is no second error format to handle.
Attachments
Up to 512 KB per file, base64 inside the JSON body, and only pdf, xml, jpeg, png, csv, ods or xlsx. A file that is too large, of the wrong type, or that fails the malware scan fails the whole submission — the document is not created without it.
Sending an invoice or a credit note
One call creates the document, validates it and queues it for the network. documentType decides which kind it is: 380 a commercial invoice, 480 an invoice out of scope of tax, 381 a credit note, 81 a credit note for goods or services.
POST/api/public/v1/outbound
Sending the same thing twice is safe
What happens, in order
- 1.The document is created and validated as one operation. If validation fails, nothing is created at all — a 422 comes back listing every reason.
- 2.Attachments are stored and scanned before validation completes.
- 3.The send to the Access Point then happens in the background, which is what submissionQueued: true is telling you.
POST /api/public/v1/outbound
X-Api-Key: tvk_live_a1b2c3d4_<secret>
Content-Type: application/json
{
"invoiceNo": "INV-2026-00125",
"documentType": "380",
"transactionType": "00000000",
"invoiceDate": "03/14/2026",
"dueDate": "04/13/2026",
"currency": "AED",
"buyer": {
"name": "Gulf Trading LLC",
"taxId": "100123456700003",
"email": "ap@gulftrading.ae",
"countryCode": "AE",
"city": "Dubai",
"addressLine1": "Office 1204, Business Bay"
},
"lines": [
{
"lineNo": 1,
"description": "Consulting services, March 2026",
"quantity": 10,
"unitPrice": 450.00,
"vatCategory": "S",
"vatRate": 5
}
],
"attachments": [
{ "fileName": "timesheet.pdf", "contentType": "application/pdf", "content": "<base64>" }
]
}HTTP/1.1 202 Accepted
{
"success": true,
"alreadyExisted": false,
"submissionQueued": true,
"header": {
"id": "7c9f1a44-55e2-4a1d-9d0b-2f0e8c6b31aa",
"invoiceNo": "INV-2026-00125",
"status": "Submitted"
}
}HTTP/1.1 422 Unprocessable Entity
{
"success": false,
"message": "The document failed validation.",
"validations": [
{ "field": "buyer.taxId", "message": "A 15-digit UAE TRN is required for a 380 document." },
{ "field": "lines[0].vatRate", "message": "vatRate must be 5 when vatCategory is S." }
]
}Nothing was created. Correct the document and send it again under the same invoiceNo.
Checking what happened to it
The 202 means the portal has it, not that the network has accepted it. Poll this with the id from header.id — your own invoiceNo will not work here.
GET/api/public/v1/outbound/{invoiceId}
GET /api/public/v1/outbound/7c9f1a44-55e2-4a1d-9d0b-2f0e8c6b31aa
X-Api-Key: tvk_live_a1b2c3d4_<secret>
{
"success": true,
"invoiceNo": "INV-2026-00125",
"status": "Delivered",
"cpAspStatus": "Accepted",
"ftaStatus": null,
"submittedAt": "2026-03-14T09:12:44Z"
}null does not mean accepted
Receiving what suppliers send you
Documents that arrive for you over the network are pushed to a URL you provide. You do not poll for them.
The webhook is a pointer, not the document
What to set up
- 1.An HTTPS endpoint of your own that accepts a POST and answers 2xx. Plain http is refused.
- 2.In the portal: Company Information, then API credentials, then the Purchase invoice webhook card. Enter the delivery URL and enable delivery.
- 3.For any ERP other than Business Central, choose Other ERP and generate a webhook secret. It is shown once.
- 4.Press Send test event to check it, and read the Delivery log for the outcome of every call, real or test.
The events
| Field | Notes |
|---|---|
| purchase_document.received | A document has arrived and been stored. |
| purchase_document.status_changed | A receipt, Access Point or tax-authority verdict changed. The event carries all the statuses as they stand, so overwrite yours with it rather than merging. |
| webhook.test | Someone pressed Send test event. Acknowledge it and ignore it. |
POST /your/webhook/endpoint
X-TV-Event: purchase_document.received
X-TV-Timestamp: 1773480764
X-TV-Signature: v1=4f1c0b7e…
{
"event": "purchase_document.received",
"purchaseDocumentId": "b17d3e90-6a21-4c88-9f3e-77aa1c2d5e04",
"invoiceNo": "SUP-9921",
"supplierName": "Emirates Stationery LLC",
"receivedAt": "2026-03-14T09:14:02Z"
}Checking a call really came from the portal
Signed calls carry X-TV-Timestamp and X-TV-Signature. Build the string {timestamp}.{raw body}, take HMAC-SHA256 of it with your webhook secret, write it as lowercase hex, and compare it in constant time with the value after v1=. Reject anything more than 300 seconds old, which stops a captured call being replayed.
import crypto from "node:crypto";
export function verify(rawBody: string, headers: Headers, secret: string) {
const timestamp = headers.get("X-TV-Timestamp") ?? "";
const signature = (headers.get("X-TV-Signature") ?? "").replace(/^v1=/, "");
// Outside five minutes, treat it as a replay of a captured call.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Verify the bytes you received
Answering, and retries
- Answer 2xx as soon as you have the event and do the work afterwards. A slow endpoint holds up delivery.
- On 5xx, 429 or no connection the portal retries: three attempts in all, roughly five then ten seconds apart.
- Any other 4xx is read as a refusal and is not retried.
- After the last attempt nothing is sent again. The failure stays in the Delivery log, and the document is stored in the portal either way.
- The same event can reach you more than once. Key on purchaseDocumentId and make your handling safe to repeat.
Fetching the document
One call returns everything: the header, every line, allowances and charges, attachments and the original UBL exactly as it was delivered. Pass includeXml=false or includeAttachmentContent=false to leave the heavy parts out.
GET/api/public/v1/purchase-documents/{id}
GET /api/public/v1/purchase-documents/b17d3e90-6a21-4c88-9f3e-77aa1c2d5e04?includeXml=false
X-Api-Key: tvk_live_a1b2c3d4_<secret>A 404 means the id does not exist — or belongs to a company that is not the one your key represents.
Field reference
The fields every document carries. The full line-item and attachment tables, and the complete code lists, are in the integration PDF — ask Tech Ventures for the current version.
| Field | Type | Required | Notes |
|---|---|---|---|
| invoiceNo | string | Yes | Your own number. Also the idempotency key — send it twice and you get the first document back. |
| documentType | string | Yes | 380 invoice · 480 out of scope · 381 credit note · 81 credit note for goods or services. |
| transactionType | string | Yes | Eight characters, one per flag. 00000000 for a standard taxable sale. |
| invoiceDate | string | Yes | MM/DD/YYYY. |
| dueDate | string | — | MM/DD/YYYY. |
| currency | string | Yes | ISO 4217. AED for a domestic sale. |
| buyer | object | Yes | name, taxId, countryCode, city and addressLine1 at minimum; taxId is required for a taxable sale. |
| lines | array | Yes | At least one. lineNo, description, quantity, unitPrice, vatCategory, vatRate. |
| allowanceCharges | array | — | Discounts and charges at document or line level. |
| attachments | array | — | base64, 512 KB each, pdf · xml · jpeg · png · csv · ods · xlsx only. |
| reference | object | Conditional | The original invoice a credit note refers to. Required for 381 and 81. |
Transaction type
Eight characters, one position per flag. Standard is all zeros; set the position that applies.
| Field | Notes |
|---|---|
| Position 1 | Reserved — 0. |
| Position 2 | 1 for a self-billed invoice. |
| Position 3 | 1 for a summary invoice. |
| Position 4 | 1 for a disclosed agent sale. |
| Position 5 | 1 for an export. |
| Positions 6–8 | Reserved — 0. |
