Webhooks
Smartbtn can push a signed JSON POST to a URL you register whenever
a subscribed event happens. This page mirrors the ACTUAL committed
App\Domain\Emitter / config/domain_events.php /
App\Domain\Subscribers\WebhookSubscriber /
App\Jobs\WebhookDeliveryJob code, not just the original design
proposal -- see the notes under the catalog below for anywhere the two differ.
Manage subscriptions via /v1/webhook-subscriptions (see
Endpoints).
Delivery envelope
Every delivery is POST <your url>, Content-Type: application/json, with these headers:
| Header | Meaning |
|---|---|
X-Smartbtn-Event | The event type, e.g. message.created. |
X-Smartbtn-Signature | sha256=v1:<hex hmac> -- see Signature verification below. |
X-Smartbtn-Idempotency-Key | One value per underlying event, shared by every endpoint subscribed to it and stable across retries of your delivery. Use this to dedupe -- see Idempotency below. |
X-Smartbtn-Delivery | One value per (endpoint, event) delivery attempt. Useful for your own logging; changes on manual redelivery even though the idempotency key does not. |
X-Smartbtn-Timestamp | Unix timestamp the request was sent. Informational only -- it is NOT part of the signed body (see below), so it changes on every retry even though the body does not. |
The body is:
{
"id": "<event id -- same value as X-Smartbtn-Idempotency-Key>",
"type": "<event type>",
"data": { ... event-specific fields, see catalog below ... }
}
The body's bytes are identical across every retry attempt of the same
delivery (no timestamp or nonce is embedded inside the signed body itself --
only in the separate, unsigned X-Smartbtn-Timestamp header), so
re-verifying the signature on a retried delivery always checks the exact same
bytes you saw the first time, if you received it.
Signature verification
Compute an HMAC-SHA256 of the raw request body (the exact bytes as received,
before any JSON parsing) using your endpoint's signing secret, and compare it
against X-Smartbtn-Signature using a constant-time comparison
(e.g. PHP's hash_equals) -- never a plain ==/===
string compare.
// Example only -- REDACTED_EXAMPLE_SECRET is a placeholder, never a real secret.
$secret = 'REDACTED_EXAMPLE_SECRET';
$rawBody = file_get_contents('php://input'); // raw bytes, not $_POST / json_decode()
$expected = 'sha256=v1:' . hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $_SERVER['HTTP_X_SMARTBTN_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
Formula, provider-agnostic: X-Smartbtn-Signature: sha256=v1:HMAC-SHA256(raw_request_body, your_webhook_secret).
Your signing secret is shown to you exactly once, at subscription-creation time
(POST /v1/webhook-subscriptions) -- Smartbtn stores it encrypted and
never displays it again afterwards. See
Authentication & Scopes for the
secret-rotation overlap window and immediate-revocation behavior.
Event catalog
Status below is the actual, current rollout state, not the
original design intent: every event in the table has real code wired to emit
it, but only events marked Live are actually enabled in
config/domain_events.php today. A subscription can be created for
any event marked Wired, canary-disabled -- the API accepts it --
but no delivery will actually happen for it until Smartbtn operations enables
that type in the canary rollout. If you need one of those event types for a
production integration, contact support to ask about its rollout timeline before
you rely on it firing.
| Event | Status | Fires when | Payload data fields (besides button_guid) |
|---|---|---|---|
message.created |
Live (canary-enabled by default) | A chat message is persisted, from either the client or an agent. | chat_id, message_id, client_id, author_type (USER|OPERATOR), source |
chat.created |
Wired, canary-disabled by default | A new chat room is created for a client contacting this widget for the first time. | chat_id, client_id, source |
chat.transferred |
Wired, canary-disabled by default | A chat is transferred from one agent to another. | chat_id, client_guid, from_operator_id, to_operator_id, author_type, source |
chat.closed |
Wired, canary-disabled by default | A chat is closed -- either via POST /v1/chats/{id}/close, or automatically when a chat transitions into the expired state (idle timeout). |
chat_id, reason (e.g. "expired", or the caller-supplied close reason) |
rating.created |
Wired, canary-disabled by default | A client submits a chat rating for the first time. | chat_client_id, operator_id, author_type, source |
agent.online |
Wired, canary-disabled by default | An agent's socket connects to this widget. | operator_id |
agent.offline |
Wired, canary-disabled by default | An agent's last connected socket disconnects. | operator_id |
chat.accepted |
Wired, canary-disabled by default | A human operator claims a previously-unowned chat -- an explicit accept, an assign action, or simply the first reply into an unclaimed room. | chat_id, operator_id, client_id |
offline_message.created |
Wired, canary-disabled by default | A client submits the offline/callback form while zero operators are connected. | chat_id, client_id, message_id, name, email, phone |
contact.updated |
Wired, canary-disabled by default | A contact's fields are updated via PATCH /v1/contacts/{id}. |
client_id, changed_fields |
Payload is intentionally minimal
Every event's data is an ids/discriminator payload, not a full copy
of the underlying record. Two differences from an earlier, more detailed design
draft are worth calling out explicitly so you don't build against fields that
don't exist yet:
-
message.createddoes not include the message body, type, or file -- onlymessage_id. If you need the message content, fetch it separately withGET /v1/chats/{chat_id}/messages(messages:read). -
rating.createddoes not include the rating value itself, only the ids of who rated and who was rated. - No sequence number is included in any payload today. Deliveries are at-least-once and are not guaranteed to arrive in the order the underlying events happened, including across retries of different events -- do not build ordering assumptions into your receiver.
A note on naming: chat.updated
chat.accepted, offline_message.created, and
contact.updated appeared in an earlier design draft of this catalog
without a native emission point -- that gap is closed; all three are now in the
table above, wired and canary-disabled like the rest. The one name from that
earlier draft that still does not appear in the table is chat.updated
-- it was never a distinct native concept on this side. It only exists as Jivo's
own name for what this API calls chat.transferred (see Migrating
from Jivo below); subscribing to the native chat.updated string
itself returns 422, since that is not this API's vocabulary.
Retry & backoff policy
Up to 4 attempts total (the initial send plus 3 retries), with
exponential backoff between attempts: min(60, 2^attempt * 5)
seconds -- roughly 10s, 20s, 40s (capped at 60s beyond that). After the final
attempt fails, the delivery is recorded internally as failed and is not retried
automatically again.
- A non-2xx response, or a transport-level error/timeout, counts as a failed attempt and consumes one retry.
- A destination that fails Smartbtn's SSRF guard (e.g. it now resolves to a private/internal address) or an endpoint with no signing secret configured fails permanently on the spot -- no retry budget is spent on a failure that would repeat identically every time.
- If an endpoint is deleted or deactivated after a delivery was already queued, that queued delivery is simply dropped -- not retried, not recorded as a failure.
- Only failed deliveries are currently recorded in the delivery log surfaced via
GET /v1/webhook-subscriptions/ redelivery -- there is no separate "succeeded" log entry today. Keep your own receiver-side record of what you've processed. - You can manually trigger a fresh delivery attempt for a recorded failure with
POST /v1/webhook-subscriptions/{id}/redeliver({delivery_id}) -- it re-sends the original stored payload on a brand-newX-Smartbtn-Deliveryid, but keeps the SAMEX-Smartbtn-Idempotency-Key, so your dedup logic correctly treats it as a repeat of the original event rather than a new one.
Idempotency-key handling (for your receiver)
Delivery is at-least-once, so your endpoint should be able to safely process the same event more than once:
- Treat
X-Smartbtn-Idempotency-Key(equal to the body'sidfield) as the row you dedupe on. Before acting on a delivery, check whether you've already processed that key (a short-lived store, e.g. 24-48h, is enough) and skip it if so. X-Smartbtn-Deliveryidentifies this specific attempt/log row -- it is useful for your own debugging (e.g. "did I see delivery X before?"), but is NOT the dedup key: a manual redelivery gets a new delivery id while intentionally keeping the same idempotency key.- Always verify the signature before trusting or parsing the body, on every attempt, including redeliveries.
- Respond quickly with a 2xx once you've durably queued the work on your side -- don't do slow processing inline in the request, since a slow non-2xx response is indistinguishable to Smartbtn from a failed delivery and will be retried.
Migrating from Jivo
If you already have a Jivo integration speaking Jivo's own webhook wire format,
you do not need to rewrite it. The compat/jivo/* routes (see
Endpoints) are a day-one
adapter: for inbound Bot/Chat API traffic, point your existing integration at
Smartbtn's /compat/jivo/bot/<token> or
/compat/jivo/chat/<secret> instead of Jivo's URL -- swap the
base URL and the token/secret, no wire-format changes required on your side. For
outbound webhooks, create a subscription with is_jivo_compat: true
to receive Jivo's own event_name-keyed envelope instead of the
native envelope above. All five Jivo event names now map to a native event with
a real emission point in the code: chat_updated →
chat.transferred, chat_finished →
chat.closed, chat_accepted →
chat.accepted, offline_message →
offline_message.created, client_updated →
contact.updated (chat_updated has no separate native
concept of its own -- it is simply Jivo's name for chat.transferred).
None of the five actually deliver in production yet, since their underlying
native events are all still canary-disabled (see the Event catalog above) -- but
subscribing to any of them now succeeds, accepted by the API the same way every
other canary-disabled event already is.