Skip to content

Receive Support events

Receive signed Support events and diagnose failed webhook deliveries.

Register an HTTPS endpoint to receive signed Support events. Verify each signature, deduplicate by event key, and use the delivery log to investigate failures.

Before you start

  • A platform identity with product admin access. All webhook registry, delivery-log, and redelivery tools require admin access. An MCP token must grant those operations; public keys cannot manage webhooks.
  • An HTTPS endpoint you control. Plain HTTP URLs are rejected.
  • Secret storage. Creation and rotation show the signing secret once.
  • A handler that responds within ten seconds and preserves the raw request body for signature verification.

The example mirrors your shop’s support conversation. Replace https://api.example.com/hooks/lepsto-support with your receiver URL and choose the events you need. Payload examples use illustrative ids; the signature test vector later on must be kept exactly as written.

Register an endpoint

  1. Call support_webhook_create with the URL and the events you want.

    {
      "url": "https://api.example.com/hooks/lepsto-support",
      "eventTypes": ["support.message.created", "support.thread.status-changed"],
      "description": "Mirrors the conversation into our own support screen"
    }

    Use an HTTPS URL and at least one of the seven event types. Empty or unknown event lists are rejected. The optional description allows up to 1,000 characters.

  2. Save the returned signing secret. It starts with whsec_ and is shown only once. Later reads through support_webhook_get or support_webhook_list return only secretPrefix, the prefix label plus six characters, for identification.

  3. Deploy your handler with that secret, then send yourself an event by doing the thing it subscribes to — post a public agent message, or move a thread to another status.

Check the result. Call support_webhook_deliveries_list for the endpoint. A row for that event with state: "delivered" and a 2xx responseStatus means the round trip works.

For a failed delivery, inspect responseStatus and error, then correlate the delivery id with receiver and proxy logs. A 401 might come from signature verification, a proxy, or your framework. If verification failed, check how the HMAC key is derived.

Change or replace an endpoint

support_webhook_update changes url, eventTypes, description, or status (active or disabled). Omitted fields stay unchanged; description: null clears the description. Use rotation to change the secret.

support_webhook_rotate_secret returns a replacement secret once. Use it if the original is lost or needs replacing.

Rotation takes effect immediately, with no grace window. Prepare the receiver deployment, call support_webhook_rotate_secret, save the returned secret, then deploy it. The new value is unavailable before the call returns. Deliveries signed during the interval before deployment fail against the receiver’s old secret, so keep that interval short.

To avoid that verification gap, register a second endpoint for the same events, configure it with its secret, and verify delivery before disabling or deleting the first. While both are active, each event is delivered to both. Deduplicate using idempotencyKey, which is shared by the copies. See Idempotency.

You can register multiple endpoints. Support delivers an event independently to each active subscriber and signs each copy with that endpoint’s secret.

Disabling stops delivery; deleting destroys the history

support_webhook_delete permanently removes the endpoint and its delivery history. There is no restore operation.

To pause delivery while retaining the registration, secret, and history, call support_webhook_update with status: "disabled". Set it back to active to resume delivery.

The seven events

Event
When it fires
support.message.created
A support agent posted a public message
support.message.updated
The body of a public message was edited, whatever its direction
support.message.deleted
A public message was deleted, whatever its direction
support.thread.status-changed
A thread moved from one status to another
support.thread.assigned
A thread’s assignee was set, changed or cleared
support.survey.published
A survey opened for answers, freezing its structure
support.response.created
Somebody answered a survey

Only these event types are accepted. An unknown subscription name is rejected.

Thread creation has no outbound webhook event. Your backend can use the thread-creation response to handle that action.

support.response.created reports a stored survey response. This event is supported regardless of which customer-facing form collected the answers. Keep the public key on your backend when submitting responses.

Your users’ messages are not echoed back to you

support.message.created requires both public visibility and an agent author. These conditions cannot be changed through endpoint settings or tool arguments.

New external-customer messages and internal notes do not emit support.message.created. Later edits or deletion of a public customer message do emit support.message.updated or support.message.deleted.

What a payload carries

Every event body has these fields:

