Receiving

Njiwa POSTs events to your URL. Set it in the console under Numbers, on the number itself, or with PUT /v1/instances/{id}/webhook - which needs a key holding webhooks:write. The Webhooks screen beside it is the log of what was delivered, not where the URL is set.

Taking one off

DELETE /v1/instances/{id}/webhook, or Remove on the number in the console. Njiwa forgets the URL and drops anything already queued for it rather than delivering it later - retries run for up to six hours, and a queued delivery carries the address it was created with, so switching the webhook off on its own would not stop them.

The signing secret stays as it is, so putting the same URL back later keeps working without a change at your end. To stop delivery without forgetting the URL, send {"enabled": false} to the PUT instead.

Headers

Content-Type: application/json
X-Njiwa-Signature: t=1756633200,v1=<hex hmac-sha256 of "{t}.{raw_body}">
X-Njiwa-Event: message.received
X-Njiwa-Delivery: <uuid>

Verify the signature

Do this before you parse the body, and against the raw bytes. Signing the timestamp along with the body is what makes a captured request useless later.

Where the secret is. Each number has its own. It is returned once, by the call that created the number, and after that it can be read back in the console under Numbers, on the number, beside the webhook: Reveal secret, which asks for your password. An API key cannot read it - a credential does not get to hand out another one.
import hmac, hashlib, time

def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > tolerance:
        return False
    signed = parts["t"].encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Events

message.receivedSomeone wrote to your number.
message.statusserver_ack, sent, delivered, read or failed.
instance.statusA number connected, dropped or was logged out.
instance.qrA new QR code during linking.
message.reactionSomeone reacted to a message.
call.receivedSomeone called the number.

What the statuses mean

queuedAccepted by Njiwa, waiting for the number. Durable: it survives a restart and is retried.
sendingAn attempt is in progress right now. Transient, and you will rarely catch it.
sentHanded to WhatsApp.
server_ackWhatsApp's servers acknowledged it. Also sets accepted_at on the message. This is not a delivery.
deliveredIt reached the recipient's device.
readThe recipient opened it.
failedIt did not go, after every retry. error says why.
delivered means arrived, not readable. The receipt confirms the encrypted message reached the device. In rare cases a device takes delivery of a message it cannot decrypt, and shows "Waiting for this message" rather than the text. It is uncommon, and it is worth knowing that a delivery receipt is the device's word that it received something, not that a person read it. read is the status that means a person opened it.
A message with accepted_at set that stays at sent is the one pattern worth alerting on. WhatsApp took every message and delivered none of them, which on the Web rail almost always means the number has been restricted, or the recipient has blocked it. Njiwa reports this rather than showing it as an ordinary send, because the two look identical from the API and only one of them is fine.

An inbound message

{
  "id": "...", "type": "message.received",
  "created_at": "2026-08-31T09:14:22Z",
  "data": {
    "id": "msg_01JC8X4K2NQ7RB",
    "instance_id": "abcdefghjkmnpqrs",
    "type": "image",
    "from": "254712345678",
    "to": "254700111222",
    "text": "Here is the receipt",
    "media": {
      "id": "med_...", "mime": "image/jpeg", "size": 184320,
      "url": "https://njiwa.upeo.ai/media/med_...?exp=...&sig=..."
    }
  }
}
from can be null. WhatsApp has begun addressing people by an internal account id rather than by their phone number. Njiwa resolves that back to the number wherever WhatsApp gives it, which is almost always. Where it cannot, from is null rather than a fifteen digit id dressed up as a phone number, because a wrong number is one your code will try to reply to. chat still identifies the conversation, so you can reply on the thread either way. Handle null.

Retries

Any non-2xx is retried at 0s, 30s, 2m, 10m, 1h and 6h. After six failed attempts the delivery is marked failed. After 50 consecutive failures on one number the webhook is switched off and the console raises an alert.

Every attempt is stored with its payload, response code and response body, and can be resent from the console. Return a 2xx quickly and do your work afterwards.