1Overview
The API accepts and returns JSON. Your organization is identified by the API key, so URLs never contain your organization’s name.
Base URL
https://aichat.mn/api/v1- Send
Content-Type: application/jsonwith every request that has a body. - Dates are ISO 8601 in UTC:
2026-11-04T09:30:12.000Z. - Amounts are whole tögrög:
94000means 94,000₮. - Lists are paginated:
?page=1&pageSize=20→ adataarray plustotal,pageandpageSize. Messages use abeforecursor andlimit.
2Authentication and scopes
Send your organization’s API key in the Authorization header with every request.
- 1
Create a key
In your dashboard, open “Settings → API & webhooks”, click “Create key”, name it and pick the scopes it needs.
- 2
Copy it once
The
ak_live_…key is shown only once, when it’s created — we store only its hash. - 3
Add the header
Send
Authorization: Bearer ak_live_…with every request.
Scopes
A key can only call endpoints covered by its scopes. A missing scope returns 403 with the SCOPE_MISSING code.
| Scope | Allows | Endpoints |
|---|---|---|
contacts:read | Read contacts and their details |
|
contacts:write | Update contact details |
|
conversations:read | Read conversations and messages |
|
messages:send | Send messages into conversations |
|
orders:read | Read orders |
|
orders:write | Create orders and change their status |
|
products:read | Read the catalog |
|
products:write | Add and update products |
|
bookings:read | Read appointments and free slots |
|
bookings:write | Create appointments |
|
3Rate limits and errors
Each key can make up to 120 requests per minute. The X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers on every response show the limit, the requests left and when the limit resets. Beyond that you get 429, and the Retry-After header tells you how many seconds to wait.
Error format
Every error returns JSON with the HTTP status, a machine-readable code and a message in Mongolian:
{
"statusCode": 403,
"code": "SCOPE_MISSING",
"message": "Энэ түлхүүрт orders:write эрх алга."
}| Status | Meaning |
|---|---|
| 400 / 422 | Invalid request data — a missing field or a wrong format. |
| 401 | The key is missing, invalid or revoked.API_KEY_MISSINGAPI_KEY_INVALID |
| 403 | A missing scope, a plan without API access or a suspended organization.SCOPE_MISSINGPLAN_FEATURE_UNAVAILABLETENANT_SUSPENDED |
| 404 | Not found — another organization’s data is never visible either. |
| 409 | A state conflict: not enough stock (OUT_OF_STOCK), the 24-hour window has closed (WINDOW_CLOSED) and so on. |
| 429 | Rate limit exceeded — wait Retry-After seconds.RATE_LIMITED |
| 5xx | A temporary error — wait a moment and retry. |
4Endpoints
All paths follow the base URL. :id is the resource UUID.
| Method | Path | Scope | Description |
|---|---|---|---|
| Contacts | |||
| GET | /contacts | contacts:read | List contacts — filter by `q`, `tag`, `channelId`; paginated |
| GET | /contacts/:id | contacts:read | A contact card with conversation history |
| PATCH | /contacts/:id | contacts:write | Update name, phone, email, tags, notes or broadcast opt-out |
| Conversations | |||
| GET | /conversations | conversations:read | List conversations — filter by `status`, `surface`, `channelId`, `q`; paginated |
| GET | /conversations/:id | conversations:read | One conversation — contact, channel and whether you can write (`window`) |
| GET | /conversations/:id/messages | conversations:read | Messages — newest first, `before` cursor, `limit` ≤ 100 |
| POST | /conversations/:id/messages | messages:send | Message a customer — 24-hour rules apply; the AI pauses in that conversation |
| Orders | |||
| GET | /orders | orders:read | List orders — filter by `status`, `paymentStatus`, `q`, `from`, `to`; paginated |
| POST | /orders | orders:write | Create an order — product `sku`/`productId` and quantity; priced from your catalog |
| GET | /orders/:id | orders:read | One order with its latest payment attempt |
| PATCH | /orders/:id | orders:write | Change the status or mark a payment manually (`PAID`, `REFUNDED`) |
| Products | |||
| GET | /products | products:read | List products — filter by `q` (Latin-letter aware), `category`, `active`; paginated |
| GET | /products/:id | products:read | One product |
| POST | /products | products:write | Add a product — SKUs are unique within your organization |
| PATCH | /products/:id | products:write | Update a product — price, stock, active |
| Appointments | |||
| GET | /bookings | bookings:read | List appointments — filter by `from`, `to`, `status`, `serviceId`; paginated |
| GET | /bookings/:id | bookings:read | One appointment |
| POST | /bookings | bookings:write | Book an appointment — `409` when full; waits for payment when a deposit is due |
| GET | /booking-services | bookings:read | Active services — duration, price, deposit, working hours |
| GET | /booking-services/:id/slots?date=YYYY-MM-DD | bookings:read | Free slots of a service on a given day (in your organization’s time zone) |
5Examples
Replace ak_live_… in the examples with your own key.
List orders
Newest first. Fetch the next page with page.
curl https://aichat.mn/api/v1/orders?page=1&pageSize=20 \
-H "Authorization: Bearer ak_live_…"Create an order
Reference products by sku or productId — the server prices the order, delivery and total from your catalog. The response is the new order with its number and total.
curl -X POST https://aichat.mn/api/v1/orders \
-H "Authorization: Bearer ak_live_…" \
-H "Content-Type: application/json" \
-d '{
"items": [
{
"sku": "BAG-001",
"quantity": 1
}
],
"customer": {
"name": "Болор",
"phone": "99112233",
"address": "БЗД, 26-р хороо, 12-р байр"
},
"paymentMethod": "QPAY"
}'Message a customer
On Messenger, Instagram and WhatsApp you can only send within 24 hours of the customer’s last message — otherwise you get 409 WINDOW_CLOSED. The message shows up in the inbox like a team reply, and the AI pauses in that conversation.
curl -X POST https://aichat.mn/api/v1/conversations/0c7d5a2e-8f1b-4c3d-9e6a-2b4f8d1c7a90/messages \
-H "Authorization: Bearer ak_live_…" \
-H "Content-Type: application/json" \
-d '{
"text": "Hi! Your order ships today."
}'Node.js
Keep the key in an environment variable and call the API from your server.
const response = await fetch('https://aichat.mn/api/v1/orders?page=1&pageSize=20', {
headers: { Authorization: `Bearer ${process.env.AICHAT_API_KEY}` }
})
if (!response.ok) throw new Error(`aichat.mn API ${response.status}`)
const { data, total } = await response.json()6Webhooks
When something happens, we send a JSON POST to your HTTPS endpoint.
- 1
Add an endpoint
In “Settings → API & webhooks”, click “Add webhook”, enter your HTTPS URL and pick the events to receive (up to 10 endpoints).
- 2
Store the secret
The signing secret is shown only once. Use “Rotate secret” whenever you need a new one.
- 3
Test it
“Send test” delivers a
pingevent. Every delivery’s response and timing are kept in the delivery log.
Events
| Event | Sent when |
|---|---|
order.created | A new order is created in chat, by your team or through the API |
order.updated | An order’s status or details change |
order.paid | An order is paid or a payment is confirmed manually |
booking.created | A new appointment is booked |
booking.updated | An appointment’s status or time changes |
contact.created | A new customer writes for the first time |
conversation.handoff | A conversation is handed off to your team |
message.received | A new message arrives from a customer |
Headers
| Header | Value |
|---|---|
X-Aichat-Signature | t=<unix seconds>,v1=<hex> — an HMAC-SHA256 signature |
X-Aichat-Event | The event name — order.paid and so on |
X-Aichat-Delivery | A unique delivery id — the same across retries |
Body example — order.paid
data has the same shape as the resource in the API — here, an order.
{
"id": "evt_5f0c2b9e1d473e8a",
"event": "order.paid",
"createdAt": "2026-11-04T09:34:52.000Z",
"tenant": {
"id": "6d2e8f4a-0b1c-4d3e-9f5a-7b8c9d0e1f2a",
"slug": "nomin-stor"
},
"data": {
"id": "9b2f6c1e-4d3a-4c55-9a43-1f2e3d4c5b6a",
"number": 1024,
"status": "CONFIRMED",
"paymentStatus": "PAID",
"paymentMethod": "QPAY",
"items": [
{
"id": "3e8a1f20-7c41-4b9e-8d5a-6f0c2b9e1d47",
"productId": "a1c4e7b2-5d8f-4a3b-9c6e-0f2d4b7a9e13",
"sku": "BAG-001",
"name": "Арьсан цүнх",
"unitPrice": 89000,
"quantity": 1,
"lineTotal": 89000
}
],
"subtotal": 89000,
"deliveryFee": 5000,
"total": 94000,
"customer": {
"name": "Болор",
"phone": "99112233",
"address": "БЗД, 26-р хороо, 12-р байр"
},
"note": "",
"source": "AI",
"attribution": "AI",
"attributedUser": null,
"contact": {
"id": "f4b8c2d6-1a3e-4f5b-8c7d-9e0a1b2c3d4e",
"externalId": "7712093384521",
"name": "Болор",
"username": null,
"avatarUrl": null
},
"conversationId": "0c7d5a2e-8f1b-4c3d-9e6a-2b4f8d1c7a90",
"channel": {
"id": "c2a7e9f1-3b5d-4c8e-a1f6-7d9b0e2c4a68",
"type": "INSTAGRAM",
"name": "nominstor",
"username": "nominstor",
"pictureUrl": null,
"status": "ACTIVE"
},
"payment": {
"id": "e7d1c3b5-9a2f-4e6d-8b0c-1f3a5e7c9b2d",
"method": "QPAY",
"amount": 94000,
"status": "PAID",
"shortUrl": "https://s.qpay.mn/…",
"qrImage": null,
"bankLinks": [],
"reference": null,
"expiresAt": "2026-11-05T09:30:12.000Z",
"paidAt": "2026-11-04T09:34:51.000Z",
"createdAt": "2026-11-04T09:30:12.000Z"
},
"createdBy": null,
"createdAt": "2026-11-04T09:30:12.000Z",
"updatedAt": "2026-11-04T09:34:51.000Z",
"paidAt": "2026-11-04T09:34:51.000Z",
"fulfilledAt": null,
"cancelledAt": null
}
}7Verifying signatures
Use the signature to check that a request came from us and wasn’t changed on the way:
- Split the
X-Aichat-Signatureheader on,to gett(unix seconds) andv1(hex). - Take the raw body bytes before parsing them and build the string
t+.+ body. - Compute the HMAC-SHA256 of that string with your webhook secret, as hex.
- Compare it with
v1using a constant-time (timing-safe) comparison. - Reject requests whose
tis more than 300 seconds away from the current time — this stops replays.
Sample code
import crypto from 'node:crypto'
import express from 'express'
const SECRET = process.env.AICHAT_WEBHOOK_SECRET
const TOLERANCE = 300 // seconds
const verify = (header, rawBody) => {
const parts = Object.fromEntries(
String(header ?? '')
.split(',')
.map(part => part.trim().split('='))
)
const timestamp = Number(parts.t)
if (!parts.v1 || !Number.isInteger(timestamp)) return false
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE) return false
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${parts.t}.`)
.update(rawBody)
.digest()
const received = Buffer.from(parts.v1, 'hex')
return received.length === expected.length && crypto.timingSafeEqual(received, expected)
}
const app = express()
// Keep the raw body: the signature covers the exact bytes that were sent.
app.post('/webhooks/aichat', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.get('X-Aichat-Signature'), req.body)) return res.sendStatus(400)
const event = JSON.parse(req.body.toString('utf8'))
// Deduplicate by event.id, queue the work and answer quickly.
res.sendStatus(200)
})
app.listen(3000)import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["AICHAT_WEBHOOK_SECRET"].encode()
TOLERANCE = 300 # seconds
def verify(header: str, raw_body: bytes) -> bool:
parts = dict(item.strip().split("=", 1) for item in header.split(",") if "=" in item)
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > TOLERANCE:
return False
signed = parts["t"].encode() + b"." + raw_body
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
app = Flask(__name__)
@app.post("/webhooks/aichat")
def aichat_webhook():
# request.get_data() is the raw body: the signature covers the exact bytes that were sent.
if not verify(request.headers.get("X-Aichat-Signature", ""), request.get_data()):
abort(400)
event = request.get_json()
# Deduplicate by event["id"], queue the work and answer quickly.
return "", 200<?php
$secret = getenv('AICHAT_WEBHOOK_SECRET');
$tolerance = 300; // seconds
// The raw body: the signature covers the exact bytes that were sent.
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_AICHAT_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $item) {
$pair = explode('=', trim($item), 2);
if (count($pair) === 2) {
$parts[$pair[0]] = $pair[1];
}
}
$timestamp = isset($parts['t']) ? (int) $parts['t'] : 0;
$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $payload, $secret);
if (abs(time() - $timestamp) > $tolerance || !hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(400);
exit;
}
$event = json_decode($payload, true);
// Deduplicate by $event['id'], queue the work and answer quickly.
http_response_code(200);8Delivery and retries
- Answer
2xxas soon as you receive the request and do slow work afterwards — within 10 seconds. - If we don’t get a
2xxresponse, we retry up to 6 times — after 5 s, 30 s, 2 min, 10 min, 30 min, 1 h (7 attempts in total). - The same event can arrive more than once — deduplicate by the body’s
id. - Order isn’t guaranteed — sort by
createdAt. - Endpoints that fail 50 times in a row are disabled automatically — fix the problem and re-enable them in the dashboard.
- Endpoints must be HTTPS and reachable from the internet — we never deliver to private network IPs.
- The delivery log is kept for 30 days.
9Partner API (delivery)
A separate API for delivery companies. It doesn’t use an organization’s ak_live_… key but a dp_live_… key issued by the platform, and returns deliveries from every shop that chose that company.
- Base URL:
https://aichat.mn/api/partner/v1 - Authentication:
Authorization: Bearer dp_live_…— the platform shows the key only once - Rate limit: 300 requests per minute per partner
- Webhooks:
delivery.created,delivery.cancelled— same signature scheme as above
| Method | Path | Description |
|---|---|---|
| GET | /me | Your company profile — status, number of connected shops and rate limit |
| GET | /deliveries | Deliveries — filter by status, updatedSince (ISO date), paginate with page, pageSize |
| GET | /deliveries/:id | One delivery — shop, pickup and drop-off, items, cash to collect, status history |
| POST | /deliveries/:id/accept | Accept the delivery (PENDING → ACCEPTED) |
| POST | /deliveries/:id/reject | Reject — reason required (PENDING → REJECTED) |
| POST | /deliveries/:id/status | Update status, courier, tracking, ETA and delivery fee |
Status flow, webhooks, sample code and how to become a partner: For delivery companies