Field
Meaning
event
The event type, exactly as subscribed
idempotencyKey
The key to deduplicate on — see Idempotency
occurredAt
ISO-8601 time the event happened
productId
Your product

The five thread-scoped events — the three support.message.* events, support.thread.status-changed and support.thread.assigned — add two more:

Field
Meaning
threadId
The thread this is about
externalUserId
The opaque end-user id the thread was opened on behalf of

Neither survey event has a threadId. The response event has its own externalUserId, identifying the respondent; survey publication has no respondent. Branch on event before reading event-specific fields.

support.message.created adds:

Field
Meaning
messageId
The message that was posted
authorAgentId
The agent who wrote it — never null on this event
createdAt
ISO-8601 time the message was created

support.message.updated adds:

Field
Meaning
messageId
The message that was edited
authorAgentId
The agent who wrote it, or null for a message from an end user
editedAt
ISO-8601 time of the edit

support.message.deleted adds:

Field
Meaning
messageId
The message that was deleted
authorAgentId
The agent who wrote it, or null for a message from an end user
deletedAt
ISO-8601 time of the deletion
scope
own when the creator deleted it, any when an admin did

Update and delete events carry no old or new body. Both apply to public messages in either direction and with either author type; internal notes emit neither. An unchanged-body update emits nothing, and repeated deletion emits only the first event. See Correct or remove a message.

support.thread.status-changed adds both ends of the transition:

Field
Meaning
status
The status name the thread moved to
statusCategory
That status’s category
previousStatus
The status name it moved from
previousStatusCategory
That status’s category

support.thread.assigned adds, either of which may be null — a null assigneeAgentId is an unassignment:

Field
Meaning
assigneeAgentId
The agent the thread is now assigned to, or null
previousAssigneeAgentId
Who it was assigned to before, or null

support.survey.published adds:

Field
Meaning
surveyId
The survey that opened for answers
name
Its name
questionCount
How many questions it froze with

support.response.created adds:

Field
Meaning
surveyId
The survey that was answered
responseId
The response, to read back with support_response_get
externalUserId
The opaque respondent. There is no thread here to name
score
The first nps or rating answer, or null
completed
Whether the respondent reached the end
createdAt
ISO-8601 time the response was recorded

score: null means no score was recorded. Keep it distinct from a score of zero.

{
  "event": "support.message.created",
  "idempotencyKey": "message-created:msg-1:2026-08-19T10:00:00.000Z",
  "occurredAt": "2026-08-19T10:00:00.000Z",
  "productId": "prod-1",
  "threadId": "thr-1",
  "externalUserId": "ext-9",
  "messageId": "msg-1",
  "authorAgentId": "agt-1",
  "createdAt": "2026-08-19T10:00:00.000Z"
}

No message text, and no answers, ever

Webhooks carry identifiers and event metadata. They contain no message subject or body, attachment content, survey answers, or question set. No setting adds these fields.

Fetch content under your own authorization after receiving the event: use support_message_list for a conversation or support_response_get for survey answers. After an outage, inspect the delivery log for missed events. Use the product-wide message read with since and cursor pagination to catch up on newly created messages.

Verify the signature

Every delivery carries four headers:

Header
Contents
X-Support-Signature
t=<unix seconds>,v1=<hex>
X-Support-Event
The event type
X-Support-Idempotency-Key
The deduplication key, same as in the body
X-Support-Delivery-Id
The delivery this attempt belongs to

Verify the string "<t>.<raw body>": Unix timestamp, a dot, and the original body bytes. Preserve the body before JSON parsing; re-serialization can change the bytes and invalidate the signature. The timestamp is included in the MAC, so it cannot be replaced with a fresh timestamp without invalidating the signature.

The HMAC key is sha256(secret), not the secret

Derive the signing key before computing the HMAC:

The HMAC key is the SHA-256 of your secret, in lowercase hex, used as a string.

Support stores the SHA-256 hex digest and a six-character secret prefix, not the raw secret. Your receiver derives the same digest from the secret returned at registration.

Use createHash("sha256").update(secret).digest("hex"). Pass that lowercase hex string as the HMAC key. Using the raw whsec_… secret or decoding the digest into bytes produces a different signature.

A verifier you can run

