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
};
}| Field | Meaning |
|---|---|
id | Unique event identifier. Identical across every retry of the same event, so use it to deduplicate. |
type | Wire event name, dotted lowercase. The GraphQL enum name differs - see the catalogue. |
timestamp | When the event occurred, ISO-8601 UTC. Not the time of this delivery attempt. |
data | Per-event payload. Shapes are on each event page; the reusable ones are in shared types. |
meta.organisationId | The client org. Route on this or on externalId. |
meta.externalId | Your own client ID. Absent only if you never set one at provisioning. |
meta.partnerId | Your provider org. Present on tech-provider fan-out deliveries; absent on org webhooks. |
meta.apiVersion | Pinned to "2026-04-01". A breaking change to any data shape bumps it; adding an optional field does not. |
meta.attempt | 1-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>| Header | Value |
|---|---|
X-Wexio-Event | Same as envelope.type. Lets you route before parsing the body. |
X-Wexio-Event-Id | Same as envelope.id. |
X-Wexio-Attempt | Same as envelope.meta.attempt. |
X-Wexio-Timestamp | Same as envelope.timestamp - the event time, ISO-8601. |
Idempotency-Key | Same as envelope.id, for HTTP middleware that expects this name. |
X-Wexio-Signature | t=<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.
- Parse
tandv1out ofX-Wexio-Signature. - Compute the HMAC over
`${t}.${rawBody}`with yourwhsec_secret. Use the raw request body - re-serialized JSON will not match. - Compare against
v1in constant time and reject on mismatch. - Optionally reject when
tis 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 response | Result |
|---|---|
2xx | Acknowledged. No retry. |
5xx, timeout, or a network failure | Retried. Up to 6 attempts, exponential backoff starting at 2 seconds. |
4xx | Not 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.
| Consequence | What to do |
|---|---|
| Your mirror can store one thread several times | Deduplicate on the ids the call returned, and on the envelope id |
| Your delivery log grows ~4× faster than the number of conversations started | Size 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(equivalentlyX-Wexio-Event-IdorIdempotency-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.createdand a followingchat.updatedcan arrive out of order. Reconcile on the timestamps indata, not on arrival order. - A
PAUSEDsubscription 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
PAUSEDor 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.