Sonar API and webhooks

Connect your own software to WhatsApp through Sonar: send messages and templates, add and update contacts with their consent, and get every incoming message, reply from the WhatsApp Business app and delivery update at your own address. Sonar sends through Meta's official WhatsApp Cloud API.

Before you start

  • A connected number. Sign up, then connect a WhatsApp number under WhatsApp numbers. A number that's already in the WhatsApp Business app can stay there: see Sonar with the WhatsApp Business app.
  • An API key. Each number has its own, under WhatsApp numbers → Advanced (for developers). Send it on every request as Authorization: Bearer YOUR_API_KEY. It sends from that number, so keep it on your server, never in a browser or app.
  • Base address: https://sonar.whats-hub.co.za. Requests and responses are JSON.
  • On every plan. The API and webhooks come with every Sonar plan, the free trial too, at no extra cost.
  • Full reference: every call, field, response and error is in the API reference, and the OpenAPI spec is at /openapi.json to import into Postman or generate a client.
  • Phone numbers are in international format, with or without the +: 27821234567 or +27 82 123 4567.

Send a message

Free text, for replying to someone who has messaged the number in the last 24 hours. Outside that window WhatsApp only accepts an approved template.

POST https://sonar.whats-hub.co.za/send
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json

{"recipient": "27821234567", "message": "Hi Sam, your room is ready from 14:00."}
200 OK
{"message_id": "wamid.HBgL...", "status": "sent"}
curl -X POST https://sonar.whats-hub.co.za/send \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"recipient":"27821234567","message":"Hello!"}'

Keep the message_id: it's how you look up delivery, and how webhook updates refer to the message.

recipient is a number in international format, or the WhatsApp user ID of a customer who hides their number (see Customers who use a WhatsApp username).

Links in a message can be tapped. To show a preview of the first link, add "preview_url": true to the request. Leave it out for one-time links, like sign-in links. WhatsApp opens the page to build the preview.

{"recipient": "27821234567", "message": "Your booking: https://example.com/b/123", "preview_url": true}

Send a template