import { createHash, createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

export function verify(secret, headers, rawBody) {
  const header = headers['x-support-signature'];
  if (!header) return false;

  const parts = Object.fromEntries(
    header.split(',').map((piece) => piece.split('=').map((s) => s.trim())),
  );
  const timestamp = Number(parts.t);
  const received = parts.v1;
  if (!Number.isFinite(timestamp) || typeof received !== 'string') return false;

  // An old signature is a valid signature. Refuse anything far from your clock.
  const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
  if (age > TOLERANCE_SECONDS) return false;

  // The key is the hex digest of the secret, as a string — not the secret.
  const key = createHash('sha256').update(secret).digest('hex');
  const expected = createHmac('sha256', key).update(`${timestamp}.${rawBody}`).digest('hex');

  const a = Buffer.from(received, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}

Pass the original string or buffer as rawBody. In Express, mount express.raw({ type: "application/json" }) on the webhook route and parse only after verification.

Use this fixed source test vector to check key derivation and MAC calculation before receiving a live event:

Input
Value
Secret
whsec_ZmFrZS1zZWNyZXQtZm9yLXRoZS10ZXN0LXZlY3Rvcg
Derived key
7194826169f98ee244a1bb51599eafb842990c84a68ec8d772872aaf7fc524de
t
1755600000

with the body, on one line and with no spaces between its members:

{"event":"support.thread.assigned","idempotencyKey":"thread-assigned:thr-1:2026-08-19T10:00:00.000Z","occurredAt":"2026-08-19T10:00:00.000Z","productId":"prod-1","threadId":"thr-1","externalUserId":"ext-9","assigneeAgentId":"agt-1","previousAssigneeAgentId":null}

signing to:

6ea5f20568189c1f13eb1248fe45fbe191b5b6992e11b527b7e230f40a54c67c

The vector’s timestamp is old, so the verifier’s freshness check rejects it as a live delivery. Compare the derived key and MAC separately for this test.

Return 2xx only for requests that pass verification.

Retries and failure

An attempt succeeds with a 2xx response within ten seconds. Other statuses, connection errors, and timeouts count as failures.

Support makes at most five attempts, waiting 30 seconds, 2 minutes, 10 minutes, and 1 hour between attempts. It does not follow redirects; register the final destination URL.

4xx responses stop retries immediately, except 408 and 429. Those two statuses, 5xx responses, and transport failures use the full retry schedule.

After retries are exhausted, the delivery becomes failed. The endpoint stays active; failures do not automatically disable it.

Keep receiver work within the ten-second response window. Verify and acknowledge promptly, then process the event asynchronously. Slow processing can cause retries while the first attempt is still running.

Idempotency

Delivery is at-least-once. A retry after a slow response can deliver an event you already processed.

Use X-Support-Idempotency-Key, also present as idempotencyKey in the body, to recognize repeats. The key remains the same for retries and manual redelivery. Record processed keys so repeated delivery does not repeat the action.

Catch up on what was delivered

support_webhook_deliveries_list returns deliveries newest first. Filter by webhookId, state, eventType, and createdFrom / createdTo. Page size is 1–200, default 50. Each record includes:

Field
Meaning
id
The delivery id — the value sent as X-Support-Delivery-Id
webhookId, eventType
The endpoint and the event
idempotencyKey
The key the receiver deduplicates on
payload
The exact JSON body that was sent
attempt
How many attempts have been made, 0 before the first
state
pending while attempts remain, then delivered or failed
responseStatus
The HTTP status of the last attempt, or null if it never got an answer
error
Why the last attempt failed, or null
deliveredAt, nextRetryAt, createdAt
When it landed, when the next attempt is due, when it was opened

Use responseStatus to identify the HTTP result, then check receiver logs for the cause. A 401 alone does not prove a signature mismatch; proxies and frameworks can also reject requests. A null response status means the attempt received no response.

support_webhook_redeliver creates a new delivery and retry sequence from a past delivery. It preserves the original payload and idempotencyKey, leaving the old log entry intact. Support checks the current endpoint at delivery time; nothing is sent if it has been disabled, deleted, or unsubscribed from that event.

Where to go next

Was this page helpful?
Esc

Start typing to search the docs.

↑↓navigate↵select