Webhooks
Receive verification events and verify their signed requests.
Webhooks send verification lifecycle events to your HTTP endpoint. A sender profile can have multiple webhook URLs; each enabled webhook for that profile receives every event listed below.
Event envelope
Each request is an HTTP POST with a JSON envelope:
{
"id": "evt_0123456789abcdef01234567",
"type": "verification.created",
"created_at": "2025-01-01T12:00:00Z",
"data": {
"id": "vrf_0123456789abcdef01234567",
"account_id": "12345",
"sender_profile_id": "prf_0123456789abcdef01234567",
"channel": "sms",
"to": "+61412345678",
"country": "AU",
"code_length": 6,
"status": "pending",
"attempts": 0,
"max_attempts": 3,
"expires_at": "2025-01-01T12:15:00Z",
"created_at": "2025-01-01T12:00:00Z",
"updated_at": "2025-01-01T12:00:00Z"
}
}data is the public verification object. It does not contain the generated code or cost; Charges explains how to read charges from a verification.
The max_attempts value shown is an example; the event reports the applicable value.
Events
| Event | When it is sent |
|---|---|
verification.created | A verification has been created and accepted. |
verification.sent | The code was handed to the carrier or messaging network for delivery. |
verification.verified | The user submitted the correct code. |
verification.failed | The code could not be sent, or the allowed code checks were used up. |
verification.expired | The verification reached expires_at before it was verified. |
verification.cancelled | The verification was cancelled. |
Event examples
Each event carries the verification as it was when the event happened. A verification receives verification.created first, usually verification.sent, and then exactly one of verification.verified, verification.failed, verification.expired, or verification.cancelled. Events can arrive out of order, so use data.status and data.updated_at rather than arrival order.
verification.created
Sent right after the verification is created. Sending happens in the background, so status is still pending.
{
"id": "evt_1a2b3c4d5e6f7a8b9c0d1e2f",
"type": "verification.created",
"created_at": "2025-01-01T12:00:00Z",
"data": {
"id": "vrf_0123456789abcdef01234567",
"account_id": "12345",
"sender_profile_id": "prf_0123456789abcdef01234567",
"channel": "sms",
"to": "+61412345678",
"country": "AU",
"code_length": 6,
"status": "pending",
"attempts": 0,
"max_attempts": 3,
"expires_at": "2025-01-01T12:15:00Z",
"created_at": "2025-01-01T12:00:00Z",
"updated_at": "2025-01-01T12:00:00Z"
}
}verification.sent
The code was handed off for delivery. This confirms that sending succeeded, not that the message reached the handset.
{
"id": "evt_2b3c4d5e6f7a8b9c0d1e2f3a",
"type": "verification.sent",
"created_at": "2025-01-01T12:00:02Z",
"data": {
"id": "vrf_0123456789abcdef01234567",
"account_id": "12345",
"sender_profile_id": "prf_0123456789abcdef01234567",
"channel": "sms",
"to": "+61412345678",
"country": "AU",
"code_length": 6,
"status": "sent",
"attempts": 0,
"max_attempts": 3,
"expires_at": "2025-01-01T12:15:00Z",
"created_at": "2025-01-01T12:00:00Z",
"updated_at": "2025-01-01T12:00:02Z"
}
}verification.verified
The user entered the correct code. verified_at is set.
{
"id": "evt_3c4d5e6f7a8b9c0d1e2f3a4b",
"type": "verification.verified",
"created_at": "2025-01-01T12:02:00Z",
"data": {
"id": "vrf_0123456789abcdef01234567",
"account_id": "12345",
"sender_profile_id": "prf_0123456789abcdef01234567",
"channel": "sms",
"to": "+61412345678",
"country": "AU",
"code_length": 6,
"status": "verified",
"attempts": 1,
"max_attempts": 3,
"expires_at": "2025-01-01T12:15:00Z",
"verified_at": "2025-01-01T12:02:00Z",
"created_at": "2025-01-01T12:00:00Z",
"updated_at": "2025-01-01T12:02:00Z"
}
}verification.failed
This event covers two cases, which you can tell apart from attempts and max_attempts.
The code could not be sent, for example because the number cannot receive messages on that channel. No verification.sent event was sent, and attempts is lower than max_attempts, usually 0:
{
"id": "evt_4d5e6f7a8b9c0d1e2f3a4b5c",
"type": "verification.failed",
"created_at": "2025-01-01T12:00:03Z",
"data": {
"id": "vrf_0123456789abcdef01234567",
"account_id": "12345",
"sender_profile_id": "prf_0123456789abcdef01234567",
"channel": "sms",
"to": "+61412345678",
"country": "AU",
"code_length": 6,
"status": "failed",
"attempts": 0,
"max_attempts": 3,
"expires_at": "2025-01-01T12:15:00Z",
"created_at": "2025-01-01T12:00:00Z",
"updated_at": "2025-01-01T12:00:03Z"
}
}The user entered a wrong code on every allowed check. attempts equals max_attempts:
{
"id": "evt_5e6f7a8b9c0d1e2f3a4b5c6d",
"type": "verification.failed",
"created_at": "2025-01-01T12:03:30Z",
"data": {
"id": "vrf_0123456789abcdef01234567",
"account_id": "12345",
"sender_profile_id": "prf_0123456789abcdef01234567",
"channel": "sms",
"to": "+61412345678",
"country": "AU",
"code_length": 6,
"status": "failed",
"attempts": 3,
"max_attempts": 3,
"expires_at": "2025-01-01T12:15:00Z",
"created_at": "2025-01-01T12:00:00Z",
"updated_at": "2025-01-01T12:03:30Z"
}
}verification.expired
The verification reached expires_at without being verified. If sending kept hitting temporary errors until then, you receive this event with no verification.sent before it.
{
"id": "evt_6f7a8b9c0d1e2f3a4b5c6d7e",
"type": "verification.expired",
"created_at": "2025-01-01T12:15:00Z",
"data": {
"id": "vrf_0123456789abcdef01234567",
"account_id": "12345",
"sender_profile_id": "prf_0123456789abcdef01234567",
"channel": "sms",
"to": "+61412345678",
"country": "AU",
"code_length": 6,
"status": "expired",
"attempts": 0,
"max_attempts": 3,
"expires_at": "2025-01-01T12:15:00Z",
"created_at": "2025-01-01T12:00:00Z",
"updated_at": "2025-01-01T12:15:00Z"
}
}verification.cancelled
The verification was cancelled before it finished.
{
"id": "evt_7a8b9c0d1e2f3a4b5c6d7e8f",
"type": "verification.cancelled",
"created_at": "2025-01-01T12:01:00Z",
"data": {
"id": "vrf_0123456789abcdef01234567",
"account_id": "12345",
"sender_profile_id": "prf_0123456789abcdef01234567",
"channel": "sms",
"to": "+61412345678",
"country": "AU",
"code_length": 6,
"status": "cancelled",
"attempts": 0,
"max_attempts": 3,
"expires_at": "2025-01-01T12:15:00Z",
"created_at": "2025-01-01T12:00:00Z",
"updated_at": "2025-01-01T12:01:00Z"
}
}Detecting delivery failures
Sending happens in the background after Send returns, so a delivery problem shows up later as an event or a status change:
- The code could not be sent: you receive
verification.failedwithattemptsbelowmax_attempts, and noverification.sentbefore it. Get returnsstatus: "failed", and Check returnsfailed. Ask the user to confirm their number or address, or offer another channel by sending a new verification. - Sending kept failing until the verification expired: temporary errors are retried, so you receive
verification.expiredwith noverification.sentbefore it. Treat this like a failed delivery and let the user request a new code. - No
verification.sentafter a few seconds: sending is still being retried. You can poll Get while the status ispending.
verification.sent means the code was handed off for delivery, not that it reached the device. If the user says the code never arrived after verification.sent, cancel the verification and send a new one, optionally on another channel.
Without webhooks, poll Get or List with status=failed.
Verify signatures
Every request includes a Maileroo-Signature header:
Maileroo-Signature: t=1700000000,v1=<hex-signature>The signature is the lowercase hexadecimal HMAC-SHA256 of <t>.<raw body>, using the webhook's secret string as the key encoded in UTF-8. The secret is generated from 32 random bytes and returned as 64 lowercase hexadecimal characters. The secret field is included in create, list, get, and update responses; handle it as a credential.
Verify the signature against the untouched raw request body before parsing or changing the JSON. Reject timestamps more than five minutes from the current time to limit replay. Compare signatures in constant time.
Node.js
import crypto from "node:crypto";
export function verifyMaileroo(signature, secret, rawBody, now = Date.now()) {
const parts = Object.fromEntries(signature.split(",").map((part) => part.split("=")));
if (!/^\d+$/.test(parts.t ?? "") || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
const timestamp = Number(parts.t);
const age = Math.abs(Math.floor(now / 1000) - timestamp);
if (!Number.isSafeInteger(timestamp) || age > 300) return false;
const expected = crypto
.createHmac("sha256", Buffer.from(secret, "utf8"))
.update(Buffer.concat([Buffer.from(`${parts.t}.`, "utf8"), rawBody]))
.digest();
const received = Buffer.from(parts.v1, "hex");
return received.length === expected.length && crypto.timingSafeEqual(expected, received);
}Pass the original request body as a Buffer; a parsed and re-serialized JSON object will not have the same signature.
Python
import hashlib
import hmac
import time
def verify_maileroo(signature, secret, raw_body, now=None):
parts = {}
for item in signature.split(","):
key, separator, value = item.partition("=")
if not separator:
return False
parts[key] = value
timestamp = parts.get("t", "")
received = parts.get("v1", "")
if not timestamp.isascii() or not timestamp.isdigit():
return False
if len(received) != 64 or any(char not in "0123456789abcdef" for char in received):
return False
try:
timestamp_value = int(timestamp)
except ValueError:
return False
current_time = time.time() if now is None else now
if abs(int(current_time) - timestamp_value) > 300:
return False
expected = hmac.new(
secret.encode("utf-8"),
timestamp.encode("ascii") + b"." + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, received)Pass the original request body as bytes.
PHP
function verifyMaileroo(string $signature, string $secret, string $rawBody, ?int $now = null): bool {
$parts = [];
foreach (explode(',', $signature) as $item) {
$pair = explode('=', $item, 2);
if (count($pair) !== 2) return false;
$parts[$pair[0]] = $pair[1];
}
$timestamp = $parts['t'] ?? '';
$received = $parts['v1'] ?? '';
if (!preg_match('/^\d+$/D', $timestamp) || !preg_match('/^[0-9a-f]{64}$/D', $received)) return false;
if (abs(($now ?? time()) - (int) $timestamp) > 300) return false;
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $received);
}Pass the original request body string without decoding and encoding it again.
Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func verifyMaileroo(header, secret string, rawBody []byte, now time.Time) bool {
fields := map[string]string{}
for _, part := range strings.Split(header, ",") {
key, value, ok := strings.Cut(part, "=")
if ok {
fields[key] = value
}
}
timestamp := fields["t"]
received, err := hex.DecodeString(fields["v1"])
if err != nil || len(received) != sha256.Size {
return false
}
timestampValue, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
age := now.Unix() - timestampValue
if age < 0 {
age = -age
}
if age > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
return hmac.Equal(mac.Sum(nil), received)
}Retries and duplicate events
If a delivery fails or your endpoint returns a non-2xx response, retries are scheduled after 30 seconds, 2 minutes, 10 minutes, 1 hour, and 6 hours, subject to the delivery attempt limit. Retry requests reuse the same event id and serialized body, but have a fresh signature timestamp. Deduplicate processed events by the envelope id.