Naateq
  1. Home
  2. Developers
  3. API reference

API reference

REST API spec for your Naateq instance. Only one endpoint is live today, the health check, and the rest is built on request.

Read this first. Anything under a Implemented badge runs in the code today. Anything under Spec A shape we commit to that isn't built yet, and it's built for your instance on request. We publish it here before building so you can plan around it.
One endpoint is live today: /health. Everything else is spec, including authentication, limits and webhooks.
Every endpoint on the page carries its status badge.

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 can reach it.

GET /health Implemented

returns 200 with a fixed body. It carries no data and needs no authentication, so it works with your service monitoring tool.

# 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, because 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

Every customer has their own instance of Naateq, so there's no single address for all customers. Your address is the domain your system was installed on:

https://<your-domain>/api/v1

When you run on your own server, your customers' chats stay inside your business, except for reading documents and images, which needs a cloud provider, as explained in security page. The server, the key and the access log are all with you.

On the other hand, keeping the server running is your responsibility. We don't commit to an uptime percentage for a server we don't manage, 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 can't be retrieved afterward. If it's lost, create a new one 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 datafor development, and doesn't send any WhatsApp messages

One key per integration. That way revoking one key doesn't stop the rest, and the log shows what each integration did.

# missing or revoked key
HTTP/1.1 401 Unauthorized

{
  "error": {
    "type": "authentication_error",
    "code": "key_revoked", "message": "The key has been revoked. Request a new key.", "request_id": "req_01J8…" } }
resource

Conversations and messages

Every conversation is isolated by the customer's number. The isolation is built into the system and always on, so no call can reach another customer's record.

GET /api/v1/conversations Spec
Query parameters
ParameterTypeDescription
stageenuminquiry · quoted · signed · delivered
updated_afterISO 8601What changed after a given time, best for periodic syncing
limitintegerDefault 50, maximum 200
cursorstringfrom next_cursor in the previous response

The four stages above are what the system records in the customer's log at each transition.

POST /api/v1/messages Spec

Sends a text message in your business's name. It goes through the same output filter as the system's replies, so whatever's blocked in the system's replies is blocked in the API too.

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 log is the same one the system writes, and the fields below are copied from it as is. So what you read from the API is what the system sees at the moment it replies.

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 8601Time of the 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
resource

Documents

Quotes and contracts. The document number is built from your business prefix, a 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 package changes, so Do not hard-code it in your integration and match the full pattern.

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

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

Document fields
FieldTypeDescription
doc_numberstringUnique identifier; its pattern is explained 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, as integers. So SAR 4,200 is written as 420000. Mixing up riyals and halalas is one of the most common causes of financial errors in payment integrations, so the system never handles decimal fractions internally, and conversion only happens at the edges.

The system calculates the payment plan from the total. The last instalment is calculated by subtracting the earlier ones from the total, so the instalments always add up to exactly the total whatever the rounding, and no one-riyal difference shows up at collection:

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

Returns the status of each gateway in your instance: whether it's configured, its mode, its minimum amount, and the names of its credential fields.

[ { "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"] } ]

We check Paylink's minimum (5.00 SAR) before sending the request to them, so you get the error immediately from your own server.

POST /api/v1/payment-links Spec

The amount is always read from the approved contract's payment schedule. This is a constraint in the code: the model has no tool that accepts an amount. The API is bound by the same constraint, so you send instalment and not 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, and the default is the first configured gateway
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, and none of them are sent yet.

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 filter 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": "NQ-Q-260804-058", "customer": { "number": "966501234567", "name": "Mohammed Al-Harbi" }, "total": 420000, "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, so sort by created_at
Duplicationpossible, so use X-Naateq-Delivery to ignore the duplicate event

Any system that retries may send an event twice or out of order, so build your handler with that in mind.

Security

Never trust an unsigned request

Compute HMAC-SHA256 over t + "." + the raw body with the webhook secret, and compare it against v1 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 lengths differ, so we check the length first. This is a common mistake that breaks many integrations in production.

And compute the signature over the raw body before parsing the JSON, because reordering the keys breaks the signature.

Reliability

Idempotency, so you don't pay twice

Every POST accepts Idempotency-Key. If the connection drops and you don't get the response, resend the same request with the same key, and you'll get the same result with no second payment link or duplicate message.

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

Cursor-based pagination

We don't use page=2, because a new record arriving between two requests shifts the page numbering, so you miss items or get duplicates. A cursor keeps your place.

{
  "data": [ … ],
  "has_more": true,
  "next_cursor": "cur_01J8…"
}

And for periodic syncing use updated_after with the last updated_at you received, so you only transfer what changed instead of the whole record set.

Errors

One shape for every error

Every error carries type for classification,code for programmatic handling,message for display, and request_id for support. Rely on code in your code, not message, because we may improve the message text, while the code stays fixed.

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 ID in your instance
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 is configured. Check GET /api/v1/gateways
422amount_below_minimumThe amount is below the gateway minimum
429rate_limitedWait Retry-After then retry
503gateway_unavailableThe gateway didn't respond. Retry later with the same idempotency key

⚠ Gateway credentials never appear in any error message, whether in full or truncated. This is a rule built into the payment layer, because error messages are among the most common places keys leak 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 when run locally, or on our servers on managed operation, with the AI at a cloud provider we name in the contract
Retention periodYou set it, and the system doesn't delete anything automatically

The limits above Spec, and none of them are enforced yet. Since each customer has their own instance, the request limit protects your instance's resources. You can raise it in your instance settings without buying anything from us.

Stability

Versioning and scheduled 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 fields you don't recognize
A new value in enumNo, and don't let an unknown value break your integration
A new endpointno
Removing a field or changing its typeYes, in a new version
Tightening an existing validationYes, in a new version

If we decide to retire an endpoint: we give you notice of 180 days in advance, add a Sunset header to every response from it, and it keeps working for the whole period.

Date

Changelog

We log every change to this reference here with its date, including corrections to anything written wrong.

2026-08-04 First publication of this reference. At the same time, we removed four things from the developers page that weren't built: an address on an unreserved domain, seven events that aren't sent, a rate limit that isn't enforced, and export with a deletion certificate. Every endpoint now has an explicit status badge.
2026-09-28 The seven events are back on the developers page with a “spec” badge, and their example now uses halalas, as this reference says. Webhooks were also removed from the “ready today” count on the integrations page.
2026-10-01 We clarified the guide's status at the top of the page: only the health check is live, and the rest is spec built on request. We labeled the limits table with a "Spec" badge and corrected where data is processed on managed operation.

Building an integration and need an endpoint that does not exist?

We set priorities based on what customers are building. Tell us about your case and which endpoint you need first.