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
conversationIdthe conversation’s id
numberits ticket number, as shown in the console
inboxIdwhich inbox it’s in
subjectthe thread’s subject
customerEmailwho wrote in
externalRefyour id for it, when the ticket arrived through portal ingestnull otherwise

On top of those, each event adds:

Eventextra 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);
}

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.