Naateq
  1. Home
  2. Developers
  3. 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.

First step

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.

GET /health Implemented

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"
Where the API lives

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.

Authentication

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.

HEADER Authorization: Bearer <key> Spec
Key types
PrefixScopeWhere to use it
nq_live_Read and writeYour back end only
nq_ro_Read onlyDashboards and reports
nq_test_Read and write on dummy dataDevelopment — 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…" } }
resource

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.

GET /api/v1/conversations Spec
Query parameters
ParameterTypeDescription
stageenuminquiry · quoted · signed · delivered
updated_afterISO 8601What changed after a moment — best suited to periodic syncing
limitintegerDefault 50, maximum 200
cursorstringfrom 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.

POST /api/v1/messages Spec

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?" }
resource

Customers

The customer record is the same one the system writes in crm.jsonThe 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.

GET /api/v1/customers/{number} Spec
Customer record fields
FieldTypeDescription
numberstringMobile number in international format without symbols
namestring | nullnull until the customer states their name themselves
stageenumThe current stage of the four
projectstring | nullThe project description as the system extracted it
quoteobject | nullamount · project · status · date
contractobject | nullIn and out of scope · delivery date · payment plan · signature date
milestonesarrayThe agreed delivery milestones
ticketsarraytype · detail · status
payment_statusstring | nullThe current collection status
payment_linksarrayPayment links issued and their status
next_payment_duedate | nullThe next instalment date
deliveredobject | nullDelivery date and warranty duration in months
created_atISO 8601The moment of the first message
updated_atISO 8601Last 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
resource

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.)

GET /api/v1/documents/{doc_number} Spec

Returns the document data and its lines. For the file itself add /pdf — it returns 302 to a signed link valid for 15 minutes.

Document fields
FieldTypeDescription
doc_numberstringThe unique identifier — see the pattern above
kindenumquote · contract
linesarrayPricing lines: description, quantity and unit price
totalintegerThe total in riyals
payment_planstringA summary of the payment plan computed from the total
approved_by_ownerbooleanThe contract is not sent to the customer before it becomes true
resource

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 schedule for a total of SAR 12,000 or more
InstalmentPercentageDue at
First50%Signing and start of work
Second30%Initial delivery
Final20%Final delivery — and computed by subtraction
GET /api/v1/gateways Spec

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.

POST /api/v1/payment-links Spec

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.

Fields for creating a payment link
FieldTypeDescription
doc_numberstring RequiredA contract approved by the owner
instalmentinteger RequiredThe instalment number in the contract schedule — and the amount is read from it
providerenummoyasar · paylink · tap — defaults to the first configured one
idempotency_keyuuidPrevents two links for one instalment
{
  "provider": "moyasar",
  "url": "https://…",
  "ref": "inv_01J8…",
  "status": "pending",
  "amount_minor": 210000,
  "currency": "SAR"
}
Outbound events

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" } }
Delivery behaviour
ItemValue
Successany 2xx code within the timeout
Your endpoint timeout5 seconds
Retries5 attempts with exponential backoff over 24 hours
Orderingnot guaranteed — order bycreated_at
Duplicationpossible — 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.

Security

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.

Reliability

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.

Idempotency key behaviour
CaseBehaviour
Same key and same bodyThe stored response is returned with Idempotency-Replayed: true
Same key, different body409idempotency_key_reused
Retention24 hours
Lists

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.

Errors

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.

Error codes
CaseCodeThe cause and what you do
400invalid_requestA missing field or wrong type. Details in errors[]
401key_missing · key_revokedCheck the header or create a key
403scope_insufficientA read-only key on a write call
404not_foundNo record with this identifier on your server
409idempotency_key_reusedSame key with a different body
409contract_not_approvedYou requested a payment link for a contract the owner has not yet approved
422gateway_not_configuredNo gateway configured — check GET /api/v1/gateways
422amount_below_minimumThe amount is below the gateway minimum
429rate_limitedWait Retry-After then retry
503gateway_unavailableThe 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

Limits and data policy

Usage limits and data policy
ItemValue
Request limit120 / minute per key
Limit headersX-RateLimit-Limit · X-RateLimit-Remaining · X-RateLimit-Reset
On exceeding429 with Retry-After in seconds
Body size256 kilobytes
Where data is storedOn your server — no copy on our side
Retention periodYou 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.

Stability

Versioning and deprecation

The version is in the path: /api/v1/. And within one version we add and never remove.

What counts as a breaking change and what does not
ChangeBreaking?
A new field in the responseNo — ignore what you do not recognise
A new value in enumNo — handle the unknown gracefully
A new endpointno
Removing a field or changing its typeYes — a new version
Tightening an existing validationYes — 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.

Date

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.