Webhooks
Webhooks tell your system when something happens on your OmaxTelecom account: an SMS is delivered, a number is bought, an eSIM package runs low, an invoice is paid. Instead of polling the APIs, you register an HTTPS URL in the Console and we send a signed POST request to it for every event you subscribe to.
One webhook setup covers every product. You manage it in the Console under Webhooks, not through the product APIs.
What you can do
| Product |
Events |
Examples |
| Billing |
console.invoice.*, console.wallet.* |
Invoice created or paid, wallet topped up, wallet balance low |
| SMS API |
sms.message.*, sms.batch.*, sms.sender_id.*, sms.wallet.*, sms.otp.* |
Message sent, delivered, failed or expired; bulk batch finished; sender ID approved; one-time code verified |
| Phone Numbers API |
phone_numbers.sms.*, phone_numbers.number.*, phone_numbers.order.*, phone_numbers.kyc.*, phone_numbers.port.updated |
SMS received on your number, number bought or renewed, order completed, KYC approved, port status changed |
| Bappy API |
bappy.link.*, bappy.package.*, bappy.usage.*, bappy.network.*, bappy.sms.* |
Link created, package activated or expired, 80% of data used, link attached to a network, SMS to a link delivered |
Every event name has three parts: <product>.<object>.<action>. The full list with payloads is in the Event reference.
Quick start
1. Add an endpoint. In the Console open Webhooks → Add endpoint:
- Destination URL: a public
https:// URL that accepts POST requests. Plain http:// URLs are refused.
- Description (optional): a note for your team.
- Events to send: pick the event types you need. Leave all unselected to receive every event type, including types added later.
- For phone number events you can also limit the endpoint to up to 10 of your numbers.
- Keep Endpoint enabled on and save.
2. Copy the signing secret. It is shown right after the endpoint is created. You can reveal it again on the endpoint page. Store it in your server configuration; you need it to verify every request.
3. Send a test event. Open the endpoint → Send test event → choose an event type → Send test. We send an example payload for that event type to this endpoint only, signed like a real event.
4. Check the result. The endpoint page and the Delivery log show every attempt with the HTTP status your server answered.
[!NOTE]
Webhooks are managed by Console users who can manage API keys. Each organization has its own endpoints and receives only its own events.
Your endpoint
- Answer fast with any
2xx status. Do your processing after you have answered, for example by putting the event on a queue. Any other status, or no answer in time, counts as a failed attempt.
- Failed attempts are retried automatically, with increasing pauses between attempts. You can also send a message again yourself from the Console (Resend).
- Events can arrive more than once and out of order. Use the
svix-id header to recognise a message you have already processed, and compare the timestamps in the payload when the order matters.
- Verify the signature of every request before you trust it.
- Keep the URL secret and use a path that is hard to guess. Never act on a request that fails verification.
Request format
Every webhook is an HTTP POST with a JSON body:
POST /hooks/omax HTTP/1.1
Host: example.com
Content-Type: application/json
svix-id: msg_2pX4Kq7Lm9Rt1Vw3Yz5Ab8Cd0Ef
svix-timestamp: 1793869200
svix-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
| Header |
Description |
svix-id |
Unique message ID. The same message keeps the same ID on every retry and resend, so use it to de-duplicate |
svix-timestamp |
Time the request was signed, in Unix seconds |
svix-signature |
One or more signatures, separated by spaces, each in the form v1,<base64> |
The values above are only an example; your requests carry their own ID, timestamp and signature.
Body
The body is a JSON object with the event type in type and the data in one object named after what the event is about:
{
"type": "phone_numbers.number.purchased",
"phone_number": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"e164": "+442071234567",
"country_iso": "GB",
"number_type": "local",
"status": "active",
"kyc_status": "approved",
"billing_period": "monthly",
"next_billing_date": "2026-12-01",
"order_id": "0198c5f9-52b0-7b1e-8c3d-4e5f6a7b8c9d"
},
"organization_sso_id": "your-organization"
}
type is always present and repeats the event name.
organization_sso_id identifies your organization. It is present on every event except the billing events, which carry the organization inside the object instead (see Billing events).
- Times are UTC in ISO 8601, for example
2026-11-01T10:15:30Z. Dates without a time are YYYY-MM-DD.
- Money amounts are decimal strings, never floating-point numbers, for example
"0.045".
- Fields listed for an event are always present; a field without a value is
null. The only exception is network.previous, which exists only in bappy.network.changed.
- New fields can be added to a payload at any time. Ignore fields you don't know.
Verify the signature
Every request is signed with your endpoint's signing secret. Check the signature before you parse or trust the body.
Take the secret. If it starts with whsec_, remove that prefix. Base64-decode the rest: the bytes are your signing key.
Build the signed content: the svix-id header, a dot, the svix-timestamp header, a dot, and the raw request body exactly as received:
{svix-id}.{svix-timestamp}.{raw body}
Compute HMAC-SHA256 of the signed content with the key, and Base64-encode the result.
svix-signature contains one or more space-separated entries such as v1,K5oZ…. The request is valid when the part after v1, of any entry equals your result. Compare in constant time.
Reject the request when svix-timestamp is more than 5 minutes away from your server time. This stops old requests from being replayed.
[!WARNING]
Use the raw body. Frameworks that parse JSON and serialize it again change spaces or key order, and the signature no longer matches.
With the Node.js SDK, omax.webhooks.verify() does all of these checks and returns the event, typed by its type:
// Node.js SDK (Express)
import express from "express";
import { Omax, OmaxWebhookVerificationError } from "@omaxtelecom/sdk";
const omax = new Omax();
const app = express();
app.post("/hooks/omax", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = omax.webhooks.verify(req.body, req.headers, process.env.OMAX_WEBHOOK_SECRET);
} catch (error) {
if (error instanceof OmaxWebhookVerificationError) return res.sendStatus(401);
throw error;
}
res.sendStatus(200); // answer first, then process
if (event.type === "phone_numbers.sms.received") {
console.log(event.sms.from, event.sms.body);
}
});
While you rotate the secret, pass both: verify(body, headers, [newSecret, oldSecret]). omax.webhooks.sign(payload, secret) signs a payload like we do, to test your handler.
Without the SDK, check the signature yourself:
// Node.js (Express)
const crypto = require("crypto");
const express = require("express");
const app = express();
const SECRET = process.env.OMAX_WEBHOOK_SECRET; // whsec_...
function verify(rawBody, headers) {
const id = headers["svix-id"];
const timestamp = headers["svix-timestamp"];
const signatures = headers["svix-signature"];
if (!id || !timestamp || !signatures) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(SECRET.replace(/^whsec_/, ""), "base64");
const expected = crypto
.createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest("base64");
return signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",", 2);
return (
version === "v1" &&
signature &&
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
});
}
app.post("/hooks/omax", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verify(rawBody, req.headers)) return res.sendStatus(401);
const event = JSON.parse(rawBody);
res.sendStatus(200); // answer first, then process
handleEvent(req.get("svix-id"), event);
});
<?php
// PHP
$secret = getenv('OMAX_WEBHOOK_SECRET'); // whsec_...
$rawBody = file_get_contents('php://input');
$id = $_SERVER['HTTP_SVIX_ID'] ?? '';
$timestamp = $_SERVER['HTTP_SVIX_TIMESTAMP'] ?? '';
$signatures = $_SERVER['HTTP_SVIX_SIGNATURE'] ?? '';
function omax_verify(string $secret, string $id, string $timestamp, string $signatures, string $rawBody): bool
{
if ($id === '' || $timestamp === '' || $signatures === '' || abs(time() - (int) $timestamp) > 300) {
return false;
}
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true));
foreach (explode(' ', $signatures) as $entry) {
[$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
if (! omax_verify($secret, $id, $timestamp, $signatures, $rawBody)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
http_response_code(200);
// process $event['type'] ...
# Python (Flask)
import base64, hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["OMAX_WEBHOOK_SECRET"] # whsec_...
def verify(raw_body: bytes, headers) -> bool:
msg_id = headers.get("svix-id", "")
timestamp = headers.get("svix-timestamp", "")
signatures = headers.get("svix-signature", "")
if not msg_id or not timestamp or not signatures:
return False
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(SECRET.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return False
@app.post("/hooks/omax")
def omax_webhook():
raw_body = request.get_data()
if not verify(raw_body, request.headers):
abort(401)
event = request.get_json()
# process event["type"] ...
return "", 200
Signing secret rotation
Rotate the secret on the endpoint page with Rotate signing secret when it may have leaked, or on a regular schedule.
- After rotating, the previous secret stays valid for 24 hours. During that time requests are signed so that both secrets verify, which is why
svix-signature can contain more than one entry.
- Update the secret in your configuration within those 24 hours. To switch without any failed check, let your code accept either the old or the new secret for a short time.
Delivery log, testing and resend
In the Console under Webhooks:
- Endpoint page: success rate, response times, recent deliveries, Edit, Disable / Enable, Rotate signing secret and delete.
- Delivery log: every message with its event type, payload and each attempt with the HTTP status your server answered. View attempts shows the details; Resend sends the message to the endpoint again with the same
svix-id.
- Send test event: an example payload of any event type, sent to one endpoint only. Test events are signed like real events.
Disabling an endpoint stops deliveries to it until you enable it again. Deleting an endpoint stops deliveries and removes it.
Event reference
Each event below lists when it is sent, its fields and an example body. Values in the examples are illustrations.
Schemas for your code
Every event is also published as a JSON Schema in an OpenAPI 3.1 document, under webhooks: /openapi/webhooks.json. The same events are listed with their schemas at the end of this page.
- Every field listed for an event is always present (
required); a field without a value is null.
- Objects do not forbid extra properties, because new fields can be added later.
- Use the document with an OpenAPI or JSON Schema code generator to create typed models for your language, or to validate payloads in your tests.
Billing events
The billing events are about your Console wallet and invoices. They carry the organization inside the object, not as organization_sso_id at the top level.
console.invoice.created
An invoice was created for your organization. Most invoices start as pending; an invoice created for a card top-up is already paid when it is created.
console.invoice.paid
An invoice was paid.
Both invoice events send the invoice object:
| Field |
Type |
Description |
invoice.id |
string |
Invoice ID |
invoice.invoice_number |
string |
Invoice number as shown on the PDF |
invoice.status |
string |
pending or paid |
invoice.currency |
string |
ISO currency code, for example EUR |
invoice.total |
string |
Total amount, decimal string |
invoice.due_date |
string |
Due date, as an ISO 8601 timestamp at midnight UTC |
invoice.paid_at |
string | null |
When it was paid, ISO 8601; null while unpaid |
invoice.organization_id |
string |
Your organization ID |
invoice.organization_sso_id |
string |
Your organization identifier |
{
"type": "console.invoice.paid",
"invoice": {
"id": "019a2f6e-3c41-7d2b-9e85-1f0a6b4c2d71",
"invoice_number": "INV-2026-00042",
"status": "paid",
"currency": "EUR",
"total": "149.9900",
"due_date": "2026-11-15T00:00:00.000000Z",
"paid_at": "2026-11-03T10:15:30+00:00",
"organization_id": "019a2f6e-3c41-7d2b-9e85-1f0a6b4c2d72",
"organization_sso_id": "your-organization"
}
}
console.wallet.top_up
Money was added to your Console wallet by a top-up. Automatic top-ups (Auto top-up) do not send this event.
| Field |
Type |
Description |
wallet.currency |
string |
Wallet currency |
wallet.balance |
string |
Wallet balance after the top-up |
transaction.id |
string |
Wallet transaction ID |
transaction.amount |
string |
Amount added |
transaction.currency |
string |
Currency of the amount |
transaction.category |
string |
top_up |
transaction.description |
string | null |
Description of the transaction |
transaction.balance_after |
string |
Balance right after this transaction |
transaction.created_at |
string |
When the transaction was recorded, ISO 8601 |
organization.id |
string |
Your organization ID |
organization.sso_id |
string |
Your organization identifier |
organization.name |
string |
Your organization name |
{
"type": "console.wallet.top_up",
"wallet": { "currency": "EUR", "balance": "250.0000" },
"transaction": {
"id": "019a2f6e-3c41-7d2b-9e85-1f0a6b4c2d73",
"amount": "100.0000",
"currency": "EUR",
"category": "top_up",
"description": "Wallet top-up",
"balance_after": "250.0000",
"created_at": "2026-11-03T10:15:30+00:00"
},
"organization": {
"id": "019a2f6e-3c41-7d2b-9e85-1f0a6b4c2d72",
"sso_id": "your-organization",
"name": "Example Ltd"
}
}
console.wallet.low_balance
Your Console wallet balance fell to or below the threshold of your Balance alert (Console → Billing → Balance alerts). It is sent only while the alert is turned on, when a change of balance leaves the wallet at or below the threshold, and at most once every 24 hours while the balance stays low.
| Field |
Type |
Description |
wallet.currency |
string |
Wallet currency |
wallet.balance |
string |
Current balance |
wallet.threshold |
string |
Your alert threshold |
organization.id |
string |
Your organization ID |
organization.sso_id |
string |
Your organization identifier |
organization.name |
string |
Your organization name |
{
"type": "console.wallet.low_balance",
"wallet": { "currency": "EUR", "balance": "12.5000", "threshold": "25.0000" },
"organization": {
"id": "019a2f6e-3c41-7d2b-9e85-1f0a6b4c2d72",
"sso_id": "your-organization",
"name": "Example Ltd"
}
}
SMS events
Events of the SMS API and OTP API.
Message status: sms.message.sent, sms.message.delivered, sms.message.failed, sms.message.expired
| Event |
Sent when |
sms.message.sent |
The message was handed to the operator |
sms.message.delivered |
The operator confirmed delivery to the handset |
sms.message.failed |
The message could not be delivered |
sms.message.expired |
No delivery report arrived in time |
Status events are sent for messages you send through the SMS API, each status once per message. Messages you submit directly over SMPP do not send webhooks; their delivery receipts arrive over your SMPP session. In test mode the events are sent too, with is_test: true.
| Field |
Type |
Description |
sms.id |
string |
The message id you got when you sent it |
sms.server_message_id |
string |
Our internal message ID; quote it to support |
sms.status |
string |
sent, delivered, failed or expired, matching the event |
sms.from |
string | null |
Sender ID |
sms.to |
string |
Recipient number, international format without + (digits only) |
sms.segments |
integer |
Number of billed segments |
sms.price.amount |
string |
Price of the message |
sms.price.currency |
string |
Currency of the price |
sms.reference |
string | null |
Your own reference, if you sent one |
sms.error_code |
string | null |
Error code when the message failed |
sms.is_test |
boolean |
true for messages sent in test mode |
sms.is_otp |
boolean |
true for one-time code messages |
sms.submitted_at |
string | null |
When the message was accepted |
sms.sent_at |
string | null |
When it was handed to the operator |
sms.delivered_at |
string | null |
When delivery was confirmed |
organization_sso_id |
string |
Your organization identifier |
{
"type": "sms.message.delivered",
"sms": {
"id": "0198c5f9-52b0-7b1e-8c3d-4e5f6a7b8c9d",
"server_message_id": "0198c5f9-61d2-7a44-b1e0-9c8d7e6f5a4b",
"status": "delivered",
"from": "ACME",
"to": "38761123456",
"segments": 1,
"price": { "amount": "0.045", "currency": "EUR" },
"reference": "order-1042",
"error_code": null,
"is_test": false,
"is_otp": false,
"submitted_at": "2026-11-03T08:00:00Z",
"sent_at": "2026-11-03T08:00:01Z",
"delivered_at": "2026-11-03T08:00:04Z"
},
"organization_sso_id": "your-organization"
}
[!TIP]
The webhook tells you that a message changed. For its complete record in API terms, read GET /messages/{id} with sms.id.
Bulk batches: sms.batch.completed, sms.batch.failed
| Event |
Sent when |
sms.batch.completed |
A bulk request finished: every message was handed over or rejected. Delivery is still reported per message |
sms.batch.failed |
A bulk request stopped before it finished |
| Field |
Type |
Description |
batch.id |
string |
Batch ID, as returned by POST /messages/bulk |
batch.status |
string |
completed or failed |
batch.from |
string |
Sender ID |
batch.total |
integer |
Number of recipients |
batch.submitted |
integer |
Messages handed over |
batch.rejected |
integer |
Messages rejected |
batch.segments_per_message |
integer |
Segments per message |
batch.reference |
string | null |
Your own reference, if you sent one |
batch.error |
string | null |
Why the batch stopped, for sms.batch.failed |
batch.created_at |
string |
When the batch was created |
batch.completed_at |
string | null |
When it finished |
organization_sso_id |
string |
Your organization identifier |
{
"type": "sms.batch.completed",
"batch": {
"id": "0198c5fa-0a11-7c3e-9d2f-1e2d3c4b5a69",
"status": "completed",
"from": "ACME",
"total": 2500,
"submitted": 2497,
"rejected": 3,
"segments_per_message": 1,
"reference": "promo-november",
"error": null,
"created_at": "2026-11-03T09:00:00Z",
"completed_at": "2026-11-03T09:02:41Z"
},
"organization_sso_id": "your-organization"
}
Sender IDs: sms.sender_id.approved, sms.sender_id.rejected
A sender ID request was approved or rejected. A sender ID that our team adds for you directly also sends sms.sender_id.approved.
| Field |
Type |
Description |
sender_id.id |
string |
Sender ID request ID |
sender_id.name |
string |
The sender ID |
sender_id.status |
string |
approved or rejected |
sender_id.use_case |
string | null |
The use case you gave |
sender_id.note |
string | null |
Note from our review, for example the reason for a rejection |
sender_id.moderated_at |
string |
When it was reviewed |
organization_sso_id |
string |
Your organization identifier |
{
"type": "sms.sender_id.rejected",
"sender_id": {
"id": "0198c5fb-2b33-7e5f-8a1b-2c3d4e5f6a7b",
"name": "ACME",
"status": "rejected",
"use_case": "Order notifications",
"note": "Brand not verified",
"moderated_at": "2026-11-03T11:20:00Z"
},
"organization_sso_id": "your-organization"
}
SMS balance: sms.wallet.topped_up, sms.wallet.low_balance
| Event |
Sent when |
sms.wallet.topped_up |
Money was added to your SMS balance |
sms.wallet.low_balance |
Your SMS balance fell below the low balance threshold. It is sent once; it is sent again only after the balance has gone back above the threshold and dropped below it again |
sms.wallet.topped_up:
| Field |
Type |
Description |
wallet.operation_id |
string |
ID of the credit |
wallet.amount |
object |
Amount added: amount (string) and currency |
wallet.balance |
object | null |
SMS balance after the credit: amount (string) and currency |
wallet.reference |
string | null |
Reference of the credit |
wallet.completed_at |
string |
When the credit completed |
organization_sso_id |
string |
Your organization identifier |
{
"type": "sms.wallet.topped_up",
"wallet": {
"operation_id": "0198c5fc-3c44-7a6b-9c2d-3e4f5a6b7c8d",
"amount": { "amount": "25.5", "currency": "EUR" },
"balance": { "amount": "152.8", "currency": "EUR" },
"reference": "INV-7",
"completed_at": "2026-11-03T12:00:00Z"
},
"organization_sso_id": "your-organization"
}
sms.wallet.low_balance:
| Field |
Type |
Description |
wallet.balance |
object |
Current SMS balance: amount (string) and currency |
wallet.threshold |
object |
The threshold: amount (string) and currency |
wallet.checked_at |
string |
When the balance was checked |
organization_sso_id |
string |
Your organization identifier |
{
"type": "sms.wallet.low_balance",
"wallet": {
"balance": { "amount": "3.2", "currency": "EUR" },
"threshold": { "amount": "5", "currency": "EUR" },
"checked_at": "2026-11-03T12:05:00Z"
},
"organization_sso_id": "your-organization"
}
One-time codes: sms.otp.verified, sms.otp.expired
| Event |
Sent when |
sms.otp.verified |
The user entered the right code (POST /otp/verify succeeded) |
sms.otp.expired |
A code expired without being verified |
| Field |
Type |
Description |
otp.verification_id |
string |
The verification_id from POST /otp/send |
otp.status |
string |
verified or expired |
otp.to |
string |
Phone number the code was sent to, digits only without + |
otp.message_id |
string | null |
ID of the SMS that carried the code |
otp.attempts |
integer |
Number of verification attempts |
otp.expires_at |
string |
When the code expires or expired |
otp.verified_at |
string | null |
When it was verified; null for sms.otp.expired |
organization_sso_id |
string |
Your organization identifier |
{
"type": "sms.otp.verified",
"otp": {
"verification_id": "0198c5fd-4d55-7b7c-8d3e-4f5a6b7c8d9e",
"status": "verified",
"to": "38761123456",
"message_id": "0198c5fd-4d60-7c11-9a2b-5c6d7e8f9a0b",
"attempts": 1,
"expires_at": "2026-11-03T12:15:00Z",
"verified_at": "2026-11-03T12:11:42Z"
},
"organization_sso_id": "your-organization"
}
Phone number events
Events of the Phone Numbers API. Events about one number (SMS, number, KYC of a number and porting) can be limited to chosen numbers: when you add or edit an endpoint with only phone number events selected, you can pick up to 10 numbers. Order events are not linked to a single number.
The phone_number object used by the number events:
| Field |
Type |
Description |
id |
string |
Phone number ID |
e164 |
string |
The number in E.164 format |
country_iso |
string |
Country, ISO 3166-1 alpha-2 |
number_type |
string |
local, mobile, toll_free or national |
status |
string |
pending_kyc, provisioning, active, suspended, releasing, released or failed |
kyc_status |
string | null |
draft, pending, approved or rejected |
billing_period |
string | null |
monthly, quarterly, six_monthly or annually |
next_billing_date |
string | null |
Next renewal date, YYYY-MM-DD |
phone_numbers.sms.received
An SMS arrived on one of your numbers.
| Field |
Type |
Description |
sms.id |
string |
Inbound message ID |
sms.direction |
string |
inbound |
sms.from |
string |
Sender number |
sms.to |
string |
Your number |
sms.body |
string |
Message text |
sms.segments |
integer |
Number of segments |
sms.phone_number_id |
string |
ID of your number |
sms.endpoint_id |
string | null |
SMS endpoint assigned to the number, if any |
sms.received_at |
string |
When it was received |
organization_sso_id |
string |
Your organization identifier |
{
"type": "phone_numbers.sms.received",
"sms": {
"id": "e5f6a7b8-c9d0-1234-efab-345678901234",
"direction": "inbound",
"from": "+447911123456",
"to": "+442071234567",
"body": "Hello",
"segments": 1,
"phone_number_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"endpoint_id": null,
"received_at": "2026-11-03T09:00:00Z"
},
"organization_sso_id": "your-organization"
}
phone_numbers.sms.sent, phone_numbers.sms.delivered, phone_numbers.sms.failed
An SMS you sent from your number (POST /sms/messages) was sent, delivered, or not delivered. phone_numbers.sms.failed covers both failed and undelivered.
| Field |
Type |
Description |
sms.id |
string |
Message ID |
sms.direction |
string |
outbound |
sms.status |
string |
sent, delivered, failed or undelivered |
sms.from |
string |
Your number |
sms.to |
string |
Recipient number |
sms.segments |
integer |
Number of segments |
sms.client_reference |
string | null |
Your client_reference, if you sent one |
sms.phone_number_id |
string |
ID of your number |
sms.error_message |
string | null |
Why it failed |
sms.sent_at |
string | null |
When it was sent |
sms.delivered_at |
string | null |
When delivery was confirmed |
organization_sso_id |
string |
Your organization identifier |
{
"type": "phone_numbers.sms.delivered",
"sms": {
"id": "f6a7b8c9-d0e1-2345-fabc-456789012345",
"direction": "outbound",
"status": "delivered",
"from": "+442071234567",
"to": "+447911123456",
"segments": 1,
"client_reference": "order-991",
"phone_number_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"error_message": null,
"sent_at": "2026-11-03T09:05:00Z",
"delivered_at": "2026-11-03T09:05:03Z"
},
"organization_sso_id": "your-organization"
}
phone_numbers.number.purchased
A number was added to your account: bought, ported in, or assigned to you. Sends the phone_number object plus order_id (the order that bought it; null for ported and assigned numbers). See the example in Body.
phone_numbers.number.released
A number was released from your account, or ported out. Sends the phone_number object.
phone_numbers.number.renewed
A number's recurring fee was charged. Sends the phone_number object plus renewed_for, the billing date that was paid (YYYY-MM-DD).
phone_numbers.number.renewal_failed
A number's recurring fee could not be charged. Top up your wallet to keep the number. Sends the phone_number object plus renewal_due (the billing date, YYYY-MM-DD) and reason, a short text explaining why the charge failed.
{
"type": "phone_numbers.number.renewal_failed",
"phone_number": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"e164": "+442071234567",
"country_iso": "GB",
"number_type": "local",
"status": "active",
"kyc_status": "approved",
"billing_period": "monthly",
"next_billing_date": "2026-12-01",
"renewal_due": "2026-12-01",
"reason": "Insufficient wallet balance."
},
"organization_sso_id": "your-organization"
}
phone_numbers.order.completed, phone_numbers.order.partially_completed, phone_numbers.order.failed
A number order (single or bulk) finished. partially_completed means only some of the requested numbers could be bought.
| Field |
Type |
Description |
order.id |
string |
Order ID |
order.type |
string |
single or bulk |
order.status |
string |
completed, partially_completed or failed |
order.country_iso |
string | null |
Country of the order |
order.number_type |
string | null |
Number type |
order.billing_period |
string | null |
Billing period |
order.quantity_requested |
integer |
Numbers requested |
order.quantity_fulfilled |
integer |
Numbers bought |
order.phone_number_ids |
array of strings |
IDs of the numbers bought |
order.total |
object |
Amount charged: amount (string), amount_minor (integer, in cents) and currency |
order.failure |
object | null |
code and message when the order failed |
order.completed_at |
string | null |
When the order finished |
organization_sso_id |
string |
Your organization identifier |
{
"type": "phone_numbers.order.completed",
"order": {
"id": "0198c5f9-52b0-7b1e-8c3d-4e5f6a7b8c9d",
"type": "single",
"status": "completed",
"country_iso": "GB",
"number_type": "local",
"billing_period": "monthly",
"quantity_requested": 1,
"quantity_fulfilled": 1,
"phone_number_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"],
"total": { "amount": "2.50", "amount_minor": 250, "currency": "EUR" },
"failure": null,
"completed_at": "2026-11-03T08:30:00Z"
},
"organization_sso_id": "your-organization"
}
phone_numbers.kyc.approved, phone_numbers.kyc.rejected
A KYC submission was reviewed, either for your organization or for one number.
| Field |
Type |
Description |
kyc.submission_id |
string |
Submission ID |
kyc.scope |
string |
organization or phone_number |
kyc.status |
string |
approved or rejected |
kyc.phone_number_id |
string | null |
The number, for scope: phone_number |
kyc.e164 |
string | null |
The number in E.164, for scope: phone_number |
kyc.rejection_reason |
string | null |
Why it was rejected |
kyc.reviewed_at |
string | null |
When it was reviewed |
organization_sso_id |
string |
Your organization identifier |
{
"type": "phone_numbers.kyc.rejected",
"kyc": {
"submission_id": "0198c5fe-5e66-7c8d-9e4f-5a6b7c8d9e0f",
"scope": "phone_number",
"status": "rejected",
"phone_number_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"e164": "+442071234567",
"rejection_reason": "The proof of address is older than 3 months.",
"reviewed_at": "2026-11-03T14:00:00Z"
},
"organization_sso_id": "your-organization"
}
phone_numbers.port.updated
A port-in or port-out request reached a new status. The porting code (PAC) itself is never sent in a webhook; read it from the API while it is valid.
| Field |
Type |
Description |
port.id |
string |
Port request ID |
port.direction |
string |
in or out |
port.e164 |
string |
The number being ported |
port.status |
string |
in_progress, code_ready, code_expired, completed, failed or cancelled |
port.phone_number_id |
string | null |
ID of the number, when it is on your account |
port.requested_port_date |
string | null |
Requested port date, YYYY-MM-DD |
port.code_ready |
boolean |
true while a porting code is ready to read from the API |
port.code_expires_at |
string | null |
When the porting code expires |
port.failure_message |
string | null |
Why the port failed |
port.completed_at |
string | null |
When the port completed |
organization_sso_id |
string |
Your organization identifier |
{
"type": "phone_numbers.port.updated",
"port": {
"id": "0198c5ff-6f77-7d9e-8f5a-6b7c8d9e0f1a",
"direction": "out",
"e164": "+447700123456",
"status": "code_ready",
"phone_number_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"requested_port_date": null,
"code_ready": true,
"code_expires_at": "2026-12-03T00:00:00Z",
"failure_message": null,
"completed_at": null
},
"organization_sso_id": "your-organization"
}
Bappy events
Events of the Bappy API. A package in these events is one plan activation on a Link; package.id is the activation ID.
The link object used by the link events:
| Field |
Type |
Description |
id |
string |
Link UUID |
name |
string | null |
Link name |
iccid |
string | null |
ICCID of the card |
msisdn |
string | null |
Phone number of the card, when it has one |
status |
string |
active, suspended, inactive or deactivated |
status_reason |
string | null |
When suspended: manual or inactivity; otherwise null |
suspended_at |
string | null |
When suspended; otherwise null |
metadata |
object | null |
The metadata you stored on the Link |
first_activated_at |
string | null |
When the Link was first activated |
deactivated_at |
string | null |
When the Link was deactivated |
bappy.link.created
A Link was created. Sends the link object.
bappy.link.status_changed
A Link's status changed. Sends the link object plus previous_status.
{
"type": "bappy.link.status_changed",
"link": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Trip to Italy",
"iccid": "8901010000000000001",
"msisdn": null,
"status": "suspended",
"status_reason": "inactivity",
"suspended_at": "2026-11-03T06:00:00Z",
"metadata": { "customer_id": "c-1042" },
"first_activated_at": "2026-09-01T10:00:00Z",
"deactivated_at": null,
"previous_status": "active"
},
"organization_sso_id": "your-organization"
}
bappy.package.activated, bappy.package.topped_up, bappy.package.expired
| Event |
Sent when |
bappy.package.activated |
The first plan on a Link became active |
bappy.package.topped_up |
Another plan became active on a Link that already had one |
bappy.package.expired |
A plan expired |
| Field |
Type |
Description |
package.id |
string |
Activation ID |
package.link_id |
string |
Link UUID |
package.iccid |
string | null |
ICCID of the card |
package.plan_id |
string | null |
Plan ID |
package.plan_name |
string | null |
Plan name |
package.status |
string |
active or expired |
package.activated_at |
string | null |
When the plan was activated |
package.expires_at |
string | null |
When the plan expires |
organization_sso_id |
string |
Your organization identifier |
{
"type": "bappy.package.activated",
"package": {
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"link_id": "550e8400-e29b-41d4-a716-446655440000",
"iccid": "8901010000000000001",
"plan_id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"plan_name": "Europe 1GB",
"status": "active",
"activated_at": "2026-11-03T10:00:00Z",
"expires_at": "2026-12-03T10:00:00Z"
},
"organization_sso_id": "your-organization"
}
bappy.package.activation_failed
A plan could not be activated on a new Link, or could not be added to an existing Link. package.id is always null because no activation was created.
| Field |
Type |
Description |
package.id |
null |
Always null |
package.type |
string |
activation (new Link) or topup (plan added to an existing Link) |
package.link_id |
string |
Link UUID |
package.iccid |
string | null |
ICCID of the card, when known |
package.plan_id |
string | null |
Plan ID |
package.plan_name |
string | null |
Plan name |
package.reason |
string |
Why it failed |
package.failed_at |
string |
When it failed |
organization_sso_id |
string |
Your organization identifier |
bappy.usage.threshold_reached, bappy.usage.depleted
Data use of an active plan crossed 50% or 80% (threshold_reached), or the data is used up (depleted, 100%). Each threshold is sent once per plan. When several thresholds are crossed at once, only the highest one is sent.
| Field |
Type |
Description |
usage.package_id |
string |
Activation ID |
usage.link_id |
string |
Link UUID |
usage.iccid |
string | null |
ICCID of the card |
usage.threshold_percent |
integer |
50, 80 or 100 |
usage.used_percent |
number |
Share of data used, up to 100 |
usage.used_mb |
number |
Data used, in MB |
usage.total_mb |
number |
Data included in the plan, in MB |
usage.remaining_mb |
number |
Data left, in MB |
organization_sso_id |
string |
Your organization identifier |
{
"type": "bappy.usage.threshold_reached",
"usage": {
"package_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"link_id": "550e8400-e29b-41d4-a716-446655440000",
"iccid": "8901010000000000001",
"threshold_percent": 80,
"used_percent": 81.25,
"used_mb": 832,
"total_mb": 1024,
"remaining_mb": 192
},
"organization_sso_id": "your-organization"
}
bappy.network.attached, bappy.network.changed
A Link connected to a mobile network for the first time (attached), or moved to another country or operator (changed).
| Field |
Type |
Description |
network.link_id |
string |
Link UUID |
network.iccid |
string | null |
ICCID of the card |
network.country_iso2 |
string |
Country, ISO 3166-1 alpha-2 |
network.country_name |
string | null |
Country name |
network.operator_name |
string | null |
Operator name |
network.operator_mcc |
string | null |
Operator MCC |
network.operator_mnc |
string | null |
Operator MNC |
network.network_type |
string | null |
Network type |
network.seen_at |
string | null |
When the network was seen |
network.previous |
object |
Only in bappy.network.changed: the previous country_iso2, country_name, operator_name, operator_mcc and operator_mnc |
organization_sso_id |
string |
Your organization identifier |
{
"type": "bappy.network.changed",
"network": {
"link_id": "550e8400-e29b-41d4-a716-446655440000",
"iccid": "8901010000000000001",
"country_iso2": "IT",
"country_name": "Italy",
"operator_name": "Example Mobile IT",
"operator_mcc": "222",
"operator_mnc": "01",
"network_type": "LTE",
"seen_at": "2026-11-03T15:42:00Z",
"previous": {
"country_iso2": "AT",
"country_name": "Austria",
"operator_name": "Example Mobile AT",
"operator_mcc": "232",
"operator_mnc": "01"
}
},
"organization_sso_id": "your-organization"
}
bappy.sms.sent, bappy.sms.delivered, bappy.sms.failed
An SMS you sent to a Link was sent, delivered, or not delivered. bappy.sms.failed covers the statuses failed, undelivered, expired and rejected.
| Field |
Type |
Description |
sms.id |
string |
SMS ID |
sms.link_id |
string | null |
Link UUID |
sms.iccid |
string | null |
ICCID of the card |
sms.status |
string |
sent, delivered, failed, undelivered, expired or rejected |
sms.message |
string |
Message text |
sms.provider_status_code |
string | null |
Status code reported by the network |
sms.provider_status_description |
string | null |
Status description reported by the network |
sms.created_at |
string |
When the SMS was created |
sms.updated_at |
string |
When the status last changed |
organization_sso_id |
string |
Your organization identifier |
{
"type": "bappy.sms.delivered",
"sms": {
"id": "9b2d1f3e-4c5a-4b6d-8e7f-0a1b2c3d4e5f",
"link_id": "550e8400-e29b-41d4-a716-446655440000",
"iccid": "8901010000000000001",
"status": "delivered",
"message": "Welcome! Your data plan is active.",
"provider_status_code": null,
"provider_status_description": null,
"created_at": "2026-11-03T10:01:00Z",
"updated_at": "2026-11-03T10:01:05Z"
},
"organization_sso_id": "your-organization"
}
Going live checklist
- Your endpoint is a public
https:// URL and answers 2xx quickly.
- Every request is verified: signature, and a timestamp within 5 minutes.
- Your code works with the raw body for verification, then parses the JSON.
- Duplicates are ignored using
svix-id.
- Your code does not depend on the order of events, and ignores fields and event types it does not know.
- The signing secret is stored securely, and you know how to rotate it.
- You sent a test event for each event type you use and checked the Delivery log.
Troubleshooting
| Problem |
What to check |
| The signature never matches |
Use the raw body, not re-serialized JSON. Use the secret of this endpoint, without the whsec_ prefix, Base64-decoded. Check that the header names are read correctly by your framework |
| The signature stopped matching |
The secret was rotated: update it. Check your server clock if the timestamp check fails |
| No events arrive |
The endpoint is enabled, the event type is selected, and for phone number events the number filter includes the number. Look for failed attempts in the Delivery log |
| The same event arrives twice |
Expected after a retry or a resend. De-duplicate on svix-id |
| An event is missing |
Find it in the Delivery log and use Resend. If it is not there, contact support with the time and the object ID |
Need help?
Email support@omaxtelecom.com. Tell us which endpoint you called, when, and what the response was — including its request_id if it has one. Never send your Client Secret or access token.