Guides
Webhooks
inaiy POSTs a JSON event to your HTTPS endpoint whenever a message changes state or a customer writes in.
Set up an endpoint
- Expose an HTTPS endpoint that accepts
POSTwith a JSON body. - In Settings → Developer → Webhooks, add the URL and pick the events you want.
- Copy the signing secret and store it on your server.
- Verify the signature on every delivery, answer
2xxquickly, and do slow work in a queue.
Events
| Event | When it fires |
|---|---|
message.sent | An outbound message was accepted by the provider for delivery. |
message.delivered | An outbound message was delivered to the recipient device. |
message.read | The recipient read an outbound message. |
message.failed | An outbound message failed to send or deliver. |
message.received | An inbound message was received from a contact. |
Delivery headers
| Header | Meaning |
|---|---|
X-Inaiy-Event | Canonical event type (e.g. message.sent). |
X-Inaiy-Event-Id | Stable, idempotent event id. Dedupe on this value. |
X-Inaiy-Timestamp | ISO-8601 timestamp the event was generated. |
X-Inaiy-Delivery-Id | Unique id for this delivery attempt. |
X-Inaiy-Signature | HMAC-SHA256 signature. Format: t=<unixSeconds>,v1=<sig> (one v1= entry per active secret during rotation). |
Verify signatures
The X-Inaiy-Signature header looks like t=<unixSeconds>,v1=<hexHmac>. It is an HMAC-SHA256 of <timestamp>.<rawRequestBody> with your signing secret. During secret rotation the header carries one v1= value per active secret.
- Extract the t (timestamp) and v1 values from the header.
- Recompute HMAC-SHA256 over ${t}.${rawBody} using your webhook signing secret.
- Compare using a constant-time equality check against each v1 value.
- Reject deliveries whose timestamp is outside your tolerance window (e.g. 5 minutes) to prevent replay.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.INAIY_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
// Keep the raw body: the signature covers the exact bytes we sent.
app.post('/webhooks/inaiy', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-Inaiy-Signature') || '';
const parts = header.split(',').map((p) => p.split('='));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return res.sendStatus(400);
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${req.body}`).digest('hex');
const valid = signatures.some(
(sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
);
if (!valid) return res.sendStatus(401);
const event = JSON.parse(req.body);
// Dedupe on event.eventId (also in the X-Inaiy-Event-Id header), then handle it.
console.log(event.event, event.data);
res.sendStatus(200);
});Retries and idempotency
Exponential backoff with jitter, per subscription retry policy.
- Retried after:
408, 429, 500, 502, 503, 504, timeouts and connection errors. - Not retried:
400, 401, 403, 404, 410. - After the max attempts are exhausted the delivery is moved to a dead-letter state and can be replayed manually.
Deliveries can repeat
Dedupe on X-Inaiy-Event-Id: the same domain change always produces the same eventId. Store processed ids and skip duplicates.
Sample payloads
message.sent
Message sent
message.sent payload
{
"event": "message.sent",
"eventId": "evt_6650a1b2c3d4e5f6a7b8c9d0_message_sent",
"timestamp": "2026-07-12T09:30:05.000Z",
"data": {
"message": {
"id": "6650a1b2c3d4e5f6a7b8c9d0",
"conversationId": "6650a1b2c3d4e5f6a7b8c9d1",
"contactId": "6650a1b2c3d4e5f6a7b8c9d2",
"channelId": "6650a1b2c3d4e5f6a7b8c9d3",
"direction": "outbound",
"type": "text",
"status": "sent",
"body": "Hello from Inaiy!",
"timestamp": "2026-07-12T09:30:00.000Z",
"providerMessageId": "wamid.HBgLMTQ..."
}
}
}message.delivered
Message delivered
message.delivered payload
{
"event": "message.delivered",
"eventId": "evt_6650a1b2c3d4e5f6a7b8c9d0_message_delivered",
"timestamp": "2026-07-12T09:30:05.000Z",
"data": {
"message": {
"id": "6650a1b2c3d4e5f6a7b8c9d0",
"conversationId": "6650a1b2c3d4e5f6a7b8c9d1",
"contactId": "6650a1b2c3d4e5f6a7b8c9d2",
"channelId": "6650a1b2c3d4e5f6a7b8c9d3",
"direction": "outbound",
"type": "text",
"status": "delivered",
"body": "Hello from Inaiy!",
"timestamp": "2026-07-12T09:30:00.000Z",
"providerMessageId": "wamid.HBgLMTQ..."
}
}
}message.read
Message read
message.read payload
{
"event": "message.read",
"eventId": "evt_6650a1b2c3d4e5f6a7b8c9d0_message_read",
"timestamp": "2026-07-12T09:30:05.000Z",
"data": {
"message": {
"id": "6650a1b2c3d4e5f6a7b8c9d0",
"conversationId": "6650a1b2c3d4e5f6a7b8c9d1",
"contactId": "6650a1b2c3d4e5f6a7b8c9d2",
"channelId": "6650a1b2c3d4e5f6a7b8c9d3",
"direction": "outbound",
"type": "text",
"status": "read",
"body": "Hello from Inaiy!",
"timestamp": "2026-07-12T09:30:00.000Z",
"providerMessageId": "wamid.HBgLMTQ..."
}
}
}message.failed
Message failed
message.failed payload
{
"event": "message.failed",
"eventId": "evt_6650a1b2c3d4e5f6a7b8c9d0_message_failed",
"timestamp": "2026-07-12T09:30:05.000Z",
"data": {
"message": {
"id": "6650a1b2c3d4e5f6a7b8c9d0",
"conversationId": "6650a1b2c3d4e5f6a7b8c9d1",
"contactId": "6650a1b2c3d4e5f6a7b8c9d2",
"channelId": "6650a1b2c3d4e5f6a7b8c9d3",
"direction": "outbound",
"type": "text",
"status": "failed",
"body": "Hello from Inaiy!",
"timestamp": "2026-07-12T09:30:00.000Z",
"providerMessageId": "wamid.HBgLMTQ..."
}
}
}message.received
Message received
message.received payload
{
"event": "message.received",
"eventId": "evt_6650a1b2c3d4e5f6a7b8c9d0_message_received",
"timestamp": "2026-07-12T09:30:05.000Z",
"data": {
"message": {
"id": "6650a1b2c3d4e5f6a7b8c9d0",
"conversationId": "6650a1b2c3d4e5f6a7b8c9d1",
"contactId": "6650a1b2c3d4e5f6a7b8c9d2",
"channelId": "6650a1b2c3d4e5f6a7b8c9d3",
"direction": "inbound",
"type": "text",
"status": "received",
"body": "Hi, I need help.",
"timestamp": "2026-07-12T09:30:00.000Z",
"providerMessageId": "wamid.HBgLMTQ..."
}
}
}