On this page

Events

Beta

Signed deliveries instead of polling: what we send, how to verify it, and what happens when your endpoint is down.

5 min read

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.

EventIn the dashboardWhenBody
activation.code_receivedCode receivedA code landed on a one-time numberActivation
activation.number_changedNumber replacedA dead number was swapped for a new one; the activation carries onActivation
activation.completedActivation finishedThe window closed after a code had arrivedActivation
activation.refundedActivation refundedIt ended without a code, and the price went backActivation
rental.message_receivedRental message receivedAny message, for as long as you hold the numberRental
rental.extendedRental extendedIts expiry moved laterRental
rental.refundedRental refundedCancelled inside its refund window, and the price went backRental
webhook.testTest eventOnly when you ask for oneSee 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.