Skip to content

Latest commit

 

History

History
279 lines (217 loc) · 9.48 KB

File metadata and controls

279 lines (217 loc) · 9.48 KB

VirtualSMS Webhooks

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/webhooks API and the in-app Settings → Webhooks tab.

Why webhooks?

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 types (v1 — 5 events)

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.

Envelope

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).

sms.received payload

{
  "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"
  }
}

order.cancelled payload

{
  "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.

order.expired payload

{
  "data": {
    "order_id": "...",
    "phone_number": "+447700900123",
    "service": "telegram",
    "country": "GB",
    "expired_at": "2026-04-29T14:23:45.000Z",
    "duration_minutes": 20
  }
}

order.swapped payload

{
  "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"
  }
}

balance.low payload

{
  "data": {
    "current_balance": 0.42,
    "threshold": 1.00,
    "currency": "USD",
    "last_spent_at": "2026-04-29T14:23:45.000Z"
  }
}

Delivery details

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, 12h after 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-Timestamp is more than 5 minutes from your server time.
  • Idempotency: Use X-VirtualSMS-Delivery (or the envelope id) as a dedupe key. We may retry the same event after a transient failure.

Verifying the signature

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.

Node.js

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);
}

Python

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)

PHP

function verifyWebhook(string $rawBody, ?string $signatureHeader, string $secret): bool {
    $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $signatureHeader ?? '');
}

Creating webhooks via API

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 secret is 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).

Subscribing to balance.low

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.

Test fire

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.

Delivery log

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.

Setting up Pipedream

  1. Create a Pipedream workflow with an HTTP / Webhook trigger. Pipedream gives you a unique URL like https://abc123.m.pipedream.net.
  2. Copy that URL.
  3. In VirtualSMS, go to Settings → Webhooks → Add webhook, paste the URL, pick the events (e.g. sms.received).
  4. Save. Copy the secret shown on the success card and store it in Pipedream as a secret variable.
  5. In your Pipedream workflow, add a step that verifies the signature using the Node.js snippet above before processing.
  6. Click Test in VirtualSMS to fire a synthetic event and confirm Pipedream sees it.

Troubleshooting

  • "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 200 first, then queue async work.
  • Duplicate events — we may retry. De-dupe by X-VirtualSMS-Delivery or envelope id.

Limits

  • 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).

Changelog

2026-04-29 — Initial release. 5 canonical events, HMAC-SHA256 signing, 5-attempt exponential backoff, auto-pause at 20 failures.