Cenqra API and webhooks

Cenqra API

Let your own system (a subscription platform, an online shop, a CRM or an admin panel) create invoices, record payments and add bills in a company's Cenqra books automatically, and hear back through webhooks when an invoice is paid, voided or approved.

Everything sent through the API follows the same rules as the Cenqra screens: VAT, numbering, closed periods, approvals, credit limits and UAE e-invoicing. Invoices are approved as soon as they are created and emailed to the customer with the PDF, unless you ask for no email (see Bringing in earlier invoices).

The API is part of the Plus and Premium plans. An owner or admin of the company makes keys in Settings > Integrations.

Quick start: invoice a subscription

Your subscription system charges a customer. It sends the charge to Cenqra; Cenqra creates the invoice (and the customer if it is new), approves it and emails it. Then it records the payment.

curl https://app.cenqra.com/api/v1/invoices \
  -H "Authorization: Bearer cnq_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sub_8841-2026-10" \
  -d '{
    "externalId": "in_8841_2026_10",
    "customer": {
      "externalId": "cus_8841",
      "name": "Lina Haddad",
      "email": "[email protected]",
      "country": "AE"
    },
    "reference": "Pro plan, October 2026",
    "lines": [
      { "description": "Pro plan (monthly)", "quantity": 1, "unitPrice": "200.00", "taxCode": "SR" }
    ]
  }'

The answer (201 Created):

{
  "object": "invoice",
  "id": "cmuy7k3…",
  "externalId": "in_8841_2026_10",
  "number": "INV-000124",
  "status": "open",
  "customer": { "id": "cmuy7k1…", "externalId": "cus_8841", "name": "Lina Haddad", "email": "[email protected]" },
  "date": "2026-10-07",
  "dueDate": "2026-11-06",
  "currency": "AED",
  "subtotal": "200.00",
  "tax": "10.00",
  "total": "210.00",
  "amountPaid": "0.00",
  "amountDue": "210.00",
  "lines": [ { "description": "Pro plan (monthly)", "quantity": "1", "unitPrice": "200.00", "accountCode": "4000", "taxCode": "SR", "net": "200.00", "tax": "10.00", "total": "210.00" } ],
  "email": { "status": "pending", "sentAt": null, "error": null },
  "payUrl": null,
  "url": "https://app.cenqra.com/#/c/…/sales/invoices/cmuy7k3…",
  …
}

When the card payment succeeds, record it against the invoice by your own id:

curl https://app.cenqra.com/api/v1/payments \
  -H "Authorization: Bearer cnq_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "externalId": "ch_3P9x…", "invoiceExternalId": "in_8841_2026_10", "fee": "6.39", "emailReceipt": true }'

The amount defaults to what is still due, the date to today, and the bank account to the key's default (for example the clearing account your card provider pays into).

Keys and permissions

Send the key in every request: Authorization: Bearer cnq_… (or X-Api-Key: cnq_…). A key belongs to one company, works with the access of the owner or admin who made it, and is limited to the permissions ticked when it was made:

PermissionAllows
readReading customers, suppliers, invoices, bills, payments, accounts and tax codes.
invoicesCreating customers and invoices, voiding invoices; reading invoices.
paymentsRecording payments against invoices and bills; reading payments.
billsCreating suppliers and bills, voiding bills; reading bills.

A key can also carry defaults: the income account and tax code for invoice lines, the expense account and tax code for bill lines, and the bank account payments go into. They are used whenever a request does not say.

Keep keys on your server, never in a web page or mobile app. A key stops working when it is revoked, when the person who made it is no longer an owner or admin, when the company turns the API off, or when the company is read-only (trial ended unpaid).

How every call works

Invoices

POST /api/v1/invoices

Creates an invoice, approves it (gives it its number and posts it to the books) and emails it to the customer with the PDF. Permission: invoices.

FieldRequiredMeaning
customerone of the twoThe customer: externalId, name, email, phone, address, city, country (AE), taxNumber (TRN), currency, paymentTermsDays. Found by externalId, then by email, then by name; otherwise added (name is then required). Details sent for a known customer update it.
customerIdone of the twoCenqra's id of an existing customer.
linesyes1 to 200 lines, see below.
externalIdrecommendedYour id for this invoice (one subscription charge). Never invoiced twice.
dateDefault today.
dueDateDefault the date plus the customer's payment terms.
currencyDefault the customer's currency, then the company's.
referenceYour subscription or order reference, printed on the invoice (60 characters).
notesPrinted on the invoice.
amountsIncludeTaxtrue when the prices already include VAT. Default false.
sendEmailfalse: approve and book the invoice but do not email it to the customer. Default true (or false when the key is set not to email invoices; true is then refused).

