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.
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.received | Someone wrote to your number. |
|---|---|
message.status | server_ack, sent, delivered, read or failed. |
instance.status | A number connected, dropped or was logged out. |
instance.qr | A new QR code during linking. |
message.reaction | Someone reacted to a message. |
call.received | Someone called the number. |
What the statuses mean
queued | Accepted by Njiwa, waiting for the number. Durable: it survives a restart and is retried. |
|---|---|
sending | An attempt is in progress right now. Transient, and you will rarely catch it. |
sent | Handed to WhatsApp. |
server_ack | WhatsApp's servers acknowledged it. Also sets
accepted_at on the message. This is not a delivery. |
delivered | It reached the recipient's device. |
read | The recipient opened it. |
failed | It 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.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.