SMS Delivery Reports
Get the status of every SMS you send posted to your own server as it happens: accepted, rejected, delivered and failed. It covers SMS sent from the app and from the API.
This is the SMS version of Webhooks. It has its own address, its own signing secret and its own signature format.
Set it up in the app
Delivery reports are set up in the app, not with an API key. Only an owner or a manager can do it.
Open the SMS card
Open Settings, click Configure Channels, and on the SMS card click Delivery reports. The button shows once SMS is Active.
Add your server address
Paste the address on your server that should get the reports, for example
https://acme-boutique.in/sms-reports. It must start withhttps://and be reachable from the internet. Addresses inside a private network are refused.Pick the reports
Tick the reports you want. Leave them all ticked if you are not sure.
Save and copy the secret
Click Save. The first save shows your signing secret once. Copy it and put it on your server. You use it to check the signature.
Send a test
Click Send test. A test report reaches your server within about 10 seconds, and the window shows whether it arrived. You can send up to 5 tests a minute.
Stuck? Click Fix it for me
The window checks your setup as you type and explains any problem in plain words. Fix it for me corrects what it can, such as a missing https://, spaces in the address or nothing ticked, and saves. It never switches reports on or ticks a report you left unticked. Those are your choices, so the window only notes them.
Events
You are charged when an SMS is sent. If it is later not delivered, the charge is not refunded, so a message.failed report never comes with money back. See SMS pricing.
Request format
Each report is an HTTP POST with a JSON body:
{
"event": "message.delivered",
"sent_at": "2026-09-26T10:42:10Z",
"data": {
"detail": "Delivered to phone",
"message_id": "3f6c1a52-8d4e-4b7a-9c21-5e0f7a9b1c34",
"occurred_at": "2026-09-26T10:42:09Z",
"status": "delivered"
},
"attempt": 1,
"event_id": "5c1d8e20-7b9a-11f1-9f3c-0242ac120002"
}message.accepted and message.rejected
{
"event": "message.accepted",
"sent_at": "2026-09-26T10:42:03Z",
"data": {
"category": "service_implicit",
"charged": 0.16,
"detail": "",
"message_id": "3f6c1a52-8d4e-4b7a-9c21-5e0f7a9b1c34",
"occurred_at": "2026-09-26T10:42:03Z",
"parts": 1,
"sender": "ACMEOT",
"status": "sent",
"to": "919876543210"
},
"attempt": 1,
"event_id": "4a7e9b10-7b9a-11f1-9f3c-0242ac120002"
}message.delivered and message.failed
{
"event": "message.failed",
"sent_at": "2026-09-26T11:02:40Z",
"data": {
"detail": "Not delivered: phone switched off",
"message_id": "3f6c1a52-8d4e-4b7a-9c21-5e0f7a9b1c34",
"occurred_at": "2026-09-26T11:02:39Z",
"status": "failed"
},
"attempt": 1,
"event_id": "8b2f0c40-7b9d-11f1-9f3c-0242ac120002"
}status is delivered or failed. detail is a short status note from the phone network. Show it or log it, but do not match on its exact words, because it can change.
The test report
Send test posts a message.delivered report with made-up values and "test": true in data. The message_id is all zeros, to is 919876543210, parts is 1, charged is 0.16 and detail is This is a test event. Nothing is sent or charged. Your server should answer it with 200 and do nothing else.
The test is a Delivered report, so it only arrives when Delivered is ticked. It goes to your saved address, and only while reports are switched on.
Check the signature
Anyone who learns your address can post to it. The signature proves a report came from us and was not changed on the way.
The X-Splashify-Signature header looks like this:
t=1790412130,v1=5f2b8c...e41atis when we signed it, in Unix seconds. Each try, including a retry, is signed again with a newt.v1is an HMAC-SHA256, in lowercase hex, of the textt, then a dot, then the raw request body. The key is your whole signing secret, including thewhsec_at the start.
To check it:
- Read the raw body exactly as it arrived, before any JSON parsing.
- Build the text
t+.+ raw body, and compute its HMAC-SHA256 with your secret. - Compare it with
v1using a constant-time compare. - Refuse it if
tis more than 5 minutes old, so an old report cannot be replayed.
Node.js / TypeScript (Express)
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.SPLASHIFY_SMS_WEBHOOK_SECRET!; // whsec_...
function verify(raw: Buffer, header: string): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string]));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
// Sign the t value exactly as sent, then the raw body.
const expected = createHmac("sha256", SECRET).update(`${parts.t}.`).update(raw).digest("hex");
const got = Buffer.from(parts.v1 || "", "utf8");
const want = Buffer.from(expected, "utf8");
return got.length === want.length && timingSafeEqual(got, want);
}
const app = express();
app.post("/sms-reports", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("X-Splashify-Signature") || "")) {
return res.status(401).send("bad signature");
}
const report = JSON.parse(req.body.toString("utf8"));
// Skip report.event_id if you have seen it before, then save report.data.
res.sendStatus(200);
});Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["SPLASHIFY_SMS_WEBHOOK_SECRET"].encode() # whsec_...
app = Flask(__name__)
def verify(raw: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts.get("t", "0"))
except ValueError:
return False
if abs(time.time() - t) > 300:
return False
# Sign the t value exactly as sent, then the raw body.
expected = hmac.new(SECRET, parts["t"].encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
@app.post("/sms-reports")
def sms_reports():
if not verify(request.get_data(), request.headers.get("X-Splashify-Signature", "")):
abort(401)
report = request.get_json()
# Skip report["event_id"] if you have seen it before, then save report["data"].
return "", 200Use the raw body
Parsing the JSON and turning it back into text changes the spacing and the order of the keys, and the signature no longer matches. Always check the signature on the body exactly as it arrived.
A new secret
In the Delivery reports window, click Make a new secret. The old secret stops working straight away, so put the new one on your server right after.
Retries
Answer with any 2xx code, such as 200, within 10 seconds. Do the slow work after you answer.
Anything else, or no answer, counts as a failure, and we try again after 1, 5, 15, 60 and 360 minutes: 6 tries in all, over about 8 hours. We do not follow redirects, so a 3xx answer is a failure too. Save the final address, with the full path. A retry carries the same event_id and a higher attempt. Its keys can come in a different order, so always check the signature on the raw body. After the last try the report is dropped.
The Delivery reports window shows the result of the last try, and what to do about it in plain words.
Good to know
- Reports can arrive out of order, for example
message.deliveredbeforemessage.acceptedafter a retry. Useoccurred_atfor the order of events. - Handle each
event_idonce. A retry of a report you already handled should get200and nothing more. - Turning reports off stops them at once, including retries still waiting.
- Missed a report? Look the message up with SMS status.