A line: description, quantity (default 1), unitPrice, discountPercent, accountCode, taxCode (null for no tax), itemCode (a product: its name, price, account and tax code are used when the line does not give them), unit (one of the product's units, for example box; its own price, or the product's price times its size, is used when unitPrice is left out; a unit the product does not have is refused). Without accountCode or taxCode, the key's defaults are used.

When it is not approved at once, it is kept as a draft and the answer says so: "status": "awaiting_approval" when the company's approval rules send it to an approver (it is emailed once approved), or "status": "draft" with heldBecause when the customer is on credit hold. Anything else that stops the invoice (a closed period, an unknown tax code, missing e-invoicing details…) is refused with an error and nothing is saved.

Email: email.status is pending (being sent), sent, waiting (for an approver), skipped (the customer has no email address), off (not emailed, as sendEmail or the key asked) or failed (with email.error).

GET /api/v1/invoices and /api/v1/invoices/{id}

Filters: status (draft, open, overdue, paid, void, deleted), externalId, contactId, from, to, updatedSince. The status of an invoice is one of draft, awaiting_approval, open, partially_paid, paid, void, deleted; overdue is true when it is past its due date and unpaid. payUrl is the pay-now link when the company takes online payments.

GET /api/v1/invoices/{id}/pdf

The invoice PDF, as the customer receives it.

POST /api/v1/invoices/{id}/void

Voids an approved invoice (a draft is deleted). Body: { "reason": "Subscription cancelled" }. Under UAE e-invoicing, an invoice already sent cannot be voided: issue a credit note in Cenqra instead.

Bringing in earlier invoices

To bring the invoices your system already issued (for example everything since 1 January) into Cenqra without emailing your customers again:

  1. In Settings > Integrations, make a separate key for the import and untick Email invoices to customers. Nothing made with that key is ever emailed, even by mistake. (Or send "sendEmail": false on each invoice with your usual key.)
  2. Send each invoice with its own date, dueDate and your externalId, oldest first. Sending the same externalId again answers the invoice already made, so the import can be stopped and run again safely.
  3. For invoices that were paid, record the payment with POST /api/v1/payments and its own date, so they do not show as unpaid. Leave emailReceipt off.
  4. Revoke the import key when it is done, and carry on with your usual key.

Before you start: invoices dated in a closed period are refused, so ask the company to open those months for the import. The company's approval rules and credit holds still apply. Stay under 120 calls a minute: a few thousand invoices take well under an hour. Webhooks announce these invoices like any other.

Payments

POST /api/v1/payments

Records money received for one invoice, or paid for one bill. Permission: payments.

FieldMeaning
invoiceId / invoiceExternalIdThe invoice paid (money received). Give exactly one of these four.
billId / billExternalIdThe bill paid (money paid out).
amountDefault what is still due. More than that stays on the contact's account as credit.
dateDefault today.
bankAccountCodeThe bank or cash account. Default the key's default bank account, or the company's only one in the currency.
feeThe provider's fee in the same currency (booked as a bank charge).
externalId, reference, notesYour payment id (never recorded twice), and text shown on the receipt.
emailReceipttrue emails the receipt to the customer. Default false.

An invoice that is still a draft answers 409 NOT_APPROVED; one already paid, 409 ALREADY_PAID. The payment is in the invoice's currency.

GET /api/v1/payments and /api/v1/payments/{id}

Filters: type (received or sent), externalId, contactId, from, to, updatedSince.

Bills

POST /api/v1/bills

The same as an invoice, with supplier or supplierId instead of the customer, and reference for the supplier's own invoice number. Bills are approved by default; send "status": "draft" to leave one for someone to check. Bills are not emailed. Permission: bills.

GET /api/v1/bills, /api/v1/bills/{id}, /api/v1/bills/{id}/pdf and POST /api/v1/bills/{id}/void work like their invoice versions.

Customers and suppliers

You rarely need these: an invoice or bill can carry its customer or supplier. To manage them yourself:

Accounts, tax codes, bank accounts and products

Any key can read these, to choose the codes to send:

Webhooks

In Settings > Integrations, add the address of your system and choose the events. Each time one happens in the company, whether through the API, a person in Cenqra, the bank feed, online payments or an import, Cenqra sends a POST with a JSON body:

