Webhooks
LynSMS sends webhooks to your application as the status of a message changes. You'll know instantly when a message is sent, delivered, or fails.
Event types
| Event | Sent when |
|---|---|
message.sent | The upstream provider has accepted the message. |
message.delivered | The carrier has confirmed delivery to the handset. |
message.failed | The message could not be delivered (provider error, blocklisted, undeliverable). |
Create a webhook endpoint
curl https://lynsms.com/api/v1/webhooks/create \
-H "Authorization: Bearer ds_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/lynsms",
"description": "Production delivery receipts",
"events": ["message.sent", "message.delivered", "message.failed"]
}'
The response includes the signing_secret — store it. It is shown only on creation.
{
"success": true,
"data": {
"id": "whe_aKjf91zXq2pLm0c4d3eR5sNbY",
"object": "webhook_endpoint",
"url": "https://example.com/webhooks/lynsms",
"description": "Production delivery receipts",
"events": ["message.sent", "message.delivered", "message.failed"],
"is_active": true,
"signing_secret": "whsec_3f7b9a2e4d..."
}
}
Payload shape
Every webhook POSTs a JSON event of this form:
{
"id": "evt_a9bX2mF4tQpKrLcSdN1zVeY3o",
"object": "event",
"type": "message.delivered",
"created_at": "2026-05-12T11:34:23+00:00",
"data": {
"id": "msg_VyB2pNkX0wnA9aTrLqDh1Z3Fc",
"object": "message",
"to": "+12025550143",
"from": "LynSMS",
"status": "delivered",
"provider": "twilio",
"delivered_at": "2026-05-12T11:34:23+00:00"
}
}
Verifying signatures
Every webhook request includes a signature header:
LynSMS-Signature: t=1715512463,v1=8c1a...d4f7
v1 is the lowercase hex HMAC-SHA-256 of
{timestamp}.{raw_request_body} using your endpoint's signing secret. Verify the
signature before parsing the body.
import crypto from "node:crypto";
import express from "express";
const app = express();
// IMPORTANT: keep the raw body for signature verification.
app.post(
"/webhooks/lynsms",
express.raw({ type: "application/json" }),
(req, res) => {
const signatureHeader = req.header("LynSMS-Signature");
const [tsPart, sigPart] = signatureHeader.split(",");
const timestamp = tsPart.split("=")[1];
const signature = sigPart.split("=")[1];
const expected = crypto
.createHmac("sha256", process.env.LYNSMS_WEBHOOK_SECRET)
.update(`${timestamp}.${req.body.toString()}`)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
return res.status(400).send("bad signature");
}
const event = JSON.parse(req.body.toString());
switch (event.type) {
case "message.sent": console.log("sent:", event.data.id); break;
case "message.delivered": console.log("delivered:", event.data.id); break;
case "message.failed": console.log("failed:", event.data.id, event.data.error); break;
}
res.sendStatus(200);
}
);
app.listen(3000);
import os, hmac, hashlib, json
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["LYNSMS_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/lynsms")
def lynsms_webhook():
raw = request.get_data() # bytes — DO NOT use request.json() before verifying.
signature_header = request.headers.get("LynSMS-Signature", "")
parts = dict(p.split("=", 1) for p in signature_header.split(","))
timestamp, signature = parts.get("t", ""), parts.get("v1", "")
expected = hmac.new(
SECRET,
f"{timestamp}.{raw.decode()}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
return "bad signature", 400
event = json.loads(raw)
if event["type"] == "message.delivered":
print("delivered:", event["data"]["id"])
return "", 200
t is more than 5 minutes from your
server's clock, reject the event. This defends against captured-payload replay attacks.
Retry policy
If your endpoint does not return a 2xx within 10 seconds, LynSMS retries with exponential backoff:
| Attempt | Schedule |
|---|---|
| 1 | Immediate |
| 2 | + 30s |
| 3 | + 5m |
| 4 | + 30m |
| 5 | + 2h |
| 6 | + 12h |
After 6 failed attempts, the event is marked abandoned. After 25 consecutive
failed deliveries to the same endpoint, the endpoint is automatically disabled — you'll see
is_active: false and a disabled_at timestamp.
Inspecting events
List recent events your account has produced:
Best practices
- Acknowledge fast. Return
200within 10s — do heavy work asynchronously. - Be idempotent. The same event id can be redelivered. Dedupe on
id. - Verify every time. Never trust unsigned or stale payloads.
- Use HTTPS, on the public internet. Plain HTTP endpoints are accepted but not recommended.