Delivery webhooks
Instead of polling for a message's fate, point us at a URL and we will post every status change to it as it happens.
Set it up under Webhooks in the panel: add an https URL, choose which events you want, and copy the signing secret. The secret is shown once.
What arrives
Every request is a POST with a JSON body in this shape.
{
"event": "message.delivered",
"event_id": "b3f1c2d4-0a5b-4c6d-8e9f-1a2b3c4d5e6f",
"sent_at": "2026-09-24T10:15:04Z",
"attempt": 1,
"data": {
"message_id": "0f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"status": "delivered",
"detail": "Message delivered to handset",
"occurred_at": "2026-09-24T10:15:02Z"
}
}The events
message.accepted
The operator took the message. It has not arrived yet.
{
"event": "message.accepted",
"event_id": "…",
"sent_at": "2026-09-24T10:15:00Z",
"attempt": 1,
"data": {
"message_id": "0f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"to": "919876543210",
"status": "sent",
"parts": 1,
"charged": 0.18,
"category": "transactional",
"sender": "SPLASH",
"detail": "",
"occurred_at": "2026-09-24T10:15:00Z"
}
}charged is zero when your account is billed on delivery rather than on send.
message.rejected
The operator refused it at submission. You were not charged. detail is
the customer-facing reason.
{
"event": "message.rejected",
"data": {
"message_id": "…",
"to": "919876543210",
"status": "rejected",
"parts": 0,
"charged": 0,
"category": "promotional",
"sender": "SPLASH",
"detail": "That mobile number does not look valid. Check it and try again.",
"occurred_at": "2026-09-24T10:15:00Z"
}
}message.delivered
It reached the handset. This is final.
{
"event": "message.delivered",
"data": {
"message_id": "…",
"status": "delivered",
"detail": "DELIVERED_TO_HANDSET",
"occurred_at": "2026-09-24T10:15:02Z"
}
}message.failed
It did not reach the handset, and will not. This is final.
{
"event": "message.failed",
"data": {
"message_id": "…",
"status": "failed",
"detail": "EC_ABSENT_SUBSCRIBER Absent subscriber",
"occurred_at": "2026-09-24T10:18:31Z"
}
}A message we never hear about is settled automatically after 48 hours and arrives as this event too.
message.refunded
Money went back to your wallet. Sent after the refund, so a balance you read on receiving it already includes it.
{
"event": "message.refunded",
"data": {
"message_id": "…",
"status": "failed",
"refunded": 0.18,
"reason": "EC_ABSENT_SUBSCRIBER Absent subscriber",
"occurred_at": "2026-09-24T10:18:31Z"
}
}A failure and its refund are two events
They are different facts. One says the message did not arrive, the other says you have your money back. A refund only happens if your account is billed on delivery; see Billing.
Verifying the signature
Every request carries these headers.
X-Splashify-Event: message.delivered
X-Splashify-Event-Id: b3f1c2d4-0a5b-4c6d-8e9f-1a2b3c4d5e6f
X-Splashify-Signature: t=1758700000,v1=9f86d081884c7d659a2feaa0c55ad015a…
Content-Type: application/jsonv1 is HMAC-SHA256(secret, "<t>.<raw body>"), lowercase hex.
Use the raw body
Compute the signature over the bytes exactly as received, before any JSON parsing. Re-serialising the body changes whitespace and key order, and the signature will never match.
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sig = parts.get("t", ""), parts.get("v1", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
return False # too old, or from the future
expected = hmac.new(
secret.encode(),
ts.encode() + b"." + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, sig)const crypto = require("crypto");
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const ts = Number(parts.t);
if (!ts || Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.`)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Always compare with a constant-time function. A plain == leaks how much of
the signature was right.
Reject anything whose timestamp is more than a few minutes old. The timestamp is inside the signed string precisely so that a captured request cannot be replayed at you later.
Answering
Answer 2xx as soon as you have stored the event. Anything else, or no answer
within ten seconds, counts as a failure.
Do the work afterwards, not before you answer. A handler that writes to a slow database before replying will time out and be sent the same event again.
Retries
A failed delivery is retried five times, after 1, 5, 15, 60 and 360 minutes. After the sixth attempt that event is dropped, and the last error is shown on the Webhooks page in the panel.
Retries carry the same event_id and the same body, with attempt raised. Key
your handler on event_id so a retry of an event you already processed is a
no-op.
A 4xx is retried as well as a 5xx. A handler that answered 404 for ten
minutes during a deploy should still receive its events.
What we will not post to
http URLs are refused: a delivery report names your recipients, and we will not put that on the wire unencrypted.
Addresses that are not reachable from the internet are refused too, including
localhost, 127.x, 10.x, 192.168.x, 172.16-31.x, 169.254.x, and
hostnames ending .local or .internal.
Testing
Send a test on the Webhooks page queues a message.delivered event with
obviously fake values and "test": true in data. It goes through the same
path as a real event, retries included, so a handler that passes the test
passes for real events too.
If you would rather poll
Nothing forces you to use webhooks. GET /messages/{id} still returns the
current status; see Message status. Poll only
the messages you act on, back off, and stop at delivered, failed or
rejected, which never change afterwards.