Developer docs
Webhooks
Subscribe to events and get a signed POST when things happen — instead of polling.
Webhooks notify you when things happen, instead of polling.
Configure
An org admin adds a webhook in Settings → Developer → Webhooks → New webhook: a payload URL and
the events to subscribe to. A signing secret (whsec_…) is shown once at creation.
Delivery
When a subscribed event occurs, ReplyPlex sends a POST to your URL:
POST <your-url>
Content-Type: application/json
X-ReplyPlex-Event: conversation.assigned
X-ReplyPlex-Delivery: 4a223927-78bd-497b-b070-1f3542307cc1
X-ReplyPlex-Signature: sha256=<hex hmac of the raw body>
X-ReplyPlex-Timestamp: 1785240000
X-ReplyPlex-Signature-V2: t=1785240000,v1=<hex hmac of "<timestamp>.<raw body>">
{
"id": "4a223927-78bd-497b-b070-1f3542307cc1",
"event": "conversation.assigned",
"createdAt": "2026-07-09T12:00:00.000Z",
"data": { "conversationId": "uuid", "assigneeId": "uuid" }
}
Respond 2xx promptly. Each delivery gets three automatic attempts in total, with an
eight-second timeout per request. Retry state is stored in PostgreSQL and survives worker
restarts. The worker checks due work every five seconds; backlog can delay delivery.
Webhook intent commits in the same transaction as the corresponding business state. Your endpoint’s availability does not affect that transaction. If ReplyPlex cannot save the event, the transaction fails instead of silently losing the notification.
Delivery is at least once, with no ordering guarantee. Verify the signature, then deduplicate
using the body’s id: automatic retries and explicit retries keep the same ID and exact body.
X-ReplyPlex-Delivery repeats that ID for diagnostics. Conversation facts are captured when
the event occurs; later edits do not change them. createdAt is the event time, while the v2
signature timestamp is refreshed for each attempt. Check freshness against the signed timestamp.
History and retry
Org admins can open Settings → Developer → Webhooks → Deliveries to see the latest 50 event batches from the last seven days. Retry failed queues only failed deliveries with their original IDs; successful deliveries are not sent again. History shows counts and timestamps, without exposing customer payloads or signing secrets.
Failed payloads can be retried until seven days after the original event. Retrying does not extend that window. Successful and cancelled payloads are cleared immediately. An unavailable endpoint can therefore miss an event permanently if nobody retries it before expiry.
Editing webhook settings cancels work queued under the previous configuration. Disabling or deleting a hook also prevents queued delivery; requests already in flight cannot be recalled. To retry a retained failure, enable the hook with the batch’s original URL and event subscription. An old payload is never redirected to a new URL.
If 20 consecutive completed event batches have no successful deliveries, ReplyPlex disables the webhook and records why. A bulk action counts as one batch per endpoint. A successful batch resets the failure count. Editing the URL or re-enabling clears the count; replay of earlier failures remains an explicit admin action.
Events & payloads
Every event about a conversation carries the conversation with it — you rarely need a follow-up request just to find out which ticket fired:
| Field | |
|---|---|
conversationId | the conversation’s id |
number | its ticket number, as shown in the console |
inboxId | which inbox it’s in |
subject | the thread’s subject |
customerEmail | who wrote in |
externalRef | your id for it, when the ticket arrived through portal ingest — null otherwise |
On top of those, each event adds:
| Event | extra data |
|---|---|
conversation.created | — |
conversation.assigned | { assigneeId } (null = unassigned) |
conversation.state_changed | { status } (e.g. CLOSED, ACTIVE) |
conversation.snoozed | { snoozedUntil } (ISO 8601) |
conversation.tagged | { tag } (tag name) |
conversation.followup_set | { followUpAt, followUpUserId } (reminder time and owner) |
message.created | { internalNote } (a reply, or a note when internalNote: true) |
csat.received | { rating } (GOOD / NEUTRAL / BAD) |
broadcast.completed | { broadcastId, subject, sent, failed, skipped } — one event for the completed broadcast, without conversation fields |
So a status change arrives as:
{
"id": "7f10458e-e032-49e6-a606-0e6d0c127fa3",
"event": "conversation.state_changed",
"createdAt": "2026-08-18T09:12:04.881Z",
"data": {
"conversationId": "uuid",
"number": 4271,
"inboxId": "uuid",
"subject": "Server is down",
"customerEmail": "ada@example.com",
"externalRef": "your-ticket-42",
"status": "CLOSED"
}
}
There’s no separate note.created — an internal note arrives as message.created with
internalNote: true.
Fetch the full object with GET /public/v1/conversations/:conversationId when you need the
messages and activity too.
Verifying the signature
The signature is sha256= + the HMAC-SHA256 of the raw request body, keyed with your webhook’s
signing secret. Always verify before trusting a payload:
import crypto from 'node:crypto';
function verify(rawBody, header, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(header);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Rejecting replays (recommended)
X-ReplyPlex-Signature covers the body alone, so a captured delivery stays valid forever. Every
request also carries X-ReplyPlex-Signature-V2, which signs the timestamp and the body
together — verify that one instead and reject anything outside a tolerance window (5 minutes is
typical) to make a captured payload useless after it expires.
function verifyV2(rawBody, header, secret, toleranceSec = 300) {
const parts = typeof header === 'string' && /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header);
if (!parts) return false;
const [, t, v1] = parts;
if (!Number.isSafeInteger(Number(t))) return false;
// Check the age FIRST — an attacker controls the replayed timestamp, not the signature over it.
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(v1);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
The original X-ReplyPlex-Signature header keeps being sent and keeps working, so existing
receivers need no change — adopt v2 when it suits you.