- Home
- Developers
- 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.
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.
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"
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.
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.
| 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 | for 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…" } }
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.
| Parameter | Type | Description |
|---|---|---|
| stage | enum | inquiry · quoted · signed · delivered |
| updated_after | ISO 8601 | What changed after a given time, best for periodic syncing |
| limit | integer | Default 50, maximum 200 |
| cursor | string | from next_cursor in the previous response |
The four stages above are what the system records in the customer's log at each transition.
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?" }
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.
| 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 | Time 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
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.
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.
| Field | Type | Description |
|---|---|---|
| doc_number | string | Unique identifier; its pattern is explained 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, 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 | Percentage | Due at |
|---|---|---|
| First | 50% | Signing and start of work |
| Second | 30% | Initial delivery |
| Final | 20% | Final delivery, and computed by subtraction |
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.
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.
| 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, and the default is the first configured gateway |
| 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, 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" } }
| 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, so sort by created_at |
| Duplication | possible, 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.
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.
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.
| Case | Behaviour |
|---|---|
| Same key and same body | The stored response is returned with Idempotency-Replayed: true |
| Same key, different body | 409 with idempotency_key_reused |
| Retention | 24 hours |
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.
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.
| 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 ID in your instance |
| 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 is 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 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 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 when run locally, or on our servers on managed operation, with the AI at a cloud provider we name in the contract |
| Retention period | You 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.
Versioning and scheduled 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 fields you don't recognize |
A new value in enum | No, and don't let an unknown value break your integration |
| A new endpoint | no |
| Removing a field or changing its type | Yes, in a new version |
| Tightening an existing validation | Yes, 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.
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.