{
  "id": "evt_6kQ2…",
  "type": "invoice.paid",
  "created": "2026-10-07T11:42:05.120Z",
  "companyId": "cmuy…",
  "data": { "object": { "object": "invoice", "id": "…", "externalId": "in_8841_2026_10", "status": "paid", … } }
}
EventWhen
invoice.approvedAn invoice was approved (posted to the books).
invoice.paidAn invoice was paid in full.
invoice.voided / invoice.deletedAn invoice was voided, or an approved invoice deleted.
credit_note.approved / credit_note.voidedA credit note was approved or voided.
bill.approved, bill.paid, bill.voided, bill.deletedThe same for bills.
payment.received / payment.voidedMoney received from a customer, or that receipt voided.
supplier_payment.sent / supplier_payment.voidedMoney paid to a supplier, or that payment voided.
pingThe test sent by “Send test”.

Changes are picked up within about 15 seconds. Answer with any 2xx status within 10 seconds. Otherwise the message is sent again after 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours (8 tries), and can be sent again by hand from the deliveries list. An address that fails 50 times in a row is paused. Use id to ignore a message you already handled.

Checking the signature

Every message has the headers Cenqra-Event, Cenqra-Event-Id, Cenqra-Delivery and Cenqra-Signature: t=1791387530,v1=5f2c…. v1 is the HMAC-SHA256, in hex, of t, a dot and the raw body, with the webhook's signing secret (whsec_…, shown in Settings > Integrations). Check it on the raw body before parsing, and refuse messages more than 5 minutes old.

Node.js

const crypto = require('crypto');

function verifyCenqra(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ''));
}

// Express: app.post('/webhooks/cenqra', express.raw({ type: 'application/json' }), (req, res) => {
//   if (!verifyCenqra(req.body.toString('utf8'), req.get('Cenqra-Signature'), process.env.CENQRA_WHSEC)) return res.sendStatus(400);
//   const event = JSON.parse(req.body);
//   …
//   res.sendStatus(200);
// });

PHP

$raw = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_CENQRA_SIGNATURE']), $p);
$expected = hash_hmac('sha256', $p['t'] . '.' . $raw, getenv('CENQRA_WHSEC'));
if (!hash_equals($expected, $p['v1']) || abs(time() - (int) $p['t']) > 300) { http_response_code(400); exit; }
$event = json_decode($raw, true);

Python

import hmac, hashlib, time

def verify_cenqra(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split('=', 1) for p in header.split(','))
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get('v1', '')) and abs(time.time() - int(parts['t'])) < 300

Errors

Every error has the same shape:

{ "error": { "status": 400, "code": "VALIDATION_FAILED", "message": "Add at least one line.", "details": ["Add at least one line."] } }
StatusCodesMeaning
400VALIDATION_FAILED, BAD_REQUESTSomething in the request is wrong or missing; the message says what. Nothing was saved.
401UNAUTHORIZEDNo key, an unknown key, or a revoked key.
403INSUFFICIENT_SCOPE, KEY_OWNER_LOST, FEATURE_OFF, PLAN_REQUIRED, BILLING_LOCKED, COMPANY_READ_ONLYThe key may not do this, or the company cannot accept it now.
404NOT_FOUNDNo such record in this company.
409ALREADY_PAID, NOT_APPROVED, VOIDED, IDEMPOTENCY_IN_PROGRESS, CONFLICTThe record is not in a state that allows it.
422IDEMPOTENCY_MISMATCHThe Idempotency-Key was used for a different request.
429RATE_LIMITEDToo many calls; wait a minute.
500SERVER_ERROROur side failed and nothing was saved. Send the same request again.

Every call is listed for 30 days under the key in Settings > Integrations (“Calls”), with its answer.

All endpoints

CallPermission
GET /api/v1/companyany
GET /api/v1/accounts, /tax-rates, /bank-accounts, /itemsany
GET /api/v1/contacts, /contacts/{id}read, invoices or bills
POST /api/v1/contacts · PATCH /contacts/{id}invoices or bills
GET /api/v1/invoices, /invoices/{id}, /invoices/{id}/pdfread or invoices
POST /api/v1/invoices, /invoices/{id}/voidinvoices
GET /api/v1/bills, /bills/{id}, /bills/{id}/pdfread or bills
POST /api/v1/bills, /bills/{id}/voidbills
GET /api/v1/payments, /payments/{id}read or payments
POST /api/v1/paymentspayments