<!-- VanceTel docs · /docs/webhooks -->

# 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](/developers/docs).

## 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 `2xx` status quickly (see [Retries and backoff](#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](/developers/docs/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.

```json
{
  "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](/developers/docs/sms) 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`.

**Node**

```ts
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.

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);
  },
);
```

**Python**

```python
import hashlib

def verify_webhook(raw_body: bytes, signature_header: str, signing_secret: str) -> bool:
    # Header looks like: t=1717000000,v1=8a1f...c4
    parts = dict(p.split("=", 1) for p in (signature_header or "").split(","))
    ts, sig = parts.get("t"), parts.get("v1")
    if not ts or not sig:
        return False
    signed = ts.encode() + b"." + raw_body  # exact bytes received, not re-serialized
    expected = hmac.new(signing_secret.encode(), signed, hashlib.sha256).hexdigest()
    # Constant-time comparison guards against timing attacks.
    return hmac.compare_digest(expected, sig)

# Flask: request.get_data() returns the raw bytes.
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/webhooks/netexem")
def netexem_webhook():
    raw = request.get_data()
    ok = verify_webhook(
        raw,
        request.headers.get("X-Netexem-Signature"),
        os.environ["NETEXEM_WEBHOOK_SECRET"],
    )
    if not ok:
        abort(400)

    event = request.get_json()
    # ...handle event["type"], then acknowledge
    return "", 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:

```ts
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](/developers/docs/sms) — the Message object you'll receive in message events
- [Authentication](/developers/docs/authentication) — how API keys and the webhook signing secret differ
- [Errors](/developers/docs/errors) — the error shape returned by the API you call
