Webhooks

Envelope & Delivery

The JSON envelope, HTTP headers, HMAC signature, retry policy, and deduplication

Every partner event - regardless of type - arrives as one POST with the same envelope, the same headers, and the same retry semantics. This page is the whole contract.

Envelope

interface PartnerWebhookEnvelope<TData> {
  id: string;        // "evt_<uuid>" - stable across retries; your dedupe key
  type: string;      // wire event name, e.g. "message.inbound.created"
  timestamp: string; // ISO-8601 UTC - when the event occurred
  data: TData;       // per-event payload - see each event page
  meta: {
    eventId: string;        // same value as `id`
    event: string;          // same value as `type`
    occurredAt: string;     // same value as `timestamp`
    organisationId: string; // the CLIENT org the event came from
    externalId?: string;    // YOUR id for that client, from createClientOrg
    partnerId?: string;     // your provider (tech-provider) org id
    apiVersion: "2026-04-01";
    attempt: number;        // 1-indexed delivery attempt
  };
}
FieldMeaning
idUnique event identifier. Identical across every retry of the same event, so use it to deduplicate.
typeWire event name, dotted lowercase. The GraphQL enum name differs - see the catalogue.
timestampWhen the event occurred, ISO-8601 UTC. Not the time of this delivery attempt.
dataPer-event payload. Shapes are on each event page; the reusable ones are in shared types.
meta.organisationIdThe client org. Route on this or on externalId.
meta.externalIdYour own client ID. Absent only if you never set one at provisioning.
meta.partnerIdYour provider org. Present on tech-provider fan-out deliveries; absent on org webhooks.
meta.apiVersionPinned to "2026-04-01". A breaking change to any data shape bumps it; adding an optional field does not.
meta.attempt1-indexed retry counter. The same (id, attempt) pair is never posted twice.

meta.connectionId is absent on partner deliveries - there is no per-org connection behind them. It is present on org webhooks.

HTTP Request

POST https://api.crmco.com/wexio/webhook
Content-Type: application/json
X-Wexio-Event: message.inbound.created
X-Wexio-Event-Id: evt_1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809
X-Wexio-Attempt: 1
X-Wexio-Timestamp: 2026-09-30T18:00:00.000Z
Idempotency-Key: evt_1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809
X-Wexio-Signature: t=1759255200,v1=8a3f...

<envelope JSON>
HeaderValue
X-Wexio-EventSame as envelope.type. Lets you route before parsing the body.
X-Wexio-Event-IdSame as envelope.id.
X-Wexio-AttemptSame as envelope.meta.attempt.
X-Wexio-TimestampSame as envelope.timestamp - the event time, ISO-8601.
Idempotency-KeySame as envelope.id, for HTTP middleware that expects this name.
X-Wexio-Signaturet=<unix seconds>,v1=<hex>. See below.

X-Wexio-Timestamp (ISO-8601, event time) and the t in X-Wexio-Signature (unix seconds, delivery time) are different values in different units. Sign with t; never substitute the header.

Verify the Signature

v1 is HMAC-SHA256(signingSecret, t + "." + rawRequestBody), hex-encoded, where t is the unix-seconds value from the same header.

  1. Parse t and v1 out of X-Wexio-Signature.
  2. Compute the HMAC over `${t}.${rawBody}` with your whsec_ secret. Use the raw request body - re-serialized JSON will not match.
  3. Compare against v1 in constant time and reject on mismatch.
  4. Optionally reject when t is more than a few minutes off your clock, to block replays.
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWexioSignature(
  rawBody: string,
  header: string,
  secret: string,
): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=", 2)),
  );
  const t = parts.t;
  const v1 = parts.v1;
  if (!t || !v1) return false;

  // Optional replay window - t is the DELIVERY time in unix seconds.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

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

In Express, get the raw body with express.raw({ type: "application/json" }) on the webhook route, or express.json({ verify: (req, _res, buf) => { req.rawBody = buf } }). JSON.stringify(req.body) will fail verification.

The secret comes from registerPartnerWebhook and is shown once. Rotate with rotatePartnerWebhookSecret.

Responding

Your responseResult
2xxAcknowledged. No retry.
5xx, timeout, or a network failureRetried. Up to 6 attempts, exponential backoff starting at 2 seconds.
4xxNot retried. The event is dropped.

Answer 2xx as soon as you have durably accepted the event, then process asynchronously. A slow handler burns your retry budget.

A 4xx is treated as a permanent rejection, so a bug that returns 400 on a valid payload silently drops events with no retry. Return 5xx for anything you might recover from.

One Action, Several Deliveries

A single API call can produce several events. The clearest case is a cold start: one trigger_conversation emits four deliveries - contact.created, conversation.created, chat.created and message.outbound.created.

ConsequenceWhat to do
Your mirror can store one thread several timesDeduplicate on the ids the call returned, and on the envelope id
Your delivery log grows ~4× faster than the number of conversations startedSize endpoint throughput and log retention on deliveries, not on conversations

Ordinary traffic behaves the same way at smaller scale: one inbound message can produce a message.inbound.created, a chat.updated and a conversation.updated. Subscribe to the narrowest level that answers your question.

Deduplication and Ordering

  • Deduplicate on id (equivalently X-Wexio-Event-Id or Idempotency-Key). Events can arrive more than once - retries after a timeout where your side actually succeeded are the common case.
  • Do not assume ordering. Deliveries are queued per event, so message.inbound.created and a following chat.updated can arrive out of order. Reconcile on the timestamps in data, not on arrival order.
  • A PAUSED subscription drops matching events rather than queueing them. They are not replayed on resume.

Delivery Attempts You Will Never See

An event is not delivered at all when:

  • the subscription is PAUSED or deleted,
  • the event is not in subscribedEvents,
  • the client org is not one of your managed clients.

None of these produce an error on your side - the event simply never arrives.

On this page