#Webhooks
Webhooks push real-time events to your server. Where the rest of the API is something you call, webhooks are how we notify you: an inbound text arrives, a message is delivered or fails, a call wraps up. Each event is an HTTP POST to an endpoint you control, signed so you can prove it came from us.
Webhooks are the inbound half of the platform. The API is write- and action-oriented (you send messages and create contacts); webhooks deliver everything that happens back — see the Introduction.
#Subscribing to events
Register an HTTPS endpoint from your dashboard and select the event types you want. Two rules apply to every endpoint:
- It must be reachable over HTTPS at a public URL.
- It must respond with a
2xxstatus quickly (see Retries and backoff).
When you create the endpoint we issue a signing secret (whsec_…). Store it server-side and use it to verify every delivery. Treat it like an API key — see Authentication.
#The event envelope
Every webhook shares one envelope. The type tells you what happened, createdAt is when it happened in UTC, and data carries the event-specific object.
{
"type": "message.received",
"createdAt": "2026-05-31T17:04:22Z",
"data": {
"id": "msg_01h…",
"from": "+15557654321",
"to": "+15551234567",
"body": "Yes, 2pm works",
"direction": "inbound",
"status": "delivered"
}
}The shape of data depends on type. For message events it is the Message object; for call events it is a call summary. Always branch on type before reading data.
#Event catalog
| Event | When it fires | data payload |
|---|---|---|
message.received | An inbound SMS or MMS arrives at one of your numbers. | The inbound Message (direction: "inbound"). |
message.delivered | The carrier confirms an outbound message reached the handset. | The Message with status: "delivered". |
message.failed | An outbound message could not be delivered (bad number, carrier rejection, opt-out). | The Message with status: "failed". |
call.completed | A call ends. | A call summary with the two parties, direction, start time and duration. |
call.missed | An inbound call is not answered. | A call summary with status: "missed". |
contact.created | A contact is created (via the API or a contact form). | The new Contact object. |
message.received is the one most integrations start with — it's how you build auto-replies and route inbound texts. Pair it with message.delivered and message.failed to track outbound send outcomes without polling.
#Verifying the signature
We sign every request with HMAC-SHA256 over the timestamp joined to the raw request body, keyed with your endpoint's signing secret. The X-Netexem-Signature header carries both parts as t=<unix-seconds>,v1=<hex-digest>. Split out v1, recompute the HMAC over t + "." + the raw body, and compare against it using a timing-safe comparison. Reject stale timestamps to blunt replays.
Verify the signature before you trust or act on a payload. An unverified request can be
forged by anyone who knows your URL. Compute HMAC over the exact raw bytes you received — parse
the JSON only after the signature checks out — and reject anything that doesn't match with a
400.
import crypto from "node:crypto";
// rawBody must be the exact bytes received, NOT a re-serialized object.
// Header looks like: t=1717000000,v1=8a1f...c4
function verifyWebhook(rawBody, signatureHeader, signingSecret) {
const parts = Object.fromEntries(
(signatureHeader ?? "").split(",").map((kv) => kv.split("=")),
);
const { t: ts, v1: sig } = parts;
if (!ts || !sig) return false;
const expected = crypto
.createHmac("sha256", signingSecret)
.update(ts + "." + rawBody)
.digest("hex");
const a = Buffer.from(sig);
const b = Buffer.from(expected);
// Lengths must match before timingSafeEqual, or it throws.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: capture the raw body so the signature stays byte-exact.
import express from "express";
const app = express();
app.post(
"/webhooks/netexem",
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verifyWebhook(
req.body, // Buffer of raw bytes
req.get("X-Netexem-Signature"),
process.env.NETEXEM_WEBHOOK_SECRET,
);
if (!ok) return res.status(400).send("invalid signature");
const event = JSON.parse(req.body.toString("utf8"));
// ...handle event.type, then acknowledge
res.sendStatus(200);
},
);#Retries and backoff
We treat any 2xx response as a successful acknowledgement. Anything else — a non-2xx status, a connection error, or a timeout — is considered a failure and the event is retried with exponential backoff over an extended window. Repeated failures eventually stop and the event is marked undelivered.
Return your 2xx quickly. Do the minimum to confirm receipt — verify the signature, enqueue or persist the event — then respond. Run slow work (database writes, third-party calls, sending replies) asynchronously after you've acknowledged.
If your handler does heavy work inline, a slow run can trip our timeout and trigger a retry — so you process the same event twice. Acknowledge fast and process out of band to avoid it.
#Idempotency
Because retries can deliver the same event more than once, your handler must be idempotent. Every event carries a stable id in data (for example msg_01h…). Record the ids you've already processed and skip duplicates:
async function handleEvent(event: { type: string; data: { id: string } }) {
const seen = await store.has(event.data.id)
if (seen) return // already processed — safe to ignore
await process(event)
await store.add(event.data.id)
}Deduplicating on the event id means a retried delivery is a no-op, so backoff retries never double-charge, double-reply, or double-write.
#Next steps
- Send an SMS — the Message object you'll receive in message events
- Authentication — how API keys and the webhook signing secret differ
- Errors — the error shape returned by the API you call