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

  1. Expose an HTTPS endpoint that accepts POST with a JSON body.
  2. In Settings → Developer → Webhooks, add the URL and pick the events you want.
  3. Copy the signing secret and store it on your server.
  4. Verify the signature on every delivery, answer 2xx quickly, and do slow work in a queue.

Events

EventWhen it fires
message.sentAn outbound message was accepted by the provider for delivery.
message.deliveredAn outbound message was delivered to the recipient device.
message.readThe recipient read an outbound message.
message.failedAn outbound message failed to send or deliver.
message.receivedAn inbound message was received from a contact.

Delivery headers

HeaderMeaning
X-Inaiy-EventCanonical event type (e.g. message.sent).
X-Inaiy-Event-IdStable, idempotent event id. Dedupe on this value.
X-Inaiy-TimestampISO-8601 timestamp the event was generated.
X-Inaiy-Delivery-IdUnique id for this delivery attempt.
X-Inaiy-SignatureHMAC-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.

  1. Extract the t (timestamp) and v1 values from the header.
  2. Recompute HMAC-SHA256 over ${t}.${rawBody} using your webhook signing secret.
  3. Compare using a constant-time equality check against each v1 value.
  4. 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..."
    }
  }
}