VAT integration with Zoho Books and Xero
Check a customer VAT number and preview invoice VAT from your backend, then map the result to your accounting platform's configured tax codes.
1. API contract and access
POST /api/v1/vat/validateacceptsvat_numberandcountry_code(ortin/country), supports AE/SA/BH/OM, and requiresvalidation:vat.POST /api/v1/vat/calculateacceptsamount,country, and optionalinclusiveandrate, or anitemsarray of 1–100 separate lines; it supports AE/SA/BH/OM/KW/QA and requiresfinance:vat.- Both responses wrap the result in
data. Validation returnsvalidandreason; calculation returnsvatRate,baseAmount,vatAmount,totalAmount, andcurrency. Batch results containitemsandtotalsByCurrency, summing rounded line results per currency. - Use separate API keys with only the scopes each integration needs. Keep keys and ERP OAuth credentials in server-side secret storage.
See the VAT validation reference and VAT calculator reference.
2. Add a server-side VAT service
This shared Node.js example is called by your Zoho custom function or Xero event worker. Normalize each provider's invoice into the shown fields, and never let an untrusted client choose an API path or scope. Keep each invoice line in invoice.lines, with its discounted, extended taxableAmount(quantity already applied) and taxInclusive setting. Set taxRatePercent from the trusted ERP-approved tax code for each line (for example, 0 for zero-rated or 5 for UAE standard-rated supplies). Pass the percentage, not a decimal fraction; missing rates require review rather than a standard-rate fallback.
const KHALEEJI_BASE_URL = "https://khaleejiapi.dev"const apiKey = process.env.KHALEEJI_API_KEYif (!apiKey) throw new Error("KHALEEJI_API_KEY is required") async function khaleejiPost(path, payload) { const response = await fetch(new URL(path, KHALEEJI_BASE_URL), { method: "POST", headers: { Authorization: "Bearer " + apiKey, "Content-Type": "application/json", }, body: JSON.stringify(payload), signal: AbortSignal.timeout(5000), }) if (!response.ok) throw new Error(`KhaleejiAPI returned ${response.status}`) return response.json()} export async function previewInvoiceTax(invoice) { if (!Array.isArray(invoice.lines) || invoice.lines.length === 0 || invoice.lines.length > 100 || invoice.lines.some(line => !Number.isFinite(line.taxRatePercent) || line.taxRatePercent < 0 || line.taxRatePercent > 100)) { return { status: "unavailable" } } const validation = await khaleejiPost("/api/v1/vat/validate", { vat_number: invoice.customerVatNumber, country_code: invoice.taxCountry, // "AE" or "SA" }) if (typeof validation.data?.valid !== "boolean") { return { status: "unavailable" } } if (validation.data.valid === false) { return { status: "invalid", reason: validation.data?.reason ?? "VAT number not valid" } } const calculation = await khaleejiPost("/api/v1/vat/calculate", { country: invoice.taxCountry, items: invoice.lines.map(line => ({ amount: String(line.taxableAmount), inclusive: line.taxInclusive === true, rate: line.taxRatePercent, })), }) if (calculation.data?.items?.length !== invoice.lines.length || !Array.isArray(calculation.data?.totalsByCurrency) || calculation.data.totalsByCurrency.length === 0 || calculation.data.totalsByCurrency.some(total => !Number.isFinite(total.vatAmount))) { return { status: "unavailable" } } return { status: "valid", validation: validation.data, tax: calculation.data }}This example validates the number before calculating the amount. Handle API timeouts, 401/403, rate limits, and unexpected responses as an unavailable/review state; do not treat them as a valid VAT result or silently approve the invoice. Preserve line boundaries even when tax rates match: the batch API rounds each line before summing, not a combined rate bucket or subtotal. Two AED 0.11 lines at 5% produce AED 0.02 VAT, whereas calculating AED 0.22 once produces AED 0.01. Keep results mapped to their original lines and approved ERP tax codes, including zero-rated lines. For more than 100 lines, send batches without splitting or combining lines, then sum rounded totals per currency using decimal arithmetic. Reconcile rounded line taxes and totals with the ERP before updating; any rounding-policy, discount, tax-treatment, or precision mismatch requires review, not an automatic overwrite.
3. Zoho Books custom function
Run this function from a Zoho Books invoice workflow, which supplies invoice and organization. Configure an OAuth Books connection named zoho_books with ZohoBooks.contacts.READ to fetch the invoice's customer, and a separate connection named merchant_vat_proxy for your own authenticated backend. Use your organization's existing tax setup to map the result to a Zoho tax code.
contactResponse = zoho.books.getRecordsByID("Contacts", organization.get("organization_id"), invoice.get("customer_id"), "zoho_books");if (contactResponse.get("code") != 0 || contactResponse.get("contact") == null){ info "Customer lookup needs review; do not auto-approve this invoice."; return;}contact = contactResponse.get("contact");vatNumber = contact.get("tax_reg_no");if (vatNumber != null && vatNumber != ""){ params = Map(); params.put("invoice_id", invoice.get("invoice_id")); result = invokeurl [ url : "https://merchant.example.com/accounting/vat-preview" type : POST parameters : params connection : "merchant_vat_proxy" ]; if (result.get("status") != "valid") { info "VAT check needs review; do not auto-approve this invoice."; } else { tax = result.get("tax"); for each total in tax.get("totalsByCurrency") { info "VAT preview: " + total.get("vatAmount") + " " + total.get("currency"); } info "Reconcile rounded line taxes and totals with Zoho before any invoice update."; }}The sample deliberately logs only the tax preview, not the VAT number or credentials. Adapt field names and connection configuration to your Zoho Books workflow. Have your authenticated proxy accept the Deluge invoice ID and fetch the saved invoice, its lines, and customer within the authenticated organization. Resolve taxRatePercent from the saved invoice's approved ERP tax code for each line, together with the verified tax country, discounted line amount, and inclusive/exclusive setting; do not trust caller-supplied amounts or rates. Pass those lines to previewInvoiceTaxwithout combining them into subtotals, even for single-rate invoices. Reconcile rounded line taxes and totals with Zoho's configured rounding policy before updating; keep mismatches as drafts for review. Do not put the KhaleejiAPI key in Deluge code.
4. Xero webhook and invoice worker
Point a Xero webhook at your backend, preserve the exact raw request body, and verify Xero's x-xero-signature before accepting a notification. Configure the webhook key as a server secret:
import { createHmac, timingSafeEqual } from "node:crypto" function isValidXeroSignature(rawBody, signature) { const secret = process.env.XERO_WEBHOOK_KEY if (!secret || !signature) return false const expected = createHmac("sha256", secret).update(rawBody).digest() const received = Buffer.from(signature, "base64") return received.length === expected.length && timingSafeEqual(received, expected)}- Reject invalid signatures; durably enqueue verified notifications and acknowledge them promptly.
- Deduplicate notifications, then use your Xero OAuth connection to fetch the current invoice rather than treating the notification body as a complete invoice.
- Normalize the customer VAT number and tax country, plus each line's discounted amount, inclusive/exclusive setting, and
taxRatePercentfrom its saved Xero tax code. CallpreviewInvoiceTaxwith those separate lines in a worker, preserving an explicit zero rate and Xero's per-line rounding; never combine rate buckets before calculating. - Reconcile each rounded line tax and the summed totals with Xero before any update. Only after they agree, map each result to its original line and approved Xero tax rate and update through Xero's Accounting API. Keep drafts for review when validation is invalid or unavailable, or rounding/tax totals differ; do not overwrite ERP tax to force agreement.
Webhooks notify your integration; they do not calculate or write invoice tax automatically. Make updates idempotent and avoid loops where your own invoice update triggers the same processing again.
Production checklist
- Keep the KhaleejiAPI key and Zoho/Xero credentials in server-side secret storage; never expose them in a browser or public function code.
- Verify webhook signatures, authenticate custom-function calls, rate-limit your backend, and allow only the invoice operations your integration needs.
- Use the ERP's saved tax codes and local rules as the source of truth; review exceptions and reconcile updated invoices.
- Do not store VAT numbers in logs, and retain only the invoice/tax data required by your accounting and privacy policies.