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.sentA code was accepted and queued for delivery.
otp.verifiedA code was successfully verified.
broadcast.queuedA 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. Forbroadcast.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.

