Splashify SMS

Docs

Open panel
Panel

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.

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

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

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

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

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

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

text
X-Splashify-Event:     message.delivered
X-Splashify-Event-Id:  b3f1c2d4-0a5b-4c6d-8e9f-1a2b3c4d5e6f
X-Splashify-Signature: t=1758700000,v1=9f86d081884c7d659a2feaa0c55ad015a…
Content-Type:          application/json

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

python
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)
javascript
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.