REST API · v1
REST API
What your own system does with Seler: read the people it talks to and what was said, add people, and send — a plain reply into an open thread, or an approved WhatsApp template to any number. JSON over HTTPS at https://api.seler.app/v1.
Authentication
Every request carries a key the workspace's owner made in the dashboard (Settings › التكاملات › مفاتيح API). The key is shown once; Seler keeps only its hash.
Authorization: Bearer sk_live_…- A key opens only
/v1routes. Sending it to any other route answers401; sending a dashboard session to/v1answers401too. The two doors do not meet. - The key names the workspace. There is no workspace id to pass, and no way to name another workspace.
- Revoking is immediate: the next request with a revoked key answers
401. - Keys belong to the workspace, not to the person who made them, with one exception:
POST /v1/messages(a plain reply) is attributed to that person, and stops working if they leave the workspace. Templates carry no author and keep working.
Scopes
Give a key the scopes its job needs and nothing more.
| contacts:read | read | List and look up contacts. |
| contacts:write | write | Add contacts, rename them. |
| conversations:read | read | List conversations and read their messages. |
| messages:send | write | List templates; send plain messages, WhatsApp notices and one-time codes. |
A call with a key that lacks the route's scope answers 403 and names the scope it wanted.
Rate limits
600 requests per minute per key, counted in a fixed one-minute window. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; the first request over the limit answers 429 with Retry-After in seconds.
Errors
Plain HTTP status codes and one JSON shape. message is written for the developer; it is never shown to a customer.
{ "statusCode": 403, "message": "This key does not have the \"messages:send\" scope", "error": "Forbidden" }| 400 | Bad request | A field failed validation; the message says which. |
| 401 | Unauthorized | No key, a revoked key, or a key on a route keys cannot open. |
| 403 | Forbidden | The key lacks the scope. |
| 404 | Not found | No such contact, conversation or template in this workspace. |
| 409 | Conflict | The action is not possible right now — no WhatsApp number connected, plan limit reached, key's author gone. |
| 422 | Unprocessable | The request was understood and WhatsApp refused it, or an Idempotency-Key was reused for a different request. |
| 429 | Too many requests | Rate limit. Wait Retry-After seconds. |
A refusal of a template send also carries code, a fixed word a program can branch on, and details naming what was wanted — see the codes under that route.
Pagination
Lists return at most limit rows (default 50, at most 100) and a nextCursor. Pass it back as cursor to get the next page; a null cursor means there is no more. Cursors are opaque; do not build them.
Phone numbers
Wherever a number goes in, Seler accepts it as an Iraqi customer would write it: 07701234567, +964 770 123 4567, 9647701234567, Arabic-Indic digits included. Numbers come back in E.164 (+9647701234567).
Who am I
/v1/mecontacts:readThe workspace (team) and scopes behind the key. Use it to check a key works.
{
"team": { "id": "cad8e8c6-…", "name": "متجر العطور" },
"key": { "id": "f9597076-…", "scopes": ["contacts:read", "messages:send"] }
}Contacts
A contact is a person the team knows: someone who wrote in on any channel, or someone the team added — from a file, or through this API.
{
"id": "3acd889a-ea2d-4047-89af-572e6081b665",
"name": "زينب الحسيني",
"phone": "+9647701234567",
"avatarUrl": null,
"channels": ["whatsapp"],
"tags": ["عميل محتمل"],
"conversations": 3,
"source": "messaged",
"lastActiveAt": "2026-09-13T18:02:11.000Z",
"createdAt": "2026-08-01T09:12:44.000Z"
}| name | string | null | The team's own name for them, or the one they gave. |
| phone | string | null | E.164. Null for a Messenger or Instagram contact who never shared one. |
| channels | string[] | Every channel they have written from: whatsapp, messenger, instagram. |
| tags | string[] | Tags on any of their conversations. |
| source | messaged | import | Whether they wrote in, or were added by the team (file or API). |
| lastActiveAt | ISO 8601 | null | Their last message. Null for someone who never wrote. |
/v1/contactscontacts:read| phone | query | Exact match, any spelling. The usual way a system finds a person. |
| search | query | Partial name or number. |
| cursor, limit | query | Pagination. |
curl "https://api.seler.app/v1/contacts?phone=07701234567" -H "Authorization: Bearer sk_live_…"{ "rows": [ { "id": "3acd889a-…", "name": "زينب الحسيني", "phone": "+9647701234567", … } ], "nextCursor": null, "total": 1 }/v1/contacts/{id}contacts:readOne contact. 404 when the id is not in this workspace.
/v1/contactscontacts:writeAdds a person by number, or returns the one the team already knows. Idempotent: calling it twice with the same number returns the same contact. A person the team already knows keeps their name and history; a name in the request only fills a blank.
{ "phone": "07701234567", "name": "زينب الحسيني" }{ "id": "3acd889a-…", "name": "زينب الحسيني", "phone": "+9647701234567", "source": "import", … }/v1/contacts/{id}contacts:write{ "name": "زينب الحسيني" }Returns the updated contact.
Conversations
{
"id": "33333333-0000-4000-8000-000000000004",
"contactId": "3acd889a-…",
"channel": "whatsapp",
"status": "open",
"assignedTo": "59b5d09f-…",
"group": { "id": "8f2c61d0-…", "name": "المبيعات" },
"lastMessageAt": "2026-09-13T18:02:11.000Z",
"createdAt": "2026-09-13T17:40:02.000Z"
}| status | bot | open | closed | bot: the assistant is answering. open: a person on the team holds it. closed: finished. |
| assignedTo | uuid | null | The person holding it, when open. |
| group | object | null | The team inside the workspace it was sent to — sales, support — with its id and current name. It stays with the thread when it closes or goes back to the assistant. null when it waits for anyone. |
/v1/conversationsconversations:read| status | query | bot, open or closed. |
| contactId | query | Only this person's threads. |
| groupId | query | Only threads sent to this team. Its id comes from a conversation's group, or from the conversation.sent_to_group webhook. |
| since | query | ISO 8601. Only threads opened at or after this instant. |
| cursor, limit | query | Newest first. |
{ "data": [ { "id": "…", "channel": "whatsapp", "status": "open", … } ], "nextCursor": "MjAyNi0w…" }/v1/conversations/{id}conversations:readOne conversation.
/v1/conversations/{id}/messagesconversations:readMessages, newest first, paged with cursor.
{
"data": [
{
"id": "…",
"conversationId": "…",
"role": "customer",
"senderId": null,
"content": "كم سعر هذا؟",
"attachments": [ { "kind": "image", "url": "https://…signed…", "contentType": "image/jpeg", "bytes": 84213, "title": null } ],
"transcription": null,
"createdAt": "2026-09-13T18:02:11.000Z",
…
}
],
"nextCursor": null
}| role | customer | assistant | team | system | Who wrote it. system rows are the thread's own notices (assigned, closed…). |
| attachments[].url | signed URL | Expires. Fetch it when you receive it; do not store the link. |
| transcription | string | null | A voice note, as words. |
Sending
/v1/messagesmessages:sendA plain message into an existing conversation, as a person on the team. Goes through the same door as a reply typed in the inbox: the message is recorded, the plan is checked, then it is sent.
{ "conversationId": "33333333-…", "text": "طلبك جاهز للاستلام 🌸" }{ "conversationId": "33333333-…", "message": { "id": "…", "role": "team", "content": "طلبك جاهز للاستلام 🌸", … } }/v1/messages/templatemessages:sendAn approved WhatsApp template to any number — the thing a system needs that a person rarely does: “your order shipped”, “your appointment is tomorrow”, “your code is 482913”. For a notice, the person is added to the workspace's contacts if new, the thread is opened or reused so the send shows in the inbox, and the plan is checked like any other send. A one-time code is different: it goes to WhatsApp and is kept nowhere in Seler — no contact, no thread, no message — so the inbox is not filled with codes and nobody on the team reads them. Its delivery webhooks still arrive. The same is true of anything sent from a number the workspace made send-only.
{
"to": "07701234567",
"name": "زينب الحسيني",
"template": "order_shipped",
"variables": { "اسم العميل": "زينب", "رقم الطلب": "1042" },
"reference": "order-1042"
}{
"to": "07701234567",
"template": "verify_code",
"code": "482913",
"reference": "login-attempt-7f3a"
}| to | string | The number, any spelling. |
| name | string | Optional. Used only if Seler does not know the person yet. Ignored for a one-time code, which adds nobody. |
| template | string | The approved template's name, as in Settings › قوالب واتساب and GET /v1/templates. |
| language | string | Optional. Defaults to the approved template's language. |
| from | string | Optional. The WhatsApp number it goes from, any spelling, or its phone number id at Meta. It must be one of the template's numbers in GET /v1/templates. Absent: the first connected number of the template's WhatsApp account. |
| variables | object | string[] | The body's values: an object keyed by the names the template was written with, or one string per {{n}} in order. Not for authentication templates. |
| code | string | Authentication templates only: the one-time code, letters, digits and dashes, up to 15. Fills the body and the copy button. |
| header | object | { text } for a text header with a variable; { link, filename? } for an image, video or document header — an https link WhatsApp can fetch. |
| buttonVariables | string[] | One per URL button whose link ends in {{1}}, in button order. |
| reference | string | Optional, up to 128 characters. Your own id for this send. Kept on the message and echoed in every webhook about it. |
Idempotency
Send Idempotency-Key (any string, up to 200 characters) and a repeat of the same request within 24 hours answers with the first result and sends nothing. The same key with a different body answers 422 idempotency_key_reused. A refused request does not use up its key. Use it on every code you send: a timeout then means one message, not two.
Idempotency-Key: login-attempt-7f3a{
"id": "5d2e…", // the message; delivery webhooks name it
"status": "sent", // accepted by WhatsApp
"to": "+9647701234567",
"from": "+964 770 000 0001", // the number it went from
"contactId": "3acd…",
"conversationId": "33333333-…",
"reference": "login-attempt-7f3a",
"message": { "id": "5d2e…", "role": "team", "content": "*482913* هو رمز التحقق الخاص بك. …", "meta": { "template": { "name": "verify_code", "language": "ar" }, "reference": "login-attempt-7f3a", "via": "api" }, … }
}{
"id": "9b41…", // delivery webhooks name it
"status": "sent",
"to": "+9647701234567",
"from": "+964 770 000 0001",
"contactId": null, // a code is kept nowhere
"conversationId": null,
"reference": "login-attempt-7f3a",
"message": null
}sent means WhatsApp took the message. Whether it reached the phone comes later, as message.delivered, message.read or message.failed, each carrying your reference.
Refusals
{
"statusCode": 400,
"error": "Bad Request",
"code": "variables_mismatch",
"message": "Template \"order_shipped\" takes 2 variables: اسم العميل, رقم الطلب.",
"details": { "variables": ["اسم العميل", "رقم الطلب"] }
}| invalid_phone | 400 | Not a number Seler recognises. |
| variables_mismatch | 400 | Wrong count or an unknown name. details.variables lists the names wanted. |
| variable_empty | 400 | A variable is blank; WhatsApp refuses those. details.variable names it. |
| code_required · invalid_code | 400 | An authentication template without a code, or a code outside letters, digits and dashes. |
| header_required · header_not_allowed | 400 | The template's header wants a value it did not get, or got one it has no place for. |
| button_variables_mismatch | 400 | The URL buttons with a variable and the values sent for them differ in count. |
| unknown_sender | 400 | from names no WhatsApp number connected to the workspace. details.connected lists the ones that are. |
| template_not_found | 404 | No template by that name. GET /v1/templates lists them. |
| template_not_approved | 409 | It exists but is pending, rejected or paused. details.status says which. |
| template_not_on_number | 409 | from is a number of another WhatsApp account, which cannot send this template. GET /v1/templates lists the numbers that can. |
| no_whatsapp_channel | 409 | The workspace has no WhatsApp number connected — or none of the account that holds the template. |
| plan_limit | 409 | The plan's active-customer limit is reached, or the subscription is paused. details.reason says which. |
| whatsapp_rejected | 422 | WhatsApp refused the send; the message carries their words and details.whatsappCode their code. |
| idempotency_key_reused | 422 | The key was used for a different request. |
| request_in_flight | 409 | The first request with this key has not finished. Retry in a moment. |
Templates
Templates are written and submitted for WhatsApp's review in the dashboard (Settings › قوالب واتساب). This route tells your system what each one needs, so it never has to guess a variable name.
A template belongs to one WhatsApp business account, and only that account's numbers can send it. A workspace with numbers from two accounts may have a template of the same name in each; both are listed, each with its account and the numbers that can send it.
/v1/templatesmessages:sendEvery template the workspace has, with its status. Filter with ?status=approved or ?category=AUTHENTICATION.
{
"data": [
{
"name": "order_shipped",
"language": "ar",
"category": "UTILITY",
"status": "approved",
"body": "مرحباً {{1}}، طلبك رقم {{2}} في الطريق.",
"footer": null,
"header": { "format": "NONE", "text": null, "required": false },
"variables": [ { "label": "اسم العميل", "kind": "name" }, { "label": "رقم الطلب", "kind": "text" } ],
"buttons": [ { "type": "QUICK_REPLY", "text": "غيّر العنوان", "variable": false } ],
"authentication": null,
"rejectionReason": null,
"account": "104934…",
"numbers": ["+964 770 000 0001", "+964 770 000 0002"]
},
{
"name": "verify_code",
"language": "ar",
"category": "AUTHENTICATION",
"status": "approved",
"body": "*{{1}}* هو رمز التحقق الخاص بك. للحفاظ على أمانك، لا تشارك هذا الرمز.",
"footer": "تنتهي صلاحية هذا الرمز خلال 10 دقائق.",
"header": { "format": "NONE", "text": null, "required": false },
"variables": [ { "label": "رمز التحقق", "kind": "text" } ],
"buttons": [ { "type": "OTP", "text": "نسخ الرمز", "variable": false } ],
"authentication": { "addSecurityRecommendation": true, "codeExpirationMinutes": 10 },
"rejectionReason": null,
"account": "104934…",
"numbers": ["+964 770 000 0001", "+964 770 000 0002"]
}
]
}| variables[].label | string | The name to send the value under in variables. kind is name when the template fills it with the contact's name in broadcasts; from the API you send it either way. |
| header.required | boolean | True when a send must carry header.text (format TEXT) or header.link (IMAGE, VIDEO, DOCUMENT). |
| buttons[].variable | boolean | True for a URL button that needs a buttonVariables entry. |
| authentication | object | null | Set on a one-time-code template. Send it with code, not variables. |
| account | string | null | The WhatsApp business account it belongs to. |
| numbers | string[] | The connected numbers that can send it — its account's. Any of them is a valid from; empty means none can until a number of that account is connected. |
Sending one-time codes
A verification code is a template of category AUTHENTICATION. WhatsApp writes its words; the team chooses the security line, how long the code lasts, and whether the button copies the code or hands it to an Android app. Make one in the dashboard from the “رمز تحقق” starter, wait for approval (usually minutes), then:
- Generate the code in your system, as you do today. Seler never makes or checks codes; it delivers yours.
POST /v1/messages/templatewithtemplate,to,code, your attempt id asreference, and the same id asIdempotency-Key.- Subscribe to
message.deliveredandmessage.failed. A failure within seconds usually means the number is not on WhatsApp — offer another way in. - Codes are delivered only for as long as they are valid: a template whose code expires in five minutes is not delivered to a phone that comes online in ten.
const API = "https://api.seler.app/v1";
const headers = {
Authorization: `Bearer ${process.env.SELER_API_KEY}`,
"Content-Type": "application/json",
};
async function sendLoginCode(attempt) {
const res = await fetch(`${API}/messages/template`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": attempt.id },
body: JSON.stringify({
to: attempt.phone,
template: "verify_code",
code: attempt.code,
reference: attempt.id,
}),
});
const body = await res.json();
if (!res.ok) {
// body.code is one of the words above: no_whatsapp_channel, plan_limit, whatsapp_rejected…
throw new Error(`Seler ${res.status} ${body.code}: ${body.message}`);
}
return body.id; // the message; message.delivered / message.failed will name it, with your reference
}A complete example
An ERP marks an order as shipped and tells the customer, adding them to Seler if they were not there:
const API = "https://api.seler.app/v1";
const headers = {
Authorization: `Bearer ${process.env.SELER_API_KEY}`,
"Content-Type": "application/json",
};
async function notifyShipped(order) {
const res = await fetch(`${API}/messages/template`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": `shipped-${order.number}` },
body: JSON.stringify({
to: order.customerPhone,
name: order.customerName,
template: "order_shipped",
variables: { "اسم العميل": order.customerName, "رقم الطلب": order.number, "موعد الوصول": order.eta },
reference: `order-${order.number}`,
}),
});
if (!res.ok) throw new Error(`Seler ${res.status}: ${(await res.json()).message}`);
return res.json(); // { id, status: "sent", conversationId, message }
}