Webhooks
Receive signed real-time event notifications when messages are delivered or verifications complete.
Webhooks let Moorsyl push events to your server the moment something happens — a message delivered, a verification approved. Instead of polling the API, you register an HTTPS endpoint and Moorsyl calls it for you.
Supported events
| Event | Triggered when |
|---|---|
sms.sent | An SMS was successfully delivered to the carrier |
sms.failed | An SMS permanently failed after all retries |
verify.sent | A verification code was sent to a phone number |
verify.approved | A user entered the correct verification code |
verify.failed | A verification session expired or max attempts were exceeded |
Set up an endpoint
- Open the dashboard → Webhooks
- Click Add Endpoint
- Enter your HTTPS URL — local URLs (
localhost) are not accepted - Select the events you want to subscribe to
- Copy the signing secret (
whsec_…) — it is shown only once
Moorsyl sends a POST request to your endpoint for each subscribed event.
Event payload
All events share the same envelope:
{
"type": "verify.approved",
"verificationId": "ver_...",
"to": "+22236551999",
"organizationId": "org_..."
}sms.sent
{
"type": "sms.sent",
"smsId": "sms_...",
"to": "+22236551999",
"providerMessageId": "provider-ref-...",
"organizationId": "org_..."
}sms.failed
{
"type": "sms.failed",
"smsId": "sms_...",
"to": "+22236551999",
"errorText": "Carrier rejected message",
"organizationId": "org_..."
}verify.sent
{
"type": "verify.sent",
"verificationId": "ver_...",
"to": "+22236551999",
"organizationId": "org_..."
}verify.approved
{
"type": "verify.approved",
"verificationId": "ver_...",
"to": "+22236551999",
"organizationId": "org_..."
}verify.failed
{
"type": "verify.failed",
"verificationId": "ver_...",
"to": "+22236551999",
"organizationId": "org_..."
}Verifying signatures
Every request from Moorsyl includes a Moorsyl-Signature header. You should verify this header before processing the event to ensure the request is genuine.
Header format
Moorsyl-Signature: t=1712345678,v1=a3f2b1c4d5e6...t— Unix timestamp in seconds of when the event was sentv1— HMAC-SHA256 signature
Verification steps
- Extract
tandv1from the header - Construct the signed string:
"${t}.${rawRequestBody}" - Compute HMAC-SHA256 of the signed string using your
whsec_…secret (base64-decoded) - Compare your computed signature to
v1using a constant-time comparison
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhook(
rawBody: string,
header: string,
secret: string // your whsec_... value
): boolean {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=") as [string, string])
);
const timestamp = parts["t"];
const signature = parts["v1"];
if (!timestamp || !signature) return false;
// Reject events older than 5 minutes to prevent replay attacks
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (age > 300) return false;
const keyBytes = Buffer.from(secret.replace("whsec_", ""), "base64");
const expected = createHmac("sha256", keyBytes)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}import hmac
import hashlib
import base64
import time
def verify_webhook(raw_body: str, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = parts.get("t")
signature = parts.get("v1")
if not timestamp or not signature:
return False
# Reject events older than 5 minutes
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = hmac.new(key, f"{timestamp}.{raw_body}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)Always use a constant-time comparison (timingSafeEqual / hmac.compare_digest) to avoid timing attacks.
Retry schedule
If your endpoint does not return a 2xx response within 10 seconds, Moorsyl marks the delivery as failed and retries automatically:
| Attempt | Delay after previous failure |
|---|---|
| 1 (initial) | Immediate |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 8 hours |
After 5 failed attempts the event is marked failed and no further retries are made. You can view failed events in the dashboard → Webhooks → Events.
Rotate a signing secret
If your signing secret is compromised:
- Open the dashboard → Webhooks
- Click the menu on your endpoint → Rotate Secret
- Copy and store the new
whsec_…secret - Update your verification logic with the new secret
The old secret stops working immediately after rotation.
Best practices
- Always verify the
Moorsyl-Signatureheader before trusting the payload - Reject events with a timestamp older than 5 minutes (replay attack protection)
- Return a
2xxresponse quickly — do heavy processing asynchronously - Make your handler idempotent — the same event may be delivered more than once
- Monitor the Events log in the dashboard for delivery failures