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 ping event 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

Request headers
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.

Body
{
  "id": "5f0c2e9a-…",
  "type": "message.received",
  "createdAt": "2026-09-13T18:02:11.000Z",
  "team": { "id": "cad8e8c6-…" },
  "data": { … }
}
iduuidThe delivery. The same id arrives again on a retry, so you can drop a duplicate you already handled.
typestringOne of the events below, or ping.
createdAtISO 8601When the event happened.
data.customerobjectPresent on every event about a person: { id, name, phone }.

Answering

  • Answer any 2xx within 10 seconds. Acknowledge first, do the work after — a slow handler is retried as if it had failed.
  • Anything else — a 4xx, a 5xx, a timeout, a refused connection — is retried: 7 attempts, spaced exponentially from one minute, about an hour in all.
  • Answer 410 Gone to 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.

Node.js (Express)
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
});
PHP (Laravel)
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);
}
Python (FastAPI)
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.createdA customer wrote for the first time, or came back after their thread was closed.
conversation.assignedA person on the team took the thread — or the assistant handed it over.
conversation.closedThe thread was closed.
conversation.returnedThe thread went back to the assistant.
conversation.sent_to_groupThe 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.receivedThe customer sent something.
message.sentThe assistant or a person on the team sent something.
message.deliveredWhatsApp put a message the team sent on the customer's phone.
message.readThe customer opened a message the team sent.
message.failedWhatsApp could not deliver a message the team sent; data.error says why.
lead.capturedThe assistant wrote down someone to follow up with.
booking.requestedThe assistant took a booking request, or booked a slot in your system.
order.placedThe assistant placed an order in the connected store.
order.cancelledThe 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.*

data
{
  "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.groupobject | nullThe team inside the workspace the thread was sent to — sales, support — with its id and current name. null when it waits for anyone.
reasonstringOn assigned/closed/returned/sent_to_group: why. manual, a sweeper's name, automation:…, or ai_escalation: … when the assistant handed over.
reopenedbooleanOn 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

data
{
  "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.

data
{
  "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

data
{
  "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:

data (request for the team)
{
  "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:

data (booked in your system)
{
  "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

data
{
  "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.