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": { }
}
typeWhendata
verification.succeededA session was verifiedsession_id, phone, operator, verified_at, metadata
rule.matchedAn SMS matched one of your rulesrule_id, prefix, phone, text, operator, received_at
session.expiredA session expired unverifiedsession_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...e9a1
  • t is the Unix time in seconds when we signed the request.
  • v1 is 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.

verify.js
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);
// });
verify.go
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 }
verify.php
<?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);
verify.py
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 "", 200

Respond 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 2xx within 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?