Events
BetaSigned deliveries instead of polling: what we send, how to verify it, and what happens when your endpoint is down.
Register an endpoint and we post to it when something happens, so you do not have to ask. Endpoints are managed on the Developers page of the dashboard: add the URL, choose what to send, and copy the signing secret — which, like a key, is shown once.
What you can subscribe to
The names in the middle column are the ones the dashboard shows. The same list,
with every header and body field, is in the reference under
Events — generated from the OpenAPI document, which
describes them in its webhooks section.
| Event | In the dashboard | When | Body |
|---|---|---|---|
activation.code_received | Code received | A code landed on a one-time number | Activation |
activation.number_changed | Number replaced | A dead number was swapped for a new one; the activation carries on | Activation |
activation.completed | Activation finished | The window closed after a code had arrived | Activation |
activation.refunded | Activation refunded | It ended without a code, and the price went back | Activation |
rental.message_received | Rental message received | Any message, for as long as you hold the number | Rental |
rental.extended | Rental extended | Its expiry moved later | Rental |
rental.refunded | Rental refunded | Cancelled inside its refund window, and the price went back | Rental |
webhook.test | Test event | Only when you ask for one | See below |
Most integrations need only the first of each: the code, or the message.
There is deliberately no rental.expired. Nothing marks a rental expired — it
simply passes its expires_at — so there is no moment at which we could honestly
send it. Read expires_at instead.
The body is the same object the matching GET returns, frozen at the moment it
happened. A delivery that arrives an hour late still describes what was true when
it was recorded.
What a delivery looks like
A POST to your URL. The event type is in a header, and the body is the
resource itself — there is no envelope around it:
POST /ghostsms HTTP/1.1
Host: your-server.example.com
Content-Type: application/json
User-Agent: GhostSMS-Webhooks/1
GhostSMS-Event-Id: 5f0832d8-b722-4c91-9e3a-6d1f0a4b2c77
GhostSMS-Event-Type: activation.code_received
GhostSMS-Signature: t=1789700000,v1=3a7f...
{
"object": "activation",
"id": "8c1e4f2a-3b6d-4e9f-a0c7-2d5b9e1f7a34",
"status": "code_received",
"service": { "id": "telegram", "name": "Telegram" },
"country": "US",
"phone_number": "+13155550142",
"price": { "amount": 62, "currency": "USD" },
"client_reference": "order-8842",
"created_at": "2026-09-18T10:20:00Z",
"expires_at": "2026-09-18T10:35:00Z",
"messages_count": 1,
"messages": [
{
"object": "message",
"id": "b7d2c9e0-4f1a-4a6b-8e3d-1c5f9a2e7b60",
"received_at": "2026-09-18T10:21:04Z",
"from": "Telegram",
"text": "Telegram code: 482913",
"code": "482913"
}
]
}
Fields with no value are left out rather than sent as null, exactly as on a
GET. Your client_reference comes back, so you can match the delivery to
your own order without a lookup.
Verifying a delivery
Each request carries:
GhostSMS-Event-Id: 5f0832d8-b722-4c91-...
GhostSMS-Event-Type: activation.code_received
GhostSMS-Signature: t=1789700000,v1=3a7f...
The signature is HMAC-SHA256 over "<t>.<raw body>", keyed with your signing
secret. Verify it over the raw bytes, before any JSON parsing:
const [t, v1] = header.split(',').map((part) => part.split('=')[1])
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
// timingSafeEqual throws on buffers of different lengths, so compare lengths first.
const ok = typeof v1 === 'string' && v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
Then check that t is recent — we allow five minutes — and reject anything older.
The timestamp is inside the signed value, so a captured delivery cannot be
replayed with a fresh one.
Delivery is at least once
Answer 2xx quickly; anything else counts as a failure. A delivery whose sender
died is picked up again, which means the same event can arrive twice —
deduplicate on GhostSMS-Event-Id, and treat the handler as idempotent.
Failures are retried after 1m, 5m, 30m, 2h, 6h, 12h and 24h — eight attempts over about forty-four hours — and then given up on. An endpoint that fails twenty times in a row is switched off and the account is told, rather than being retried forever against a dead address; you can switch it back on once it is fixed. Withdrawing an account's API access also switches its endpoints off.
The recent deliveries for each endpoint, with what your server answered, are in the dashboard — that is the first place to look when something has gone quiet.
Test events
Send test event in the dashboard posts a webhook.test to that endpoint
alone, signed and retried like any other delivery. Its body is not a
resource:
{ "message": "This is a test event from GhostSMS." }
So branch on GhostSMS-Event-Type before parsing the body as an activation or
a rental — a handler that assumes every delivery is an activation will fail the
first test you send it.
Endpoints and signing secrets
- Three endpoints per account. Each chooses its own events, so a separate endpoint per environment or per job is the usual shape.
- Each endpoint has its own signing secret, shown once when the endpoint is created.
- Rotating a secret replaces it at once — there is no period where both work. Every attempt is signed when it is sent, so a delivery your server refused during the switch is retried with the new secret: deploy the new one promptly and nothing is lost.
- Deliveries come from
User-Agent: GhostSMS-Webhooks/1, if you filter traffic on it. Filter on the signature, not on the address we send from.
What we will not post to
Your URL is an address we connect to, so it is checked when you save it and again on every send: HTTPS only, no credentials in the URL, and every address the name resolves to must be public — loopback, link-local, private ranges and carrier-grade NAT are all refused. Redirects are never followed, and we do not read your response body.