Skip to content
Splashify Pro
Docs

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.

  1. Open the SMS card

    Open Settings, click Configure Channels, and on the SMS card click Delivery reports. The button shows once SMS is Active.

  2. 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 with https:// and be reachable from the internet. Addresses inside a private network are refused.

  3. Pick the reports

    Tick the reports you want. Leave them all ticked if you are not sure.

  4. 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.

  5. 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:

json
{
  "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

json
{
  "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

json
{
  "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:

text
t=1790412130,v1=5f2b8c...e41a
  • t is when we signed it, in Unix seconds. Each try, including a retry, is signed again with a new t.
  • v1 is an HMAC-SHA256, in lowercase hex, of the text t, then a dot, then the raw request body. The key is your whole signing secret, including the whsec_ at the start.

To check it:

  1. Read the raw body exactly as it arrived, before any JSON parsing.
  2. Build the text t + . + raw body, and compute its HMAC-SHA256 with your secret.
  3. Compare it with v1 using a constant-time compare.
  4. Refuse it if t is more than 5 minutes old, so an old report cannot be replayed.

Node.js / TypeScript (Express)

text
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)

python
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 "", 200

Use 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.delivered before message.accepted after a retry. Use occurred_at for the order of events.
  • Handle each event_id once. A retry of a report you already handled should get 200 and nothing more.
  • Turning reports off stops them at once, including retries still waiting.
  • Missed a report? Look the message up with SMS status.