Introduction
The Safqat API lets you manage your data programmatically. It is a REST API that accepts and returns JSON. The base URL is:
https://safqat.ai/api/v1The API is available on the Pro plan. This reference covers Products, Conversations, WhatsApp Templates, per-message Delivery status and Webhooks. Files cannot be uploaded — pass direct http(s) links for images and media.
Authentication
Authenticate every request with a bearer token created in your Developer settings. Keep it secret — it grants full access to your account's products. The token is shown only once, at creation.
curl https://safqat.ai/api/v1/products \
-H "Authorization: Bearer sqt_live_xxxxxxxxxxxxxxxxxxxx"Requests without a valid token return 401. Tokens for accounts that are not on an active Pro plan return 403.
Rate limits
Each token is limited to 60 requests per minute and 2,000 requests per day. Every response includes RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until reset) headers. When you exceed a limit the API responds with 429 and a Retry-After header.
The Conversations endpoints carry an extra limit, because they return whole message threads: the first 100 requests go through freely, and after that the token is held to one request per minute for the next 4 hours. The allowance then resets to 100. Pull your export in one pass and use updatedSince for incremental syncs rather than polling.
Errors
Errors use conventional HTTP status codes and a consistent JSON body:
{
"error": {
"code": "invalid_field",
"message": "price is required and must be a number >= 0."
}
}Status codes
| Field | Type | Description |
|---|---|---|
| 400 | Bad Request | Invalid JSON, id, or field value. |
| 401 | Unauthorized | Missing or invalid token. |
| 403 | Forbidden | Token's account is not on an active Pro plan. |
| 404 | Not Found | The resource does not exist, or is not yours. |
| 429 | Too Many Requests | Rate limit exceeded — see Retry-After. |
Pagination
List endpoints are paginated with page (1-based) and pageSize (default 20, max 100) query parameters. The response includes a pagination object.
{
"data": [ /* ... */ ],
"pagination": {
"page": 1,
"pageSize": 20,
"total": 42,
"totalPages": 3,
"hasMore": true
}
}Conversations are the exception: because the list merges two channels, it pages with limit and a cursor. Pass the pagination.nextCursor of a page as the cursor of the next request, and stop when hasMore is false.
List conversations
/conversationsReturns your WhatsApp and web-widget conversations in one list, most recently updated first. Message bodies are omitted unless you ask for them.
Query parameters
| Field | Type | Description |
|---|---|---|
| channel | enum | whatsapp | web | all. Default all. |
| status | enum | active | expired | closed | idle | all. expired is WhatsApp-only, idle is web-only. |
| escalated | boolean | Only threads escalated to a human. |
| includeMessages | boolean | Include every message body. Caps the page at 25. |
| limit | number | Threads per page. Default 20, max 100 (25 with messages). |
| cursor | string | ISO date from the previous page's nextCursor. |
| updatedSince | string | ISO date. Only threads updated at or after it — for incremental syncs. |
curl "https://safqat.ai/api/v1/conversations?limit=50&channel=all" \
-H "Authorization: Bearer $SAFQAT_TOKEN"{
"data": [
{
"id": "6a91eb2e0be7943582e4f1b6",
"channel": "whatsapp",
"status": "active",
"customer": {
"name": "Sara",
"phone": "+9627xxxxxxx",
"email": null,
"visitorId": null
},
"agentId": "6650f1a2c3d4e5f6a7b8c9d0",
"leadScore": "hot",
"intent": "pricing",
"botPaused": false,
"favorite": false,
"escalated": false,
"escalatedAt": null,
"escalationUrgency": null,
"escalationReason": null,
"messageCount": 9,
"lastMessageAt": "2026-08-28T20:11:37.852Z",
"lastCustomerMessageAt": "2026-08-28T20:10:02.000Z",
"createdAt": "2026-08-28T19:40:00.000Z",
"updatedAt": "2026-08-28T20:11:37.852Z"
}
],
"pagination": {
"limit": 50,
"count": 50,
"hasMore": true,
"nextCursor": "2026-08-28T19:33:58.381Z"
}
}Get a conversation
/conversations/{id}Retrieve one thread with its messages, by id. Works for both channels — you do not need to know which one the thread belongs to.
Query parameters
| Field | Type | Description |
|---|---|---|
| messagesFrom | number | Index of the first message to return. Default 0. |
| messagesLimit | number | Messages to return. Default 500, max 1000. |
curl "https://safqat.ai/api/v1/conversations/6a91eb2e0be7943582e4f1b6" \
-H "Authorization: Bearer $SAFQAT_TOKEN"{
"id": "6a91eb2e0be7943582e4f1b6",
"channel": "whatsapp",
"status": "active",
"customer": { "name": "Sara", "phone": "+9627xxxxxxx", "email": null, "visitorId": null },
"messageCount": 9,
"messages": [
{
"index": 0,
"role": "customer",
"type": "text",
"text": "Hello, how much is it?",
"attachments": [],
"timestamp": "2026-08-28T19:40:00.000Z",
"waMessageId": "wamid.HBg..."
},
{
"index": 1,
"role": "agent",
"type": "text",
"text": "It is 199 SAR.",
"attachments": [],
"timestamp": "2026-08-28T19:40:12.000Z",
"waMessageId": null
}
],
"messagesPagination": { "from": 0, "count": 9, "hasMore": false }
}List templates
/templatesEvery template on your WhatsApp Business Account with its live Meta status and category. Never served from a stored copy — Meta changes both after approval without notice. Optional ?status=approved|pending|rejected|paused.
curl "https://safqat.ai/api/v1/templates?status=approved" \
-H "Authorization: Bearer $SAFQAT_TOKEN"{
"data": [
{
"id": "6a32de314d62861800311c8d",
"name": "followup_new_6min",
"language": "ar",
"category": "marketing",
"status": "approved",
"rejectionReason": null,
"body": "مرحبًا {{1}}، ...",
"placeholderCount": 1,
"metaTemplateId": "1006561815667426",
"createdAt": "2026-06-17T17:49:37.273Z"
}
]
}Template status
/templates/{name}?language={code}One template's live review state. language is required — a name can exist in several languages.
curl "https://safqat.ai/api/v1/templates/followup_new_6min?language=ar" \
-H "Authorization: Bearer $SAFQAT_TOKEN"Create a template
/templatesSubmit a template to Meta for review (201, status pending). Body and footer only for now — headers and buttons return unsupported_component. Meta's own rejection comes back as 400 meta_rejected with its reason. Limit: 50 submissions per 24 hours.
Body parameters
| Field | Type | Description |
|---|---|---|
| name* | string | Lowercase letters, digits and underscores; hyphens are converted. |
| language | string | Meta locale code (ar, en_US, es_MX). Default en_US. |
| category* | enum | utility | marketing. |
| body* | string | ≤ 1024 chars. Placeholders {{1}}, {{2}}… must be sequential, and the body must not start or end with one. |
| footer | string | null | Optional, ≤ 60 chars, no placeholders. |
| examples* | string[] | One sample value per placeholder — Meta reviews the template with them. Omit only when there are no placeholders. |
curl -X POST "https://safqat.ai/api/v1/templates" \
-H "Authorization: Bearer $SAFQAT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "order_update_v2",
"language": "ar",
"category": "utility",
"body": "مرحبًا {{1}}، طلبك رقم {{2}} في الطريق إليك.",
"footer": "رد بـ STOP لإيقاف الرسائل",
"examples": ["سارة", "A-1042"]
}'Delivery status of a message
/messages/{wamid}Where an outbound WhatsApp message got to — sent, delivered, read or failed — keyed by the wamid returned when it was sent. Receipts arrive from Meta seconds to minutes after a send, so a fresh message is 404 until the first one lands. Status never moves backwards. Records are kept 90 days; tracking began 2026-09-03.
curl "https://safqat.ai/api/v1/messages/wamid.HBgMOTYzOTk3MjQwOTQ4FQIAERgSRkRFMzI5RDI0NDA4OEIwRUJGAA==" \
-H "Authorization: Bearer $SAFQAT_TOKEN"{
"waMessageId": "wamid.HBgMOTYz...",
"conversationId": "6a99e712391178d2257403af",
"status": "read",
"recipientPhone": "+963997240948",
"sentAt": "2026-09-03T21:30:00.000Z",
"deliveredAt": "2026-09-03T21:30:04.000Z",
"readAt": "2026-09-03T21:31:00.000Z",
"failedAt": null,
"error": null,
"lastEventAt": "2026-09-03T21:31:00.000Z"
}Webhooks
Register an https endpoint and Safqat POSTs events to it as they happen — instead of polling. Create returns the signing secret exactly once. Up to 5 endpoints per account.
/webhooksGET/webhooksBody parameters
| Field | Type | Description |
|---|---|---|
| url* | string | Public https endpoint. Localhost, private IPs and embedded credentials are refused. |
| events | string[] | Event types to receive. Empty or omitted = all. |
| description | string | Optional label, ≤ 200 chars. |
| active | boolean | Default true. Setting true on PATCH also clears an auto-disable. |
curl -X POST "https://safqat.ai/api/v1/webhooks" \
-H "Authorization: Bearer $SAFQAT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your.server/safqat/webhook",
"events": ["message.received", "message.status", "conversation.escalated", "template.status"],
"description": "production"
}'{
"id": "6a99e834d4c989e8f718f849",
"url": "https://your.server/safqat/webhook",
"events": ["message.received", "message.status", "conversation.escalated", "template.status"],
"description": "production",
"active": true,
"disabledAt": null,
"disabledReason": null,
"consecutiveFailures": 0,
"lastDeliveryAt": null,
"lastSuccessAt": null,
"lastFailureAt": null,
"createdAt": "2026-09-03T21:35:49.000Z",
"updatedAt": "2026-09-03T21:35:49.000Z",
"secret": "whsec_..."
}Managing an endpoint
| Field | Type | Description |
|---|---|---|
| GET /webhooks/{id} | One endpoint with health counters (never the secret). | |
| PATCH /webhooks/{id} | Change url, events, description or active. | |
| DELETE /webhooks/{id} | Remove the endpoint; past deliveries stay 30 days. | |
| POST /webhooks/{id}/rotate-secret | New signing secret, returned once, used from the next delivery. | |
| POST /webhooks/{id}/test | Send a webhook.test event now and return your server's response status. | |
| GET /webhooks/{id}/deliveries | Recent deliveries with the full event body. Filter status=pending|succeeded|failed, page with limit + cursor. | |
| POST /webhooks/{id}/deliveries/{deliveryId}/retry | Replay one delivery now, even after its retries were used up. |
Respond 2xx within 5 seconds and do the work afterwards. Failures are retried after 30s, 2m, 10m, 30m, 1h, 3h, 6h and 12h (retries run from a scheduler every ~2 minutes); after that the delivery is marked failed and stays replayable for 30 days. Delivery is at-least-once — dedupe on the event id; ordering is not guaranteed. An endpoint that fails 100 times in a row is switched off until you PATCH it active again. Redirects are not followed.
Webhook events
Every delivery is a JSON body with a stable id, the event type, when it happened, and a data block. Headers: X-Safqat-Event, X-Safqat-Delivery, X-Safqat-Signature.
POST https://your.server/safqat/webhook
Content-Type: application/json
X-Safqat-Event: message.received
X-Safqat-Delivery: 6a99ec39d4c989e8f718f885
X-Safqat-Signature: t=1788471351,v1=4d56041123f4...
{
"id": "evt_WMBADFTQMST0_aAQ2IYZxg",
"type": "message.received",
"createdAt": "2026-09-03T21:35:51.448Z",
"data": {
"conversationId": "6a99da25391178d22573f516",
"channel": "whatsapp",
"customer": { "name": "Sara", "phone": "+9647xxxxxxx", "email": null, "visitorId": null },
"message": {
"index": 1,
"role": "customer",
"type": "text",
"text": "كم السعر؟",
"attachments": [],
"timestamp": "2026-09-03T21:35:50.000Z",
"waMessageId": "wamid.HBgN..."
}
}
}Event types
| Field | Type | Description |
|---|---|---|
| message.received | event | A customer (WhatsApp) or visitor (web) message was saved. Carries conversationId, channel, customer and the message. |
| message.status | event | A Meta receipt moved an outbound message to sent, delivered, read or failed. Keyed by waMessageId. |
| conversation.escalated | event | The bot handed a thread to a human. Carries urgency, reason, customer. |
| template.status | event | Meta changed a template's review state: approved | rejected | flagged | paused. |
| template.quality | event | Meta changed a template's quality score: green | yellow | red | unknown. Carries the previous score too. |
| number.quality | event | Your WhatsApp number's quality rating (green | yellow | red) or messaging tier changed. outreachAutoPaused is true when we disabled API Triggers for a RED rating. |
| webhook.test | event | Sent only by POST /webhooks/{id}/test. |
// message.status
{ "waMessageId": "wamid.…", "conversationId": "…", "status": "delivered",
"timestamp": "…", "recipientPhone": "+963…", "error": null }
// conversation.escalated
{ "conversationId": "…", "channel": "whatsapp", "urgency": "high",
"reason": "…", "escalatedAt": "…", "customer": { "name": "…", "phone": "…", "email": null, "visitorId": null } }
// template.status
{ "name": "order_update_v2", "language": "ar", "status": "approved", "reason": null, "metaTemplateId": "…" }
// template.quality
{ "name": "order_update_v2", "language": "ar", "qualityScore": "yellow", "previousQualityScore": "green", "metaTemplateId": "…" }
// number.quality
{ "phoneNumber": "+971 55 …", "qualityRating": "red", "previousQualityRating": "green",
"messagingTier": "TIER_1K", "previousMessagingTier": "TIER_1K", "tierLimit": 1000, "outreachAutoPaused": true }Verifying signatures
X-Safqat-Signature is t=<unix seconds>,v1=<hex>. Compute HMAC-SHA256 of "<t>.<raw body>" with the endpoint secret, compare in constant time, and reject timestamps older than 5 minutes. Use the raw request bytes, not re-serialised JSON.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySafqatSignature(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return (
expected.length === parts.v1.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
);
}List products
/productsReturns a paginated list of your products, newest first.
Query parameters
| Field | Type | Description |
|---|---|---|
| page | number | Page number, 1-based. Default 1. |
| pageSize | number | Items per page. Default 20, max 100. |
curl "https://safqat.ai/api/v1/products?page=1&pageSize=20" \
-H "Authorization: Bearer $SAFQAT_TOKEN"{
"data": [
{
"id": "6650f1a2c3d4e5f6a7b8c9d0",
"name": "Premium Plan",
"description": "Annual subscription",
"price": 499,
"currency": "SAR",
"paymentPeriod": "yearly",
"imageUrl": "https://cdn.example.com/plan.png",
"mediaUrl": null,
"mediaType": null,
"checkoutUrl": "https://example.com/buy/premium",
"category": "subscriptions",
"isActive": true,
"createdAt": "2026-06-01T10:00:00.000Z"
}
],
"pagination": { "page": 1, "pageSize": 20, "total": 1, "totalPages": 1, "hasMore": false }
}Get a product
/products/{id}Retrieve a single product by id.
curl "https://safqat.ai/api/v1/products/6650f1a2c3d4e5f6a7b8c9d0" \
-H "Authorization: Bearer $SAFQAT_TOKEN"Create a product
/productsCreate a new product. Returns the created product (201).
Body parameters
| Field | Type | Description |
|---|---|---|
| name* | string | Display name of the product. |
| price* | number | Price, must be ≥ 0. |
| description | string | Optional description. |
| currency | string | ISO currency code. Defaults to your agent's currency or SAR. |
| paymentPeriod | enum | one_time | monthly | yearly. Defaults to one_time. |
| imageUrl | string | null | The product's photo (a still image). Direct http(s) link only — no uploads. If mediaUrl is not set, this is the image the agent sends when presenting the product. |
| mediaUrl | string | null | The attachment the agent sends when presenting the product — takes priority over imageUrl. Direct http(s) link only; can be an image, video, or voice file. Set mediaType to match it. |
| mediaType | enum | null | image | video | voice — tells the agent how to deliver mediaUrl. Always set it together with mediaUrl; for video or voice it is required, otherwise the media is not sent. |
| checkoutUrl | string | null | Direct http(s) checkout/purchase link. |
| category | string | Optional category label. |
| isActive | boolean | Whether the product is active. Defaults to true. |
curl -X POST "https://safqat.ai/api/v1/products" \
-H "Authorization: Bearer $SAFQAT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Premium Plan",
"price": 499,
"currency": "SAR",
"paymentPeriod": "yearly",
"imageUrl": "https://cdn.example.com/plan.png",
"checkoutUrl": "https://example.com/buy/premium"
}'Update a product
/products/{id}Update one or more fields of an existing product. Only the fields you send are changed.
Body parameters (all optional)
| Field | Type | Description |
|---|---|---|
| name* | string | Display name of the product. |
| price* | number | Price, must be ≥ 0. |
| description | string | Optional description. |
| currency | string | ISO currency code. Defaults to your agent's currency or SAR. |
| paymentPeriod | enum | one_time | monthly | yearly. Defaults to one_time. |
| imageUrl | string | null | The product's photo (a still image). Direct http(s) link only — no uploads. If mediaUrl is not set, this is the image the agent sends when presenting the product. |
| mediaUrl | string | null | The attachment the agent sends when presenting the product — takes priority over imageUrl. Direct http(s) link only; can be an image, video, or voice file. Set mediaType to match it. |
| mediaType | enum | null | image | video | voice — tells the agent how to deliver mediaUrl. Always set it together with mediaUrl; for video or voice it is required, otherwise the media is not sent. |
| checkoutUrl | string | null | Direct http(s) checkout/purchase link. |
| category | string | Optional category label. |
| isActive | boolean | Whether the product is active. Defaults to true. |
curl -X PATCH "https://safqat.ai/api/v1/products/6650f1a2c3d4e5f6a7b8c9d0" \
-H "Authorization: Bearer $SAFQAT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "price": 449, "isActive": false }'Delete a product
/products/{id}Permanently delete a product.
curl -X DELETE "https://safqat.ai/api/v1/products/6650f1a2c3d4e5f6a7b8c9d0" \
-H "Authorization: Bearer $SAFQAT_TOKEN"{ "deleted": true, "id": "6650f1a2c3d4e5f6a7b8c9d0" }