- Home
- Developers
- API reference
API reference
A REST API on top of your own instance of Naateq. Its payloads are your system’s own records — not models composed for display.
Read this first. Anything under a Implemented badge runs in the code today. Anything under Spec is a contract we commit to that is not built yet — it is enabled on your server during setup, and we fix its shape here before building so you can build against it without waiting.
You will not find an endpoint on this page presented as ready when it is not.
Five-minute start
The only call that works today with no key and no setup is the health check. Start with it to confirm your instance is running and the network reaches it.
returns 200 with a fixed body. It carries no data and needs no authentication, so it suits your own service monitor.
# replace the domain with your own server
curl https://bot.example.sa/health
# response
{ "ok": true }
Then request a key from the setup team, and store it in an environment variable. Do not put it in front-end code — the key grants full access to your customers’ records.
export NAATEQ_URL=https://bot.example.sa export NAATEQ_KEY=nq_live_… curl "$NAATEQ_URL/api/v1/conversations?limit=1" \ -H "Authorization: Bearer $NAATEQ_KEY"
The base URL is your own domain
Naateq runs On your server, not on a shared cloud. So there is no single URL for all customers — yours is the domain your system was installed on:
https://<your-domain>/api/v1
And this is not a technical detail: it is the same reason your customers’ conversations never pass through a third party, and the same thing you read on the security page. You own the server, the key, and the access log.
The corresponding burden: availability is your responsibility. We do not promise uptime for a server we do not operate, and what we do commit to is written on the support page.
One key in the header
Every request carries your key in Authorization. The key is shown once at creation and never retrieved again — if it is lost, create another and revoke the old one.
| Prefix | Scope | Where to use it |
|---|---|---|
| nq_live_ | Read and write | Your back end only |
| nq_ro_ | Read only | Dashboards and reports |
| nq_test_ | Read and write on dummy data | Development — does not send a single WhatsApp message |
One key per integration. Revoking one key does not stop the others, and it gives you a log saying which integration did what.
# missing or revoked key
HTTP/1.1 401 Unauthorized
{
"error": {
"type": "authentication_error",
"code": "key_revoked", "message": "The key has been revoked. Create a new one from the dashboard.", "request_id": "req_01J8…" } }
Conversations and messages
Every conversation is isolated by customer number. And the isolation is not a setting you choose — it is built into the system: no call ever sees another customer’s record.
| Parameter | Type | Description |
|---|---|---|
| stage | enum | inquiry · quoted · signed · delivered |
| updated_after | ISO 8601 | What changed after a moment — best suited to periodic syncing |
| limit | integer | Default 50, maximum 200 |
| cursor | string | from next_cursor in the previous response |
The four stages above are the system’s actual stages, not a marketing classification — they are written by crm.js at every transition.
Sends a text message in your business’s name. The message passes through the same output guard the system’s own reply passes through — so the API cannot say what the bot cannot say.
POST /api/v1/messages
Authorization: Bearer $NAATEQ_KEY
Idempotency-Key: a3f1c9de-…
{
"to": "966501234567", "text": "Your quote is ready — shall we send it?" }
Customers
The customer record is the same one the system writes in crm.json — The fields below are copied from the code, not composed for display. Which is why what you read from the API is what the bot sees at the moment of replying, not a copy lagging behind it.
| Field | Type | Description |
|---|---|---|
| number | string | Mobile number in international format without symbols |
| name | string | null | null until the customer states their name themselves |
| stage | enum | The current stage of the four |
| project | string | null | The project description as the system extracted it |
| quote | object | null | amount · project · status · date |
| contract | object | null | In and out of scope · delivery date · payment plan · signature date |
| milestones | array | The agreed delivery milestones |
| tickets | array | type · detail · status |
| payment_status | string | null | The current collection status |
| payment_links | array | Payment links issued and their status |
| next_payment_due | date | null | The next instalment date |
| delivered | object | null | Delivery date and warranty duration in months |
| created_at | ISO 8601 | The moment of the first message |
| updated_at | ISO 8601 | Last change — use it in updated_after |
{ "number": "966501234567", "name": "Mohammed Al-Harbi", "stage": "quoted", "project": "12-metre majlis — custom", "quote": { "amount": 4200, "project": "12-metre majlis — custom", "status": "sent", "date": "2026-08-04" }, "contract": null, "milestones": [], "tickets": [], "payment_links": [], "next_payment_due": null, "created_at": "2026-08-0
Documents
Quotes and contracts. And the document number is not random — it is built by docNumber() from your business prefix, the type letter, the date, and a three-digit sequence:
HT-Q-260804-058 │ │ │ └── daily sequence │ │ └────────── YYMMDD │ └─────────────── Q quote · C contract └──────────────────── your business prefix
⚠ The prefix changes when the business pack changes. Do not hard-code it in your integration — match the pattern, not the text. (Hand-writing it in two places silently broke our own contract-approval command once.)
Returns the document data and its lines. For the file itself add /pdf — it returns 302 to a signed link valid for 15 minutes.
| Field | Type | Description |
|---|---|---|
| doc_number | string | The unique identifier — see the pattern above |
| kind | enum | quote · contract |
| lines | array | Pricing lines: description, quantity and unit price |
| total | integer | The total in riyals |
| payment_plan | string | A summary of the payment plan computed from the total |
| approved_by_owner | boolean | The contract is not sent to the customer before it becomes true |
Payments
Amounts are always in halalas — an integer. 4,200 riyals is written 420000. Mixing currency units is the most common way payment integrations lose money, so our core never sees a decimal and every adapter converts at its own boundary.
And the payment plan is computed, not written. The final instalment is computed by subtraction rather than by multiplication, so the instalments sum to the total exactly whatever rounding does — because a riyal lost in a contract is a defect discovered at collection time:
| Instalment | Percentage | Due at |
|---|---|---|
| First | 50% | Signing and start of work |
| Second | 30% | Initial delivery |
| Final | 20% | Final delivery — and computed by subtraction |
The status of every gateway on your server: is it configured? in which mode? what is its minimum? and what are its credential field names?
[ { "id": "moyasar", "label_ar": "<Arabic label>", "configured": true, "mode": "live",
"min_amount_minor": 100,
"fields": ["secret_key"] },
{ "id": "paylink", "label_ar": "Paylink",
"configured": false, "mode": null,
"min_amount_minor": 500, "fields": ["api_id", "secret_key"] }, { "id": "tap", "label_ar": "<Arabic label>", "configured": false, "mode": null, "min_amount_minor": 100, "fields": ["secret_key"] } ]
Paylink’s minimum (5.00 SAR) we enforce ourselves before the request rather than waiting for their rejection — so the error reaches you locally and immediately.
The amount never comes from a language model. It is read from the approved contract’s instalment schedule. And this is not a usage policy but a constraint in the code: there is no tool the model holds that accepts an amount. The API is under the same constraint — instalment no amount.
| Field | Type | Description |
|---|---|---|
| doc_number | string Required | A contract approved by the owner |
| instalment | integer Required | The instalment number in the contract schedule — and the amount is read from it |
| provider | enum | moyasar · paylink · tap — defaults to the first configured one |
| idempotency_key | uuid | Prevents two links for one instalment |
{
"provider": "moyasar",
"url": "https://…",
"ref": "inv_01J8…",
"status": "pending",
"amount_minor": 210000,
"currency": "SAR"
}
Webhooks
You register a single URL, and the subscribed events arrive there as JSON with a signature X-Naateq-Signature.
All the events Spec — none of them has an emitter in the code today.
message.receivedA message arrived from a customer. Carries the text, the sender number and the classified intent.reply.sentThe system replied. Carries the reply, the source (automated · tool · human) and the processing time.stage.changedThe lead moved between the four stages. Carries the previous stage and the new one.quote.issuedA quote was issued. Carries the document number, the total and the payment plan.contract.approvedThe owner approved sending the contract. Carries the contract number and its value.payment.recordedA payment was confirmed. Carries the instalment number, the amount in halalas, and the gateway.handoff.requestedA handover to a human was requested. Carries the escalation reason.guard.blockedThe input guard blocked a message. Carries the attempt type.POST /your-endpoint
X-Naateq-Signature: t=1785838800,v1=5f2b…
X-Naateq-Delivery: whd_01J8…
{
"event": "quote.issued",
"created_at": "2026-08-04T09:31:02.771Z",
"data": {
"doc_number": "HT-Q-260804-058", "customer": { "number": "966501234567", "name": "Mohammed Al-Harbi" }, "total": 4200, "currency": "SAR", "payment_plan": "50% on signing • 30% on initial delivery • 20% on final delivery" } }
| Item | Value |
|---|---|
| Success | any 2xx code within the timeout |
| Your endpoint timeout | 5 seconds |
| Retries | 5 attempts with exponential backoff over 24 hours |
| Ordering | not guaranteed — order bycreated_at |
| Duplication | possible — make your handler tolerant of replays via X-Naateq-Delivery |
We say “ordering is not guaranteed” and “duplication is possible” because both are the truth of every retry system. Anyone promising you guaranteed ordering over an unreliable network is promising you what the first outage will break.
Never trust an unsigned request
Compute HMAC-SHA256 over t + "." + the raw body with the webhook secret, and compare it againstv1 using a constant-time comparison. And reject any request older than five minutes so an old request cannot be replayed against you.
import crypto from "node:crypto";
export function verify(raw, header, secret) {
const [t, v1] = header.split(",").map(p => p.split("=")[1]);
// time window: prevents replaying an old request
if (Math.abs(Date.now() / 1000 - +t) > 300) return false;
const expect = crypto
.createHmac("sha256", secret)
.update(`${t}.${raw}`)
.digest("hex");
// constant-time comparison: == leaks the difference through execution time
const a = Buffer.from(expect), b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
⚠ timingSafeEqual throws if the two lengths differ — which is why a length check precedes it. And this is exactly the case that takes down many integrations in production.
And compute the signature over the raw body before any JSON parsing — reordering the keys breaks the signature.
Idempotency — never pay twice
Every POST accepts Idempotency-Key. If the connection drops and the response never reaches you, repeat the same request with the same key: you get the same result not a second payment link and not a duplicated message.
| Case | Behaviour |
|---|---|
| Same key and same body | The stored response is returned with Idempotency-Replayed: true |
| Same key, different body | 409 — idempotency_key_reused |
| Retention | 24 hours |
Pagination by cursor, not by number
no page=2: a new record between two requests shifts the numbering, so you miss items or see them twice. A cursor pins the position.
{
"data": [ … ],
"has_more": true,
"next_cursor": "cur_01J8…"
}
And for periodic syncing use updated_after with the last updated_at you received — it transfers only what changed rather than the whole record set.
One shape for every error
Every error carries type for classification,code for programmatic handling,message for display, andrequest_id for support. Handle code no message — the text may improve; the code does not change.
| Case | Code | The cause and what you do |
|---|---|---|
| 400 | invalid_request | A missing field or wrong type. Details in errors[] |
| 401 | key_missing · key_revoked | Check the header or create a key |
| 403 | scope_insufficient | A read-only key on a write call |
| 404 | not_found | No record with this identifier on your server |
| 409 | idempotency_key_reused | Same key with a different body |
| 409 | contract_not_approved | You requested a payment link for a contract the owner has not yet approved |
| 422 | gateway_not_configured | No gateway configured — check GET /api/v1/gateways |
| 422 | amount_below_minimum | The amount is below the gateway minimum |
| 429 | rate_limited | Wait Retry-After then retry |
| 503 | gateway_unavailable | The gateway did not respond. Retry later with the same idempotency key |
⚠ Gateway credential text is never echoed in any error message — neither in full nor truncated. And this is a rule in the payment layer, not merely a habit: an error message is the most common place a key leaks from.
Limits and data policy
| Item | Value |
|---|---|
| Request limit | 120 / minute per key |
| Limit headers | X-RateLimit-Limit · X-RateLimit-Remaining · X-RateLimit-Reset |
| On exceeding | 429 with Retry-After in seconds |
| Body size | 256 kilobytes |
| Where data is stored | On your server — no copy on our side |
| Retention period | You set it — the system deletes nothing on its own |
An honest note: as long as the system is on your server, the request limit protects your own resources. Raising it is a setting in your instance, not something you buy from us.
Versioning and deprecation
The version is in the path: /api/v1/. And within one version we add and never remove.
| Change | Breaking? |
|---|---|
| A new field in the response | No — ignore what you do not recognise |
A new value in enum | No — handle the unknown gracefully |
| A new endpoint | no |
| Removing a field or changing its type | Yes — a new version |
| Tightening an existing validation | Yes — a new version |
And if we deprecate anything: notice 180 days in advance, a Sunset header on every response from the deprecated endpoint, and it stays working for the whole period.
Changelog
Every change to this reference is logged here with its date — including correcting what was written wrongly.
2026-08-04
First publication of this reference. At the same time, four claims with no code behind them were withdrawn from the developers page: a host on an unregistered domain, seven events with no emitter, a request limit with no limiter, and an export and deletion certificate that were never built. And every endpoint now carries an explicit status badge.
Building an integration and need an endpoint that does not exist?
The order is decided by what customers actually build. Tell us your case and which endpoint you need first.