Skip to content
OmniChannels.ai

Webhooks and delivery statuses

Receive delivery statuses and inbound messages on your HTTPS endpoint, respond fast, deduplicate events, and poll or re-trigger statuses when needed.

Base URL https://api.broadnet.meUpdated

The platform pushes delivery statuses, and inbound messages, to the webhook URL configured on your account, so you can track every message without polling. This page covers the status lifecycle, a production-ready handler, and what to do when events go missing.

Set up your endpoint#

  1. Expose an HTTPS endpoint that accepts POST requests with a JSON body, for example https://api.example.com/webhooks/omnichannels.
  2. Send the URL to our team (or your account admin) to configure it on your account. Resellers can configure a separate webhook per sub-account.
  3. Send a test message to your own number and confirm that the events arrive.

During development, a tunnel such as ngrok gives your local server a public HTTPS URL.

Delivery statuses#

Every channel reports the same normalised statuses:

Status Meaning Final?
SUBMITTED Accepted by the platform and queued for the network or channel. No
SENT Handed to the operator or channel. No
DELIVERED Confirmed on the recipient's device. Usually; READ may follow
READ Opened by the recipient, on channels that report reads (such as WhatsApp). Yes
FAILED Not delivered. The event carries a reason where the channel provides one. Yes
REJECTED Refused by the operator or channel, for example an unregistered sender ID or blocked content. Yes

Treat statuses as a progression that can arrive out of order. A late SENT should never overwrite a DELIVERED you have already stored. Also expect new values over time, and do not fail on a status you do not recognise.

Payload#

Payload fields vary by channel, and the API spec does not define a single webhook schema. Log the raw body first, then map the fields you need. The example below shows the kind of information a delivery event carries. It is illustrative only; do not code against these field names without checking a real event from your account.

Illustrative delivery event (field names vary by channel)

{
  "messageId": "2603121407537573731",
  "channel": "SMS",
  "destination": "447700900123",
  "status": "DELIVERED",
  "timestamp": "2026-10-10T09:15:42Z",
  "reason": null
}

Ask your onboarding engineer for sample payloads for each channel you use, and keep them as fixtures in your tests.

Write the handler#

Do three things in order: store the raw event, acknowledge with a 2xx, then process it asynchronously.

Node.js (Express)

import express from "express";

const app = express();

// Capture the raw body so you can store it exactly as received.
app.post(
  "/webhooks/omnichannels",
  express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); } }),
  async (req, res) => {
    try {
      await db.webhookEvents.insert({ receivedAt: new Date(), body: req.rawBody });
    } catch (err) {
      return res.sendStatus(500); // storage failed: let the event be retried
    }
    res.sendStatus(200);          // acknowledge fast
    queue.publish("status-events", req.body); // do the real work elsewhere
  },
);

// Worker: idempotent processing
async function processEvent(event) {
  const id = event.messageId ?? event.message_id ?? event.id; // map per channel
  const status = event.status;
  const rank = { SUBMITTED: 1, SENT: 2, DELIVERED: 3, READ: 4, FAILED: 4, REJECTED: 4 };

  const current = await db.messages.findOne({ providerId: id });
  if (!current) return; // unknown message: log it and investigate
  if ((rank[status] ?? 0) <= (rank[current.status] ?? 0)) return; // duplicate or stale

  await db.messages.update({ providerId: id }, { status, updatedAt: new Date() });
}

Python (Flask)

from datetime import datetime, timezone
from flask import Flask, request

app = Flask(__name__)

RANK = {"SUBMITTED": 1, "SENT": 2, "DELIVERED": 3, "READ": 4, "FAILED": 4, "REJECTED": 4}


@app.post("/webhooks/omnichannels")
def omnichannels_webhook():
    raw = request.get_data(as_text=True)
    try:
        db.webhook_events.insert({"received_at": datetime.now(timezone.utc), "body": raw})
    except Exception:
        return "", 500           # storage failed: let the event be retried
    queue.publish("status-events", raw)  # process asynchronously
    return "", 200               # acknowledge fast


def process_event(event: dict) -> None:
    msg_id = event.get("messageId") or event.get("message_id") or event.get("id")  # map per channel
    status = event.get("status")
    current = db.messages.find_one({"provider_id": msg_id})
    if current is None:
        return  # unknown message: log it and investigate
    if RANK.get(status, 0) <= RANK.get(current["status"], 0):
        return  # duplicate or stale event
    db.messages.update({"provider_id": msg_id}, {"status": status})

Rules for a reliable handler#

  • Respond within a few seconds. Do not call other services, send messages or run heavy queries before you respond.
  • Return 2xx only after the event is stored. If storage fails, return 5xx.
  • Deduplicate by message ID. The same status can arrive more than once. Make processing idempotent: applying an event twice must have the same effect as applying it once.
  • Never resend a message from the webhook handler. Decide on retries in a separate job, after checking the status.
  • Keep the raw body for troubleshooting and for replaying events after a bug fix.

Secure the endpoint#

The API spec does not define a webhook signature header, so do not wait for one. Instead:

  • Use HTTPS only, with a valid certificate.
  • Use a long, unguessable path, and rotate it if it leaks.
  • Validate the body shape and reject anything unexpected.
  • Treat events as notifications. Before an irreversible action (a refund, a fraud lock), confirm the state with a polling call.

When events are missing#

If your endpoint was down, or a status seems stuck, you have three options.

Need Call Notes
Re-push SMS delivery receipts POST /sms/resendDlr Filter by startDate, endDate, senderId and messageStatus.
Check SMS bulk job progress GET /sms/list Processed and pending counts per job.
Read WhatsApp status per recipient GET /whatsapp/message/bulk-insights Filter a batch by status or destination; paginated with page and size.

Re-push DLRs

curl -X POST https://api.broadnet.me/sms/resendDlr \
  -H "Authorization: Bearer $BROADNET_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "startDate": "2026-10-09",
    "endDate": "2026-10-10",
    "senderId": "OmniDemo",
    "messageStatus": "DELIVERED"
  }'

For WhatsApp, read the status of each recipient in a batch:

WhatsApp per-recipient status

curl "https://api.broadnet.me/whatsapp/message/bulk-insights?batch_Id=2609091405000000001&page=0&size=100" \
  -H "Authorization: Bearer $BROADNET_TOKEN"

The other channels have batch list endpoints too (for example POST /rcs/message/batches and POST /push/message/batches); see each channel's page.

Troubleshooting checklist#

  1. Is the URL configured on the right account or sub-account?
  2. Is the endpoint reachable from the internet over HTTPS, with a valid certificate?
  3. Does it answer 2xx quickly, or does it time out?
  4. Is a firewall or WAF rule blocking the requests?
  5. Are you logging the raw body before parsing, so a parsing error does not hide the event?

Next steps#

Ready to send your first message?

Request test credentials. An engineer sets up your account, and test sends go to your own verified numbers.

Request test access