Approved templates can be sent at any time. Create them in Sonar under Templates; WhatsApp usually approves them within minutes.

  • body_parameters fill the template's {{1}}, {{2}} and so on, in order. If the template uses named fill-ins like {{first_name}}, give the values in the order those fill-ins first appear in the template.
  • buttons are for buttons that need a value when you send: index (its position among the template's buttons, from 0), kind (url for a link ending in a variable, copy_code for a coupon, quick_reply) and payload (the value).
  • header_document for a PDF header: filename, plus base64 or an https link, up to 16 MB. header_image for an image header: link or base64, JPEG or PNG up to 5 MB.
  • reference (optional) is your own id for the message, up to 100 characters. Send the same reference again from the same number within 24 hours, for example after a timeout, and Sonar doesn't send a second message: you get the first one's answer with "duplicate": true. While the first is still being sent you get 409, and if WhatsApp didn't answer the first in time (so it may have gone) a retry with that reference gets 409 too. The reference comes back in message.status and /message-status.
  • Opt-outs. A marketing template (or one whose category Sonar doesn't know) isn't sent to someone who opted out of your business, or of this number only. You get 403 with the reason, for example {"detail": "Sam opted out of marketing from this number."}. Utility and authentication templates, like order and booking updates, still send.
POST https://sonar.whats-hub.co.za/send-template
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json

{"recipient": "27821234567", "template_name": "booking_confirmed", "language_code": "en_US",
 "body_parameters": ["Sam", "Kruger Safari Lodge", "12 May", "15 May"],
 "buttons": [{"index": 0, "kind": "url", "payload": "P-881"}], "reference": "booking-881"}
200 OK
{"message_id": "wamid.HBgL...", "status": "sent", "reference": "booking-881"}

Check delivery

WhatsApp reports delivery a moment after it accepts a message, so a sent message can still fail. Send up to 100 message_ids from this number to get each one's latest status: sent, delivered, read or failed. The message.status webhook tells you the same as it happens.

POST https://sonar.whats-hub.co.za/message-status
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json

{"wamids": ["wamid.HBgL..."]}

Or look them up by the references you gave /send-template: {"references": ["booking-881"]}. Times are UTC.

200 OK
{"statuses": {"wamid.HBgL...": {"status": "read", "error_code": null, "error_message": null,
                                 "updated_at": "2026-10-25T12:01:09", "reference": "booking-881"}}}

Create a template

Templates can also be made by API, on the WhatsApp account of the key's number. WhatsApp reviews each one, usually within minutes and at most 24 hours; /list-templates and the template.updated webhook tell you the outcome.

  • category: UTILITY, MARKETING or AUTHENTICATION. WhatsApp can approve a utility template as marketing if it reads like one, so check category in the answer.
  • body_text uses {{1}}, {{2}} and so on, with a sample for each in body_example. WhatsApp rejects text that starts or ends with a fill-in.
  • buttons: {"type": "URL", "text": ..., "url": ...} for a link. A link ending in {{1}} takes its end from the send's buttons, with a sample in example. Also QUICK_REPLY and PHONE_NUMBER.
POST https://sonar.whats-hub.co.za/create-template
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json

{"name": "booking_confirmed", "category": "UTILITY", "language_code": "en_US",
 "body_text": "Hi {{1}}, your stay at {{2}} from {{3}} to {{4}} is confirmed.",
 "body_example": ["Sam", "Kruger Safari Lodge", "12 May", "15 May"],
 "buttons": [{"type": "URL", "text": "View booking", "url": "https://example.com/b/{{1}}", "example": "P-881"}]}
200 OK
{"id": "1234567890123456", "status": "PENDING", "category": "UTILITY", "name": "booking_confirmed"}

List your templates

The templates on this number's WhatsApp account, with their approval status, straight from Meta.

GET https://sonar.whats-hub.co.za/list-templates
Authorization: Bearer YOUR_API_KEY

200 OK
{"templates": [{"name": "booking_confirmed", "status": "APPROVED", "category": "UTILITY", "language": "en_US"}]}

Add or update a contact

Matched on the phone number: a new number is added, an existing contact is updated. Use it to keep Sonar in step with your own system, for example tagging a guest "Hold placed" so a flow sends a reminder.

  • tags are added to the ones the contact has, and remove_tags are taken off. Either can be a list or one comma-separated string.
  • custom sets your own contact fields by their key, like arrival_date. Add fields under Contacts → Custom fields.
  • opt_in: true records marketing consent, with opt_in_source saying where it came from. opted_out: true unsubscribes them. Sonar keeps the consent record POPIA asks for.
  • For a customer whose number is hidden, send their whatsapp_user_id as phone. It updates the contact Sonar has for them. It never adds a new one, so an ID Sonar doesn't know gets a 400.
POST https://sonar.whats-hub.co.za/contacts/upsert
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json

{"phone": "+27 82 123 4567", "name": "Sam Scop", "tags": ["Hold placed"],
 "remove_tags": ["Enquiry"], "custom": {"arrival_date": "2026-11-14"},
 "opt_in": true, "opt_in_source": "booking form"}
200 OK
{"id": 123, "wa_id": "27821234567", "name": "Sam Scop", "created": true, "opted_out": false,
 "tags": ["Hold placed"], "custom": {"arrival_date": "2026-11-14"}, "opt_in_at": "2026-10-25T14:00:00Z"}

Webhooks

Sonar can post what happens to your own address as it happens. Set it up under Settings → Connected apps → Your own system (webhook): give the https address, choose the events, and copy the signing secret, which is shown once. One webhook covers all of a business's numbers.

  • Format: a JSON POST with id (unique per update), type (the event) and sent_at, plus the event's own fields. The headers include X-Sonar-Event (the event) and X-Sonar-Delivery (the same id).
  • Signature: X-Sonar-Signature: sha256=... is the HMAC-SHA256 of the raw request body with your secret. Check it before trusting an update.
  • Delivery: answer with any 2xx within 10 seconds. If your address doesn't, Sonar tries twice more over about half a minute. Use id to ignore an update you've already handled.
  • Addresses inside a private network aren't allowed.
# Python: check the signature on the raw body, before parsing it
import hashlib, hmac

def is_from_sonar(secret: str, raw_body: bytes, signature: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")

Webhook events

The events Sonar can send to your webhook
EventWhen
message.receivedSomeone messages one of your numbers.
message.sentSomeone on your team replies from the WhatsApp Business app on their phone (Meta's message echoes, on numbers connected with the app).
message.statusA message sent through Sonar or the API is delivered, read or fails.
template.updatedWhatsApp approves, rejects, pauses or switches off a template, gives notice that it will move one to another category (about 24 hours ahead), or moves it.
contact.opted_in, contact.opted_outSomeone opts in or out, by keyword, button or the API.
rsvp.updatedAn event RSVP changes (going, not going, waitlist).
number.connectedA property's owner connects their number through a partner connect link (see Building a platform). Always sent to partners.
{"id": "4f1c...", "type": "message.received", "sent_at": "2026-10-25T12:00:02Z",
 "message": {"id": "wamid.HBgL...", "type": "text", "text": "Do you have a room on Friday?",
             "received_at": "2026-10-25T12:00:01Z", "phone_number_id": "1234567890",
             "ad_headline": null, "media": null},
 "contact": {"whatsapp": "+27821234567", "whatsapp_user_id": "ZA.1349...", "name": "Sam", "tags": ["Guest"], "opted_out": false}}
{"id": "9b2e...", "type": "message.sent", "sent_at": "2026-10-25T12:03:40Z",
 "message": {"id": "wamid.HBgL...", "type": "text", "text": "Yes, the Main Suite is free.",
             "to": "+27821234567", "to_user_id": "ZA.1349...", "sent_at": "2026-10-25T12:03:39Z",
             "phone_number_id": "1234567890", "sent_from": "whatsapp_business_app", "media": null},
 "contact": {"whatsapp": "+27821234567", "whatsapp_user_id": "ZA.1349...", "name": "Sam", "tags": ["Guest"], "opted_out": false}}
{"id": "c07a...", "type": "message.status", "sent_at": "2026-10-25T12:05:11Z",
 "message": {"id": "wamid.HBgL...", "status": "read", "previous_status": "delivered",
             "to": "+27821234567", "to_user_id": "ZA.1349...", "template": "booking_confirmed", "broadcast_id": null,
             "reference": "booking-881", "phone_number_id": "1234567890", "error": null, "at": "2026-10-25T12:05:10Z"}}
{"id": "7d3a...", "type": "template.updated", "sent_at": "2026-10-25T12:07:00Z",
 "template": {"name": "booking_confirmed", "language": "en_US", "status": "APPROVED", "category": "UTILITY",
              "reason": null, "waba_id": "1029...", "change": "category_notice",
              "coming_category": "MARKETING", "coming_at": "2026-10-26T12:00:00Z"}}

change is status (with previous_status), category_notice (with coming_category and coming_at) or category (with previous_category). reason is filled for a rejection.

{"id": "e51d...", "type": "contact.opted_out", "sent_at": "2026-10-25T12:09:00Z",
 "contact": {"whatsapp": "+27821234567", "whatsapp_user_id": "ZA.1349...", "name": "Sam", "tags": ["Guest"], "opted_out": true},
 "consent": {"source": "keyword", "keyword": "STOP", "detail": "STOP"}}

Customers who use a WhatsApp username

Some customers use a WhatsApp username and don't share their number. For them, "whatsapp" is null and their WhatsApp user ID is in "whatsapp_user_id". In message.sent and message.status, "to" is null for them and the ID is in "to_user_id". To message them, send that ID as "recipient" to /send or /send-template, exactly as you got it.

  • A customer whose number Sonar already has usually keeps it: Sonar sends to their number, as WhatsApp recommends. If Sonar keeps their user ID as a separate contact, a send to the ID goes to the ID.
  • Sign-in code (authentication) templates need a number. WhatsApp refuses them to a user ID.

Files

When a message has a document, photo, voice note or video, its update includes message.media with an id, mime, filename and a url. Fetch the file from that address with any of the business's API keys:

GET https://sonar.whats-hub.co.za/media/MEDIA_ID
Authorization: Bearer YOUR_API_KEY

Sonar keeps each file for 12 months, so save the ones you need in your own system. Voice notes come as the original audio file (OGG).

Numbers on the WhatsApp Business app

A number connected with the WhatsApp Business app (what Meta calls coexistence) works with the API like any other, and also keeps working in the app on the owner's phone. South African +27 numbers included.

  • What you send shows in the app. Messages sent through /send or /send-template appear in that chat in the owner's WhatsApp Business app.
  • What they send from the app reaches you. A reply typed in the app arrives as message.sent, with "sent_from": "whatsapp_business_app". Messages sent from the app stay free.
  • Incoming messages go to both. They show in the app and in Sonar, and reach you as message.received.
  • The API keeps WhatsApp's rules. Free text through /send only works within 24 hours of the customer's last message; after that, send an approved template. The owner carries on in the app as they always have.
  • Past chats aren't replayed. When the number connects, Meta can copy up to 6 months of past chats into Sonar's inbox. They aren't sent to your webhook, which starts from the moment it's set up. Group chats stay in the app only.
  • The app has to stay in use. If the WhatsApp Business app isn't opened for about 14 days, Meta disconnects the number and it needs connecting again.

Errors and limits

  • 401: the API key is missing or wrong.
  • 400: something in the request isn't right, like a missing field or a number that isn't in international format. The response's detail says what. WhatsApp's own refusals come back here too, for example a free-text /send to someone outside their 24-hour window.
  • 402: the message is past the plan's included messages and the account's balance can't cover it. Top up or upgrade to carry on.
  • If WhatsApp is pacing your number, wait a little and try again. Broadcasts sent from Sonar are paced for you.
  • WhatsApp's own charge for each message is billed by Meta to the card on the business's WhatsApp account. See WhatsApp pricing in South Africa.

Building a platform

If you run software for many businesses, like a booking or property management system, the partner API lets each of your customers connect their own WhatsApp number from inside your product, with no Sonar login. Each customer becomes its own business in Sonar (a property here), with its own numbers, contacts and opt-outs, and its number stays in the owner's own Meta business portfolio.

  1. Add the property. Sonar returns a connect_url for its owner. It works once, for 30 days.
  2. Send the owner to it. They sign in with Facebook and confirm on their phone in the WhatsApp Business app, which keeps working as before.
  3. They come back to you. Sonar sends them to your return_url with property_id and phone_number_id added, and posts number.connected to your webhook.
  4. Get the number's API key from the property, and use it for everything above.
POST https://sonar.whats-hub.co.za/partner/properties
Authorization: Bearer YOUR_PARTNER_KEY
Content-Type: application/json

{"name": "Riverside Guest House", "return_url": "https://yourapp.example/whatsapp/connected"}
{"id": 42, "name": "Riverside Guest House",
 "connect_url": "https://sonar.whats-hub.co.za/connect/3kQ9...", "connect_url_expires_at": "2026-11-25T09:00:00Z",
 "numbers": []}

Once the owner has connected, GET /partner/properties/42 lists the number with its api_key. GET /partner/properties lists every property, and POST /partner/properties/42/connect-link makes a new link when one has expired or the owner needs to connect again.

{"id": "a81f...", "type": "number.connected", "sent_at": "2026-10-26T09:14:03Z",
 "number": {"phone_number_id": "1234567890", "display_phone": "+27 82 123 4567",
            "name": "Riverside Guest House", "whatsapp_business_app": true},
 "property": {"id": 42, "name": "Riverside Guest House"}}

The webhook in your own Sonar business (see Webhooks) gets every property's updates, each with a property field, so one address and one secret cover them all.

Email support@whats-hub.co.za to become a partner. Once we've switched it on, your account owner makes the partner key under Settings → Connected apps → Partner API. It's shown once and never sent by email. Partners' owners and admins can read the partner API reference once signed in.

Need a hand?

Email support@whats-hub.co.za with what you're building and we'll help you connect it. Uptime and updates are on status.whats-hub.co.za.