Webhooks
Instead of polling, register an endpoint in the dashboard and LapSMS pushes an event every time a message changes status: message.submitted, message.delivered, message.undelivered and message.failed.
Payload
{
"id": "evt_01j9vabc123...",
"type": "message.delivered",
"created_at": "2026-08-14T12:34:56Z",
"data": {
"id": "msg_01j9v7...",
"status": "delivered",
"to": "+27821234567",
"segments": 1,
"charge": { "amount": 0.49, "currency": "ZAR" }
}
}The id is stable per event - if you receive the same event twice (retries), use it to deduplicate.
Account events
Four events are about your account rather than a single message, so they are sent to every subscribed endpoint in your organisation:
payment.completed when a wallet top-up clears and the credit is available, and payment.failed when one does not (cancelled, declined, or an amount that did not match). Use these instead of the browser redirect if you reconcile payments yourself - the redirect can be abandoned, these cannot.
{
"id": "evt_01j9vdef456...",
"type": "payment.completed",
"created_at": "2026-08-15T06:12:04Z",
"data": {
"id": "pay_01j9v7k2m3n4p5q6r7s8t",
"status": "succeeded",
"amount": 500.00,
"currency": "ZAR",
"provider": "payfast",
"provider_reference": "1089250",
"failure_reason": null,
"completed_at": "2026-08-15T06:12:04Z"
}
}wallet.low_balance fires once when available credit drops to the threshold you set on the wallet page, and re-arms after a top-up lifts you back above it. spend_limit.reached fires the first time a spend limit blocks a send in a given window - one alert per window, not per blocked request.
{
"id": "evt_01j9vghi789...",
"type": "wallet.low_balance",
"created_at": "2026-08-15T06:30:00Z",
"data": {
"available": 42.00,
"balance": 42.00,
"reserved": 0.00,
"currency": "ZAR",
"threshold": 50.00,
"approx_sms_remaining": 120
}
}Signature verification
Every delivery is signed with your endpoint's whsec_… secret (shown once at creation). The signature covers a timestamp plus the exact raw body:
POST /your/endpoint HTTP/1.1 Content-Type: application/json LapSMS-Signature: t=1755172496,v1=5f8a2c... LapSMS-Event-Id: evt_01j9vabc123... LapSMS-Event-Type: message.delivered
Compute HMAC-SHA256(secret, `{t}.{rawBody}`) and compare to v1 in constant time. Reject if the timestamp is older than 5 minutes - that defeats replay attacks.
import { createHmac, timingSafeEqual } from "crypto";
export function verifyLapSMS(
signatureHeader: string, // "t=1755172496,v1=5f8a..."
rawBody: string, // the EXACT request body, unparsed
secret: string, // your whsec_... signing secret
): boolean {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=") as [string, string]),
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // 5 min replay window
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""));
}using System.Security.Cryptography;
using System.Text;
static bool VerifyLapSMS(string signatureHeader, string rawBody, string secret)
{
long t = 0; string? v1 = null;
foreach (var part in signatureHeader.Split(','))
{
if (part.StartsWith("t=")) long.TryParse(part[2..], out t);
else if (part.StartsWith("v1=")) v1 = part[3..];
}
if (t == 0 || v1 is null) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300) return false;
var expected = Convert.ToHexStringLower(HMACSHA256.HashData(
Encoding.UTF8.GetBytes(secret),
Encoding.UTF8.GetBytes($"{t}.{rawBody}")));
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(v1));
}Retries and dead-letter
Respond with any 2xx within 10 seconds to acknowledge. Anything else is retried with backoff: 30s, 2m, 10m, 30m, 2h. After 6 failed attempts the delivery is dead_lettered - visible in the dashboard's delivery history, where one click replays it with a fresh retry window.
Best practices
Acknowledge first, process later: persist the payload and return 200 immediately, then handle it async. Always verify the signature before trusting the payload. Deduplicate on id. Never assume ordering - a delivered event can arrive after you already saw the status via polling.