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. 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 | Sending the verification succeeded. |
verification.verified | The user submitted the correct code. |
verification.failed | Delivery failed or the allowed code checks were exhausted. |
verification.expired | The verification expired. |
verification.cancelled | The verification was cancelled. |
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.