SafqatAPI Reference
Get your API key

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/v1

The 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

FieldTypeDescription
400Bad RequestInvalid JSON, id, or field value.
401UnauthorizedMissing or invalid token.
403ForbiddenToken's account is not on an active Pro plan.
404Not FoundThe resource does not exist, or is not yours.
429Too Many RequestsRate 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

GET/conversations

Returns your WhatsApp and web-widget conversations in one list, most recently updated first. Message bodies are omitted unless you ask for them.

Query parameters

FieldTypeDescription
channelenumwhatsapp | web | all. Default all.
statusenumactive | expired | closed | idle | all. expired is WhatsApp-only, idle is web-only.
escalatedbooleanOnly threads escalated to a human.
includeMessagesbooleanInclude every message body. Caps the page at 25.
limitnumberThreads per page. Default 20, max 100 (25 with messages).
cursorstringISO date from the previous page's nextCursor.
updatedSincestringISO 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

GET/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

FieldTypeDescription
messagesFromnumberIndex of the first message to return. Default 0.
messagesLimitnumberMessages 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

GET/templates

Every 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

GET/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

POST/templates

Submit 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

FieldTypeDescription
name*stringLowercase letters, digits and underscores; hyphens are converted.
languagestringMeta locale code (ar, en_US, es_MX). Default en_US.
category*enumutility | marketing.
body*string≤ 1024 chars. Placeholders {{1}}, {{2}}… must be sequential, and the body must not start or end with one.
footerstring | nullOptional, ≤ 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

GET/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.

POST/webhooksGET/webhooks

Body parameters

FieldTypeDescription
url*stringPublic https endpoint. Localhost, private IPs and embedded credentials are refused.
eventsstring[]Event types to receive. Empty or omitted = all.
descriptionstringOptional label, ≤ 200 chars.
activebooleanDefault 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

FieldTypeDescription
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-secretNew signing secret, returned once, used from the next delivery.
POST /webhooks/{id}/testSend a webhook.test event now and return your server's response status.
GET /webhooks/{id}/deliveriesRecent deliveries with the full event body. Filter status=pending|succeeded|failed, page with limit + cursor.
POST /webhooks/{id}/deliveries/{deliveryId}/retryReplay 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

FieldTypeDescription
message.receivedeventA customer (WhatsApp) or visitor (web) message was saved. Carries conversationId, channel, customer and the message.
message.statuseventA Meta receipt moved an outbound message to sent, delivered, read or failed. Keyed by waMessageId.
conversation.escalatedeventThe bot handed a thread to a human. Carries urgency, reason, customer.
template.statuseventMeta changed a template's review state: approved | rejected | flagged | paused.
template.qualityeventMeta changed a template's quality score: green | yellow | red | unknown. Carries the previous score too.
number.qualityeventYour 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.testeventSent 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

GET/products

Returns a paginated list of your products, newest first.

Query parameters

FieldTypeDescription
pagenumberPage number, 1-based. Default 1.
pageSizenumberItems 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

GET/products/{id}

Retrieve a single product by id.

curl "https://safqat.ai/api/v1/products/6650f1a2c3d4e5f6a7b8c9d0" \
  -H "Authorization: Bearer $SAFQAT_TOKEN"

Create a product

POST/products

Create a new product. Returns the created product (201).

Body parameters

FieldTypeDescription
name*stringDisplay name of the product.
price*numberPrice, must be ≥ 0.
descriptionstringOptional description.
currencystringISO currency code. Defaults to your agent's currency or SAR.
paymentPeriodenumone_time | monthly | yearly. Defaults to one_time.
imageUrlstring | nullThe 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.
mediaUrlstring | nullThe 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.
mediaTypeenum | nullimage | 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.
checkoutUrlstring | nullDirect http(s) checkout/purchase link.
categorystringOptional category label.
isActivebooleanWhether 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

PATCH/products/{id}

Update one or more fields of an existing product. Only the fields you send are changed.

Body parameters (all optional)

FieldTypeDescription
name*stringDisplay name of the product.
price*numberPrice, must be ≥ 0.
descriptionstringOptional description.
currencystringISO currency code. Defaults to your agent's currency or SAR.
paymentPeriodenumone_time | monthly | yearly. Defaults to one_time.
imageUrlstring | nullThe 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.
mediaUrlstring | nullThe 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.
mediaTypeenum | nullimage | 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.
checkoutUrlstring | nullDirect http(s) checkout/purchase link.
categorystringOptional category label.
isActivebooleanWhether 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

DELETE/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" }
Safqat API Reference | Safqat AI