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).
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:
| Permission | Allows |
|---|---|
read | Reading customers, suppliers, invoices, bills, payments, accounts and tax codes. |
invoices | Creating customers and invoices, voiding invoices; reading invoices. |
payments | Recording payments against invoices and bills; reading payments. |
bills | Creating 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.
How every call works
- Address:
https://app.cenqra.com/api/v1/…. Requests and answers are JSON. - Amounts are strings with a dot, such as
"1250.50"(numbers are accepted too). Up to 4 decimals. - Dates are
YYYY-MM-DDin the company's time zone. Moments (createdAt…) are ISO 8601 in UTC. - Your own ids: give an
externalIdto customers, invoices, bills and payments. Sending the sameexternalIdagain never creates a second one: the answer is the existing record with"existing": trueand status200. Look records up with?externalId=. - Idempotency-Key (optional, on POST and PATCH): retrying the same request with the same key returns the first answer, with the header
Idempotent-Replayed: true, without doing the work again. The same key with a different body answers422. Keys are remembered for 30 days. - Lists answer
{ "object": "list", "data": [...], "page", "pageSize", "total", "hasMore" }. Use?page=and?pageSize=(up to 100), and?updatedSince=2026-10-01T00:00:00Zto fetch only what changed. - Limits: 120 calls a minute per key. Over that,
429withRetry-After. - Unknown fields are refused, so typing mistakes are caught.
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.
| Field | Required | Meaning |
|---|---|---|
customer | one of the two | The 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. |
customerId | one of the two | Cenqra's id of an existing customer. |
lines | yes | 1 to 200 lines, see below. |
externalId | recommended | Your id for this invoice (one subscription charge). Never invoiced twice. |
date | Default today. | |
dueDate | Default the date plus the customer's payment terms. | |
currency | Default the customer's currency, then the company's. | |
reference | Your subscription or order reference, printed on the invoice (60 characters). | |
notes | Printed on the invoice. | |
amountsIncludeTax | true when the prices already include VAT. Default false. | |
sendEmail | false: 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:
- 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": falseon each invoice with your usual key.) - Send each invoice with its own
date,dueDateand yourexternalId, oldest first. Sending the sameexternalIdagain answers the invoice already made, so the import can be stopped and run again safely. - For invoices that were paid, record the payment with
POST /api/v1/paymentsand its owndate, so they do not show as unpaid. LeaveemailReceiptoff. - 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.
| Field | Meaning |
|---|---|
invoiceId / invoiceExternalId | The invoice paid (money received). Give exactly one of these four. |
billId / billExternalId | The bill paid (money paid out). |
amount | Default what is still due. More than that stays on the contact's account as credit. |
date | Default today. |
bankAccountCode | The bank or cash account. Default the key's default bank account, or the company's only one in the currency. |
fee | The provider's fee in the same currency (booked as a bank charge). |
externalId, reference, notes | Your payment id (never recorded twice), and text shown on the receipt. |
emailReceipt | true 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:
- POST
/api/v1/contactswith"type": "customer","supplier"or"both"and the fields above. The sameexternalIdagain answers the existing contact. - PATCH
/api/v1/contacts/{id}changes only the fields sent. - GET
/api/v1/contacts(filterstype,externalId,email,q,updatedSince) and/api/v1/contacts/{id}.
Accounts, tax codes, bank accounts and products
Any key can read these, to choose the codes to send:
- GET
/api/v1/company: the company, its currency, today's date there, and the key's name and permissions. A good first call to test a key. - GET
/api/v1/accounts: the chart of accounts (code,name,type). - GET
/api/v1/tax-rates: tax codes and rates. In the UAE:SRstandard 5%,ZRzero-rated,EXexempt,OSout of scope. - GET
/api/v1/bank-accounts: bank and cash accounts. - GET
/api/v1/items: products and services with their prices.
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", … } }
}
| Event | When |
|---|---|
invoice.approved | An invoice was approved (posted to the books). |
invoice.paid | An invoice was paid in full. |
invoice.voided / invoice.deleted | An invoice was voided, or an approved invoice deleted. |
credit_note.approved / credit_note.voided | A credit note was approved or voided. |
bill.approved, bill.paid, bill.voided, bill.deleted | The same for bills. |
payment.received / payment.voided | Money received from a customer, or that receipt voided. |
supplier_payment.sent / supplier_payment.voided | Money paid to a supplier, or that payment voided. |
ping | The 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."] } }
| Status | Codes | Meaning |
|---|---|---|
| 400 | VALIDATION_FAILED, BAD_REQUEST | Something in the request is wrong or missing; the message says what. Nothing was saved. |
| 401 | UNAUTHORIZED | No key, an unknown key, or a revoked key. |
| 403 | INSUFFICIENT_SCOPE, KEY_OWNER_LOST, FEATURE_OFF, PLAN_REQUIRED, BILLING_LOCKED, COMPANY_READ_ONLY | The key may not do this, or the company cannot accept it now. |
| 404 | NOT_FOUND | No such record in this company. |
| 409 | ALREADY_PAID, NOT_APPROVED, VOIDED, IDEMPOTENCY_IN_PROGRESS, CONFLICT | The record is not in a state that allows it. |
| 422 | IDEMPOTENCY_MISMATCH | The Idempotency-Key was used for a different request. |
| 429 | RATE_LIMITED | Too many calls; wait a minute. |
| 500 | SERVER_ERROR | Our 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
| Call | Permission |
|---|---|
GET /api/v1/company | any |
GET /api/v1/accounts, /tax-rates, /bank-accounts, /items | any |
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}/pdf | read or invoices |
POST /api/v1/invoices, /invoices/{id}/void | invoices |
GET /api/v1/bills, /bills/{id}, /bills/{id}/pdf | read or bills |
POST /api/v1/bills, /bills/{id}/void | bills |
GET /api/v1/payments, /payments/{id} | read or payments |
POST /api/v1/payments | payments |