Webhooks
Webhooks
Register an HTTPS address in the dashboard and Seler sends it a signedPOST whenever something you subscribed to happens. Each address has its own secret, its own subscription list and its own delivery log with retries.
Setting up
- In the dashboard, Settings › التكاملات › Webhooks › عنوان جديد. Enter the
https://address and tick the events. - The secret (
whsec_…) is shown once. Put it in your server and verify every delivery with it (below). - Press اختبار: Seler sends a
pingevent and the delivery drawer shows what your server answered.
Up to 10 addresses per workspace. Addresses must be on a public host; private ranges, loopback and localhost are refused.
The delivery
POST /your/path HTTP/1.1
Content-Type: application/json
User-Agent: Seler-Webhooks/1.0 (+https://developer.seler.app)
X-Seler-Event: message.received
X-Seler-Delivery: 5f0c2e9a-… (the delivery id; stable across retries)
X-Seler-Signature: t=1757779200,v1=9d1b… (see Verifying)Every body is the same envelope; only data depends on the event.
{
"id": "5f0c2e9a-…",
"type": "message.received",
"createdAt": "2026-09-13T18:02:11.000Z",
"team": { "id": "cad8e8c6-…" },
"data": { … }
}| id | uuid | The delivery. The same id arrives again on a retry, so you can drop a duplicate you already handled. |
| type | string | One of the events below, or ping. |
| createdAt | ISO 8601 | When the event happened. |
| data.customer | object | Present on every event about a person: { id, name, phone }. |
Answering
- Answer any
2xxwithin 10 seconds. Acknowledge first, do the work after — a slow handler is retried as if it had failed. - Anything else — a
4xx, a5xx, a timeout, a refused connection — is retried: 7 attempts, spaced exponentially from one minute, about an hour in all. - Answer
410 Goneto say “stop”: the delivery is marked failed and not retried. - After 50 consecutive final failures the address is disabled and the workspace is shown why. Fixing the server and pressing تشغيل resumes it.
- Redirects are not followed. Answer at the address you registered.
Verifying the signature
The header is t=<unix seconds>,v1=<hex>, where the digest is HMAC-SHA256 with your secret over the string <t>.<raw body>. Verify against the raw bytes you received, before parsing; compare in constant time; and refuse a timestamp more than five minutes from your clock, so a captured request cannot be replayed.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.SELER_WEBHOOK_SECRET;
const app = express();
app.post("/seler/webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Seler-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
const expected = crypto.createHmac("sha256", SECRET).update(`${parts.t}.${req.body}`).digest("hex");
const ok =
age < 300 &&
parts.v1?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body);
res.status(200).end(); // acknowledge first
handle(event); // then do the work
});public function __invoke(Request $request)
{
$secret = config('services.seler.webhook_secret');
parse_str(str_replace(',', '&', $request->header('X-Seler-Signature', '')), $parts);
$raw = $request->getContent();
$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $raw, $secret);
if (abs(time() - (int) ($parts['t'] ?? 0)) > 300 || !hash_equals($expected, $parts['v1'] ?? '')) {
abort(401);
}
$event = json_decode($raw, true);
dispatch(new HandleSelerEvent($event)); // queue it; answer now
return response()->noContent(200);
}import hmac, hashlib, json, time
from fastapi import FastAPI, Request, HTTPException
SECRET = os.environ["SELER_WEBHOOK_SECRET"].encode()
app = FastAPI()
@app.post("/seler/webhook")
async def webhook(request: Request):
raw = await request.body()
parts = dict(p.split("=", 1) for p in request.headers.get("x-seler-signature", "").split(","))
expected = hmac.new(SECRET, f"{parts.get('t')}.".encode() + raw, hashlib.sha256).hexdigest()
if abs(time.time() - int(parts.get("t", 0))) > 300 or not hmac.compare_digest(expected, parts.get("v1", "")):
raise HTTPException(401)
event = json.loads(raw)
background.add_task(handle, event)
return {}Events
| conversation.created | A customer wrote for the first time, or came back after their thread was closed. | |
| conversation.assigned | A person on the team took the thread — or the assistant handed it over. | |
| conversation.closed | The thread was closed. | |
| conversation.returned | The thread went back to the assistant. | |
| conversation.sent_to_group | The thread was sent to a team inside the workspace — into its inbox, or straight to one of its members (then conversation.assigned arrives too). | |
| message.received | The customer sent something. | |
| message.sent | The assistant or a person on the team sent something. | |
| message.delivered | WhatsApp put a message the team sent on the customer's phone. | |
| message.read | The customer opened a message the team sent. | |
| message.failed | WhatsApp could not deliver a message the team sent; data.error says why. | |
| lead.captured | The assistant wrote down someone to follow up with. | |
| booking.requested | The assistant took a booking request, or booked a slot in your system. | |
| order.placed | The assistant placed an order in the connected store. | |
| order.cancelled | The assistant cancelled an order at the customer's request. |
Names are thing.what-happened and are never renamed. New events and new fields may appear; write your handler to ignore what it does not know.
conversation.*
{
"conversation": {
"id": "33333333-…",
"channel": "whatsapp",
"status": "open",
"assignedTo": "59b5d09f-…",
"group": { "id": "8f2c61d0-…", "name": "المبيعات" },
"customerId": "3acd889a-…",
"lastMessageAt": "2026-09-13T18:02:11.000Z",
"createdAt": "2026-09-13T17:40:02.000Z"
},
"reason": "ai_escalation: العميل يريد التحدث مع شخص",
"reopened": false,
"customer": { "id": "3acd889a-…", "name": "زينب الحسيني", "phone": "+9647701234567" }
}| conversation.group | object | null | The team inside the workspace the thread was sent to — sales, support — with its id and current name. null when it waits for anyone. |
| reason | string | On assigned/closed/returned/sent_to_group: why. manual, a sweeper's name, automation:…, or ai_escalation: … when the assistant handed over. |
| reopened | boolean | On created: true when a closed thread came back rather than a new one being made. |
conversation.sent_to_group is said when a thread moves into a team — sent there by a person or by an automation. A thread stays with its team when it closes or goes back to the assistant, so a returning customer does not raise it again. Sent straight to one of the team's members, the same change also arrives as conversation.assigned. Taking a thread out of every team is not announced on its own; the next event about it carries "group": null.
message.received · message.sent
{
"conversationId": "33333333-…",
"message": {
"id": "…",
"role": "customer",
"content": "كم سعر هذا؟",
"attachments": [ { "kind": "image", "url": "https://…signed…", "contentType": "image/jpeg", "bytes": 84213, "title": null } ],
"transcription": null,
"createdAt": "2026-09-13T18:02:11.000Z"
},
"customer": { "id": "…", "name": "زينب الحسيني", "phone": "+9647701234567" }
}message.delivered · message.read · message.failed
What became of a message the team sent — from the inbox, a broadcast, or POST /v1/messages/template. Only forward moves are announced: a late “delivered” after “read” is not. reference is whatever your send carried; for a message sent from the inbox it is null. A one-time code is kept nowhere in Seler, so its events carry conversationId and customer as null; messageId is still the id the send answered with.
{
"conversationId": "33333333-…",
"messageId": "5d2e…", // the id POST /v1/messages/template answered with
"status": "failed", // delivered | read | failed
"providerMessageId": "wamid.HBgM…", // WhatsApp's own id
"reference": "login-attempt-7f3a",
"template": { "name": "verify_code", "language": "ar" },
"error": { "message": "Message Undeliverable" }, // null unless failed
"occurredAt": "2026-09-28T18:02:11.000Z",
"customer": { "id": "…", "name": "زينب الحسيني", "phone": "+9647701234567" }
}lead.captured
{
"conversationId": "…",
"lead": { "name": "زينب الحسيني", "phone": "+9647701234567", "need": "تريد عرض سعر لتجهيز مطبخ كامل" },
"customer": { … }
}booking.requested
Two shapes. When the workspace has no booking system, the assistant writes the request down for a person to confirm:
{
"conversationId": "…",
"booking": { "name": "زينب", "phone": "+9647701234567", "service": "تنظيف أسنان", "preferredTime": "الأحد صباحاً" },
"customer": { … }
}When your system implements the appointments half of the contract, the assistant books a real slot and the event carries what your system returned:
{
"conversationId": "…",
"booking": { "id": "apt-5521", "startsAt": "2026-09-16T09:30:00+03:00", "endsAt": "2026-09-16T10:00:00+03:00", "service": "تنظيف أسنان", "status": "confirmed", "source": "system" },
"customer": { … }
}order.placed · order.cancelled
{
"conversationId": "…",
"order": {
"id": "8812",
"number": "1001",
"state": "confirmed",
"total": 45000,
"currency": "IQD",
"placedAt": "2026-09-13T18:05:00Z",
"trackingUrl": null,
"lines": [ { "title": "عباية سوداء كلاسيك — 54", "quantity": 1, "price": 45000 } ]
},
"customer": { … }
}ping
Sent by the test button to one address, regardless of its subscriptions. data.message is a sentence. Answer 200.
Operating it
- Idempotency. Keep the last few thousand delivery ids you handled and skip a repeat; a retry after your ack was lost carries the same
id. - Order. Deliveries are queued as they happen but may arrive out of order under retries. Use
createdAt, not arrival time. - Rotating the secret. Settings › Webhooks › تدوير. The old secret stops verifying at once; put the new one in place first if you can afford no gap, then rotate — deliveries in the minute between will fail and be retried with the new signature.
- Redelivery. Any delivery in the log can be sent again by hand, byte for byte, after you have fixed your server.
- Retention. The delivery log keeps a month.