Skip to content
Oxtro
Documentation menu

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

FieldTypeDescription
urlstring, required
eventsstring[], requiredOne of: message.sent, message.delivered, message.read, message.failed, message.received, connection.connected, connection.disconnected, otp.sent, otp.verified, otp.failed.
is_activebooleanDefault: true.
curl
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"]
  }'
201 Created
{
  "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
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
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.

POST to your endpoint
{
  "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"
  }
}
HeaderValue
X-Webhook-Signaturet=<unix seconds>,v1=<hex signature>. See below.
X-Webhook-EventThe event name, such as message.received.
X-Webhook-DeliveryThe delivery ID. It stays the same across retries, so use it to ignore duplicates.
Content-Typeapplication/json

Events

EventSent whenFields in data
message.receivedA 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.sentA queued message was handed to WhatsApp.message_id, connection_id, to, type, sent_at, provider_message_id
message.deliveredThe message reached the recipient device.message_id, connection_id, status, timestamp
message.readThe recipient opened the message.message_id, connection_id, status, timestamp
message.failedThe message could not be sent after all retries.message_id, connection_id, to, error, failed_at
connection.connectedA number finished linking and can send.connection_id, phone_number, status, timestamp
connection.disconnectedA number lost its session. Sends fail until it is linked again.connection_id, phone_number, status, timestamp
otp.sentA one-time code was generated and queued.request_id, phone_number, expires_at
otp.verifiedA code was entered correctly.request_id, phone_number, verified_at
otp.failedA 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.

  1. Read t and v1 from the X-Webhook-Signature header.
  2. Reject the request if t is more than 5 minutes from your server clock. This blocks replays.
  3. Compute HMAC-SHA256 of the string <t>.<raw request body> with your signing secret as the key, hex encoded.
  4. Compare it with v1 using 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.

Node.js (Express)
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 2xx status 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.