Webhooks

Webhook Events

Webhooks tell your server when something happens, so you don't have to keep asking. Transmit sends a signed POST request to your URL for every event you subscribe to.

Setting up

  • 1. Add an endpoint: On the Webhooks page, add your HTTPS URL and pick the events you want. Pick none to get all of them. Up to 5 endpoints.
  • 2. Save the secret: It is shown once. Store it as TRANSMIT_WEBHOOK_SECRET.
  • 3. Handle the request: Verify the signature, act on the event, and answer 200.

Event types

otp.sent

A code was accepted and queued for delivery.

otp.verified

A code was successfully verified.

broadcast.queued

A broadcast passed validation and entered the send pipeline.

The Test button in the dashboard sends a webhook.test event, so you can check your handler without sending a real code.

The payload

{
  "id": "3f2a9c81-77b0-4aa1-9d64-02de5c645ea3",
  "event": "otp.verified",
  "timestamp": "2026-09-26T14:03:11.402Z",
  "data": {
    "otpId": "9c1f0b7a-4d2e-4f61-9a3c-6b0e2d81f5aa",
    "channel": "sms",
    "destination": "+220833001234"
  }
}
  • id: The same on every retry. Save it and skip events you have already handled.
  • event: Which event this is.
  • data: For OTP events: otpId, channel, destination. For broadcast.queued: broadcastId, channel, totalCount.

Example handler

A complete server that verifies the signature and handles each event. There is no SDK to install — the check is a few lines of standard HMAC.

const crypto = require("crypto");
const express = require("express");

const app = express();

// Returns true when any v1 signature matches and the timestamp is under 5 minutes old.
function verifySignature(rawBody, header, secret) {
  const parts = (header ?? "").split(",");
  const timestamp = parts.find((p) => p.startsWith("t="))?.slice(2);
  const signatures = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));

  if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  return signatures.some(
    (sig) =>
      sig.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
  );
}

// express.raw, not express.json — the signature covers the exact bytes sent
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");
  const signature = req.headers["x-transmit-signature"];
  const secret = process.env.TRANSMIT_WEBHOOK_SECRET;

  if (!verifySignature(rawBody, signature, secret)) {
    return res.status(400).send("Invalid signature!");
  }

  const event = JSON.parse(rawBody);

  switch (event.event) {
    case "otp.sent":
      // A code is on its way to event.data.destination
      break;
    case "otp.verified":
      // Mark event.data.destination as verified
      break;
    case "broadcast.queued":
      // event.data.broadcastId is sending to event.data.totalCount recipients
      break;
    case "webhook.test":
      // Sent by the Test button in the dashboard
      break;
  }

  res.status(200).send("Webhook processed!");
});

app.listen(3000, () => console.log("Listening for webhooks on port 3000"));

How the check works is explained in Verifying Signatures.