Real-time event delivery for SMS arrivals, order lifecycle, and balance alerts. Configure once, get instant pushes to any HTTPS URL — including Pipedream, Zapier, n8n, or your own backend.
Status: available 2026-04-29. Available across 145+ countries and 2500+ services on the
/api/v1/customer/webhooksAPI and the in-app Settings → Webhooks tab.
Polling /api/v1/customer/order/{id} works, but webhooks are faster and cheaper:
- Instant delivery. SMS arrives -> we POST your endpoint within ~100ms of the database write.
- No idle requests. Stop polling. Receive events only when something happens.
- Cancel / expire / balance hooks. Trigger auto-topup or cleanup workflows that polling can't see efficiently.
| Event | Fires when | Common use |
|---|---|---|
sms.received |
An SMS arrives at one of your rented numbers and matches a waiting order | Pipedream / MCP polling replacement, instant code consumption |
order.cancelled |
You cancel an order via API, dashboard, or auto-refund worker | Accounting, cleanup |
order.expired |
An order timed out without an SMS within its window | Retry, fallback flow |
order.swapped |
You request a replacement number for an active order | Number rotation tracking |
balance.low |
Your USD balance drops to / below a threshold you configured | Stripe-style auto-topup |
balance.low is edge-triggered. It fires once per trip, then re-arms after the balance returns above the threshold.
Every event uses the same outer shape:
{
"id": "evt_01HXY7K8ZNPABZQ4M2T6PQXR9V",
"type": "sms.received",
"created_at": "2026-04-29T14:23:45.123Z",
"account_id": "<your-user-uuid>",
"data": { /* event-specific */ }
}id is a 26-character ULID with the evt_ prefix — time-sortable, globally unique. Use it as an idempotency key (we may retry; you should de-dupe by id).
{
"data": {
"order_id": "...",
"phone_number": "+447700900123",
"service": "telegram",
"country": "GB",
"code": "847291",
"full_text": "Your Telegram code: 847291",
"received_at": "2026-04-29T14:23:45.000Z"
}
}{
"data": {
"order_id": "...",
"phone_number": "+447700900123",
"service": "telegram",
"country": "GB",
"cancelled_at": "2026-04-29T14:23:45.000Z",
"refunded_amount": 0.05,
"refund_currency": "USD",
"reason": "user_cancelled"
}
}reason is one of user_cancelled, timeout_no_sms, operator_rejected.
{
"data": {
"order_id": "...",
"phone_number": "+447700900123",
"service": "telegram",
"country": "GB",
"expired_at": "2026-04-29T14:23:45.000Z",
"duration_minutes": 20
}
}{
"data": {
"old_order_id": "...",
"new_order_id": "...",
"old_phone_number": "+447700900123",
"new_phone_number": "+447700900456",
"service": "telegram",
"country": "GB",
"swapped_at": "2026-04-29T14:23:45.000Z"
}
}{
"data": {
"current_balance": 0.42,
"threshold": 1.00,
"currency": "USD",
"last_spent_at": "2026-04-29T14:23:45.000Z"
}
}Every webhook delivery POSTs to your URL with these headers:
Content-Type: application/json
User-Agent: VirtualSMS-Webhook/1.0
X-VirtualSMS-Signature: sha256=<hex>
X-VirtualSMS-Timestamp: <unix-millis>
X-VirtualSMS-Event: <event.type>
X-VirtualSMS-Delivery: <delivery-uuid>
- Method:
POST. Body is the raw JSON envelope above. - Timeout: 5 seconds connect + read. Respond fast and queue any heavy work async.
- Success: Any 2xx status. Anything else (3xx / 4xx / 5xx / timeout / network error) is treated as failure.
- Retry: 5 attempts at exponential backoff:
1m, 5m, 30m, 2h, 12hafter each failure. - Auto-pause: After 20 consecutive failures across all events, your endpoint is automatically paused and a notification is sent. You un-pause it via PATCH or the dashboard.
- Replay protection: Reject any request where
X-VirtualSMS-Timestampis more than 5 minutes from your server time. - Idempotency: Use
X-VirtualSMS-Delivery(or the envelopeid) as a dedupe key. We may retry the same event after a transient failure.
Your endpoint receives X-VirtualSMS-Signature: sha256=<hex> where the hex is HMAC-SHA256(rawBody, secret). Recompute on your side using the raw request body (don't re-serialize) and compare in constant time.
const crypto = require('crypto');
function verifyWebhook(rawBody, signatureHeader, secret) {
const expected = 'sha256=' +
crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(signatureHeader || '', 'utf8');
const b = Buffer.from(expected, 'utf8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}import hmac, hashlib
def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature_header or "", expected)function verifyWebhook(string $rawBody, ?string $signatureHeader, string $secret): bool {
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $signatureHeader ?? '');
}curl -X POST https://virtualsms.io/api/v1/customer/webhooks \
-H "X-API-Key: vms_your_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/virtualsms",
"description": "Pipedream workflow",
"events": ["sms.received", "order.cancelled"]
}'Response:
{
"success": true,
"webhook": {
"id": "...",
"url": "https://example.com/hooks/virtualsms",
"description": "Pipedream workflow",
"events": ["sms.received", "order.cancelled"],
"secret": "abc...64-hex",
"active": true,
"paused": false,
"threshold": null,
"created_at": "2026-04-29T14:23:45Z"
}
}The
secretis returned only on this initial create call. Save it — subsequent GETs will not include it. You'll need this exact value to verify HMACs on every incoming request. If you lose it, delete and re-create the webhook (rotate-secret will be added in a future revision).
Pass threshold (USD) when subscribing:
curl -X POST https://virtualsms.io/api/v1/customer/webhooks \
-H "X-API-Key: vms_your_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/topup",
"events": ["balance.low"],
"threshold": 5.00
}'You'll receive balance.low once when the balance crosses below 5.00, then again only after it goes above and below again.
Verify your endpoint is reachable and your HMAC verification works without waiting for a real SMS:
curl -X POST https://virtualsms.io/api/v1/customer/webhooks/<id>/test \
-H "X-API-Key: vms_your_key"The dispatcher queues a synthetic event whose payload includes data.test: true. The signature, headers, and retry policy are identical to a real event — your code path should be exercised end-to-end. Ignore data.test === true events in production.
Inspect recent deliveries (success, failures, response codes, response bodies up to 1KB):
curl https://virtualsms.io/api/v1/customer/webhooks/<id>/deliveries \
-H "X-API-Key: vms_your_key"Returns up to the last 100 deliveries. Each row carries event_id, attempt, status, response_status, error_message, scheduled_for, delivered_at.
- Create a Pipedream workflow with an HTTP / Webhook trigger. Pipedream gives you a unique URL like
https://abc123.m.pipedream.net. - Copy that URL.
- In VirtualSMS, go to Settings → Webhooks → Add webhook, paste the URL, pick the events (e.g.
sms.received). - Save. Copy the secret shown on the success card and store it in Pipedream as a secret variable.
- In your Pipedream workflow, add a step that verifies the signature using the Node.js snippet above before processing.
- Click Test in VirtualSMS to fire a synthetic event and confirm Pipedream sees it.
- "Endpoint paused" after a deploy gone wrong — happens after 20 consecutive failures. Un-pause via PATCH
{"paused": false}or the dashboard. The failure counter resets to zero on un-pause. - Signature verification fails — make sure you're hashing the raw bytes of the request body, not a re-serialized JSON. Express users: use
express.raw({type: 'application/json'})for the webhook route, then parse after verifying. - Hitting timeout — your endpoint must respond within 5 seconds. Acknowledge with
200first, then queue async work. - Duplicate events — we may retry. De-dupe by
X-VirtualSMS-Deliveryor envelopeid.
- HTTPS only.
http://and any RFC1918 / loopback / link-local host is rejected at create time. - Body responses are truncated to 1000 chars in the delivery log.
- Up to 25 endpoints per account.
- 5 retry attempts max. After that, the row is marked
permanent_failure(the endpoint stays active for future events).
2026-04-29 — Initial release. 5 canonical events, HMAC-SHA256 signing, 5-attempt exponential backoff, auto-pause at 20 failures.