API reference
Webhooks
Oxtro posts a signed JSON event to your server when a customer replies, a message changes status, a number disconnects or a code is verified.
Register an endpoint
In your workspace, open Developer API, add a webhook endpoint with a public HTTPS URL and choose the events it should receive. Oxtro shows a signing secret that starts with whsec_. It is shown once, so store it with your other secrets. You can rotate it later.
Manage endpoints with the API
Integrations can register their own endpoint with an API key that has the webhooks:manage scope.
POST/v1/webhooksScope webhooks:manage
| Field | Type | Description |
|---|---|---|
url | string, required | |
events | string[], required | One of: message.sent, message.delivered, message.read, message.failed, message.received, connection.connected, connection.disconnected, otp.sent, otp.verified, otp.failed. |
is_active | boolean | Default: true. |
curl https://api.getoxtro.com/v1/webhooks \
-H "Authorization: Bearer $OXTRO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/oxtro",
"events": ["message.received", "message.failed"]
}'{
"id": "whk_123",
"url": "https://example.com/webhooks/oxtro",
"events": ["message.received", "message.failed"],
"is_active": true,
"created_at": "2026-10-10T09:00:00.000Z",
"secret": "whsec_..."
}secret is returned by this call only. Listing and reading endpoints never include it.
GET/v1/webhooksScope webhooks:manage
Returns { "data": [ ... ] } with every endpoint of the workspace.
curl https://api.getoxtro.com/v1/webhooks \
-H "Authorization: Bearer $OXTRO_API_KEY"GET/v1/webhooks/{id}Scope webhooks:manage
DELETE/v1/webhooks/{id}Scope webhooks:manage
Returns { "id": "whk_123", "deleted": true }. Deliveries to the endpoint stop.
curl -X DELETE https://api.getoxtro.com/v1/webhooks/whk_123 \
-H "Authorization: Bearer $OXTRO_API_KEY"The request Oxtro sends
Every event is an HTTP POST with a JSON body in the same envelope: the event name, when it was created, your workspace and a data object that depends on the event.
{
"event": "message.received",
"timestamp": "2026-10-10T09:45:12.000Z",
"workspace_id": "ws_123",
"data": {
"message_id": "msg_456",
"conversation_id": "conv_123",
"from": "213555123456",
"sender_name": "Ahmed",
"type": "TEXT",
"content": "Is my order on its way?",
"received_at": "2026-10-10T09:45:11.000Z"
}
}| Header | Value |
|---|---|
X-Webhook-Signature | t=<unix seconds>,v1=<hex signature>. See below. |
X-Webhook-Event | The event name, such as message.received. |
X-Webhook-Delivery | The delivery ID. It stays the same across retries, so use it to ignore duplicates. |
Content-Type | application/json |
Events
| Event | Sent when | Fields in data |
|---|---|---|
message.received | A contact sends a message to one of your numbers. Group messages are not included. | message_id, conversation_id, from (digits, no plus), sender_name, type, content, media_url when there is a file, received_at |
message.sent | A queued message was handed to WhatsApp. | message_id, connection_id, to, type, sent_at, provider_message_id |
message.delivered | The message reached the recipient device. | message_id, connection_id, status, timestamp |
message.read | The recipient opened the message. | message_id, connection_id, status, timestamp |
message.failed | The message could not be sent after all retries. | message_id, connection_id, to, error, failed_at |
connection.connected | A number finished linking and can send. | connection_id, phone_number, status, timestamp |
connection.disconnected | A number lost its session. Sends fail until it is linked again. | connection_id, phone_number, status, timestamp |
otp.sent | A one-time code was generated and queued. | request_id, phone_number, expires_at |
otp.verified | A code was entered correctly. | request_id, phone_number, verified_at |
otp.failed | A code was wrong, expired or out of attempts, or a phone hit the hourly or daily limit. | phone_number, reason, and request_id and remaining_attempts when they apply |
New fields can be added to data over time. Ignore fields you do not recognise instead of rejecting the event.
Verify the signature
Anyone who learns your URL can post to it, so check every request before you trust it.
- Read
tandv1from theX-Webhook-Signatureheader. - Reject the request if
tis more than 5 minutes from your server clock. This blocks replays. - Compute HMAC-SHA256 of the string
<t>.<raw request body>with your signing secret as the key, hex encoded. - Compare it with
v1using a constant-time comparison.
Use the raw request body, byte for byte. If your framework parses the JSON and you serialise it again, key order or spacing can change and the signature will not match.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// express.raw keeps the body as the exact bytes Oxtro signed.
app.post('/webhooks/oxtro', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-Webhook-Signature');
if (!isValidSignature(req.body, header, process.env.OXTRO_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body);
if (event.event === 'message.received') {
console.log(event.data.from, event.data.content);
}
res.status(200).end();
});
function isValidSignature(rawBody, header = '', secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((pair) => pair.split('=')));
if (!/^\d+$/.test(parts.t ?? '') || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.`)
.update(rawBody)
.digest();
const received = Buffer.from(parts.v1, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
app.listen(3000);The same check in PHP, Laravel and Python. To debug a signature that does not match, paste it into the webhook signature checker.
Responding and retries
- Answer with any
2xxstatus within 10 seconds. Do slow work after you respond. - Any other status, a timeout or a connection error counts as a failure.
- A failed delivery is tried up to 5 times in total, with a growing wait between tries that starts at about 10 seconds.
- Because of retries, the same event can arrive more than once. Make your handler idempotent, keyed on
X-Webhook-Delivery. - Events can arrive out of order. Use the timestamps in the payload, not the arrival order.
The Developer API page lists recent deliveries for each endpoint with the response your server gave, and has a button that sends a test.ping event.