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-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>">
{
  "event": "conversation.assigned",
  "createdAt": "2026-07-09T12:00:00.000Z",
  "data": { "conversationId": "uuid", "assigneeId": "uuid" }
}

Respond 2xx promptly. A non-2xx response (or a timeout — 8s) is retried up to 3 times with a short backoff. Deliveries are best-effort and fire-and-forget; they never block or fail the action that triggered them.

Endpoints that stop responding

If an endpoint fails every delivery for 20 consecutive events, ReplyPlex disables the webhook and records why — visible in Settings → Developer → Webhooks. Any successful delivery resets the counter, so a brief outage costs you nothing. Editing the URL or switching the webhook back on clears the count and resumes delivery.

Events & payloads

Eventdata
conversation.created{ conversationId }
conversation.assigned{ conversationId, assigneeId } (null assigneeId = unassigned)
conversation.state_changed{ conversationId, status } (e.g. CLOSED, ACTIVE)
conversation.snoozed{ conversationId, snoozedUntil } (ISO 8601)
conversation.tagged{ conversationId, tag } (tag name)
message.created{ conversationId, internalNote } (a reply, or a note when internalNote: true)
csat.received{ conversationId, rating } (GOOD / NEUTRAL / BAD)

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 more than the id.

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 { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  // 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.