Webhooks & signatures
How results reach your server, how to verify the HMAC signature, and how to handle retries safely.
A webhook is an event notification that 2fa.mn sends to your server automatically. Each one is a signed HTTPS POST request. Each mode (test and live) has one webhook endpoint and its own secret.
Set up the endpoint
curl -X PUT https://api.2fa.mn/v1/webhook \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/webhooks/2fa" }'{ "url": "https://example.com/webhooks/2fa", "secret": "whsec_..." }Read the current setting with GET /v1/webhook. Rotate the secret with POST /v1/webhook/rotate-secret; the response contains the new secret.
Events
Every event has the same envelope:
{
"id": "evt_9a8b7c",
"type": "verification.succeeded",
"mode": "live",
"created_at": "2026-12-01T10:00:42Z",
"data": { }
}type | When | data |
|---|---|---|
verification.succeeded | A session was verified | session_id, phone, operator, verified_at, metadata |
rule.matched | An SMS matched one of your rules | rule_id, prefix, phone, text, operator, received_at |
session.expired | A session expired unverified | session_id |
Example verification.succeeded:
{
"id": "evt_9a8b7c",
"type": "verification.succeeded",
"mode": "live",
"created_at": "2026-12-01T10:00:42Z",
"data": {
"session_id": "ses_8f2k1c9x",
"phone": "99112233",
"operator": "unitel",
"verified_at": "2026-12-01T10:00:41Z",
"metadata": { "user_id": "u_42" }
}
}Verify the signature
Every request carries this header:
X-2FA-Signature: t=1764583242,v1=5f2c...e9a1tis the Unix time in seconds when we signed the request.v1is the hex-encoded HMAC-SHA256 of the string"<t>.<raw body>", keyed with your webhook secret.
To verify:
Read the raw body
Read the raw request body as bytes. Don't parse and re-serialize the JSON first; any change in whitespace breaks the signature.
Split the header
Split the header into t and v1.
Check the timestamp
Reject the request if t is more than 300 seconds away from your current time.
Compute the HMAC
Compute HMAC-SHA256 of t + "." + rawBody with your secret, hex-encode it.
Compare
Compare it with v1 using a constant-time comparison.
import crypto from "node:crypto";
export function verify2faSignature(rawBody, header, secret) {
const parts = Object.fromEntries(
(header ?? "").split(",").map((p) => p.trim().split("=")),
);
const t = Number(parts.t);
if (!t || !parts.v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody) // Buffer or string, exactly as received
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(parts.v1, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: use express.raw() so req.body is the raw Buffer.
// app.post("/webhooks/2fa", express.raw({ type: "application/json" }), (req, res) => {
// if (!verify2faSignature(req.body, req.get("X-2FA-Signature"), process.env.TWOFA_WEBHOOK_SECRET)) {
// return res.sendStatus(400);
// }
// const event = JSON.parse(req.body);
// res.sendStatus(200);
// });package twofa
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"strconv"
"strings"
"time"
)
func VerifySignature(rawBody []byte, header, secret string) bool {
var t int64
var v1 string
for _, part := range strings.Split(header, ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
t, _ = strconv.ParseInt(kv[1], 10, 64)
case "v1":
v1 = kv[1]
}
}
if t == 0 || v1 == "" {
return false
}
if math.Abs(float64(time.Now().Unix()-t)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(v1))
}
// In your handler: body, _ := io.ReadAll(r.Body)
// if !VerifySignature(body, r.Header.Get("X-2FA-Signature"), secret) { w.WriteHeader(400); return }<?php
function verify_2fa_signature(string $rawBody, string $header, string $secret): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
$kv = explode('=', trim($part), 2);
if (count($kv) === 2) {
$parts[$kv[0]] = $kv[1];
}
}
$t = isset($parts['t']) ? (int) $parts['t'] : 0;
$v1 = $parts['v1'] ?? '';
if ($t === 0 || $v1 === '') {
return false;
}
if (abs(time() - $t) > 300) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $v1);
}
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_2FA_SIGNATURE'] ?? '';
if (!verify_2fa_signature($rawBody, $header, getenv('TWOFA_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($rawBody, true);
http_response_code(200);import hashlib
import hmac
import time
def verify_2fa_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = {}
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if value:
parts[key] = value
try:
t = int(parts.get("t", "0"))
except ValueError:
return False
v1 = parts.get("v1", "")
if not t or not v1:
return False
if abs(int(time.time()) - t) > 300:
return False
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask:
# @app.post("/webhooks/2fa")
# def webhook():
# if not verify_2fa_signature(request.get_data(), request.headers.get("X-2FA-Signature", ""), SECRET):
# abort(400)
# event = request.get_json()
# return "", 200Respond quickly
Return any 2xx status within 10 seconds. Do heavy work (sending emails, updating other systems) after you respond, for example from a job queue.
Retries
If your endpoint times out or returns anything other than 2xx, we retry with growing delays of roughly 10 seconds, 1 minute, 5 minutes, 30 minutes and 2 hours, up to 5 retries (6 attempts in all). After the last attempt the delivery is marked failed and stays visible in the Dashboard. This applies to live webhooks: test webhooks are sent once and never retried.
Deduplicate on the event id
A retry reuses the same id. Store processed ids and ignore an event you have already handled, so a retry never verifies a user twice.
Delivery history
See recent deliveries and their status:
curl https://api.2fa.mn/v1/webhook/deliveries -H "Authorization: Bearer sk_live_..."{
"data": [
{
"event_id": "evt_9a8b7c",
"type": "verification.succeeded",
"status": "delivered",
"attempts": 1,
"last_status_code": 200,
"created_at": "2026-12-01T10:00:42Z",
"delivered_at": "2026-12-01T10:00:42Z"
}
]
}status is pending, delivered or failed. The Dashboard shows the same history.
Checklist
- Verify the signature on the raw body, with a constant-time compare.
- Reject timestamps older or newer than 300 seconds.
- Deduplicate on
id. - Respond
2xxwithin 10 seconds. - Use HTTPS for your endpoint.
- To add an IP-based restriction on top, email hello@2fa.mn; verifying the signature stays the reliable check.
- You can also limit your API key to your server's IP addresses: see API keys.
What's next
Was this page helpful?