Create a key
Add balance on the website or in Discord, then create a key in Dashboard → API keys. Choose only the permissions you need.
Build your own SMS workflow. Discover services, order numbers and receive verification codes through one API, backed by your shared SMSLovers balance.
curl https://smslovers.com/api/v1/account \
-H "Authorization: Bearer $SMSLOVERS_API_KEY"{ "data": {
"currency": "EUR",
"balanceCents": 1500
},
"requestId": "…"
}Example response, abbreviated.Add balance on the website or in Discord, then create a key in Dashboard → API keys. Choose only the permissions you need.
Discover a service and country, then request a live quote. Choose Standard or Resend where available.
Confirm the returned price and purchase with a saved Idempotency-Key. Retries reuse the same key and body.
Poll the order every 5–10 seconds. Keep codes as strings, including any leading zeroes.
Base URL: https://smslovers.com/api/v1. Send JSON for POST requests. Successful responses contain data and requestId; collections use items, page, limit and nextPage. All EUR amounts are integer cents. All timestamps are Unix milliseconds.
Send Authorization: Bearer <API_KEY> on every request. Keys are shown once, stored as keyed hashes and managed in your dashboard. Each key has a name, permissions, expiry and daily SMS spending cap. You can revoke it at any time. Revocation blocks new requests; an already submitted operation may still complete.
| Permission | Allows |
|---|---|
account:read | Account, balance and ledger |
sms:read | Catalogue, countries, orders and SMS codes |
sms:write | Quotes, purchases, cancellation and free resends |
webhooks:read | Endpoint settings and delivery logs |
webhooks:write | Manage signed outgoing events (topic read scopes also required) |
Keep keys on your backend in environment variables or a secret manager. Browser CORS is disabled. Do not put keys in URLs, frontend bundles, logs or messages to an AI agent. Use separate keys for separate integrations. Ten active keys per account; expiry options are 30, 90 or 365 days. Password resets, password changes, account suspension and identity linking also invalidate access; linking revokes existing keys on the merged accounts.
Save 25–50% on eligible API SMS orders, depending on availability. Discounted quotes have a cost and margin floor, so some stock may be unavailable at API rates. Each option returns priceCents, retailPriceCents and discountPercent; the returned price is the amount charged. Rounding uses whole EUR cents.
This is an illustrative quote. Rates are live. API quotes last 60 seconds, belong to the key that requested them and cannot be used through website or Discord checkout. Website and Discord orders keep their normal prices. Add balance through the website or Discord bot. Deposits, promo codes and support tickets are not available through this API. Resends carry no customer fee.
The key’s daily cap counts gross SMS reservations from 00:00 UTC, including orders later refunded. Refunds return balance but do not replenish this daily cap. Existing idempotent retries do not count twice.
GET /services and use exact country keys from GET /countries. Coverage does not guarantee stock.POST /quotes returns available options. Select an options[].id; display the price to your customer.POST /orders with that quoteId and a unique, persisted Idempotency-Key. Balance is reserved once. At most five active purchases per account.GET /orders/{id} every 5–10 seconds. The background service updates the code automatically. Codes and message history are private to your account.| Status | What to do |
|---|---|
purchasing / waiting | Keep the order ID. Poll for a number/code. |
received | Code is ready. Preserve leading zeroes. Resend may be available. |
resending | A new SMS is pending. Poll the same order ID. |
cancel_requested | Cancellation is pending; do not assume a refund. |
cancelled / refunded / unavailable | Order ended; balance has been returned. Stop polling. |
reconciling / resend_review | The result needs review. Contact support with the order ID; do not repurchase blindly. |
Resend availability can change. A Resend quote selects eligible stock but does not guarantee indefinite reuse. One resend can be pending at a time; at most 25 attempts per number. The original code stays in message history. You must request the next code from the service you are verifying.
Read your current balance anytime with GET /account using account:read. For automatic updates, configure up to five public HTTPS receivers in Dashboard → Webhooks or through /webhooks. Choose the events you need and set your low-balance threshold in EUR cents.
| Event | When it fires | Extra API permission to subscribe |
|---|---|---|
balance.low | Balance crosses from at/above your threshold to below it. | account:read |
sms.received | First SMS and every successful resend; includes code/message. | sms:read |
sms.ended | Confirmed cancellation, refund or unavailable/expired attempt. | sms:read |
sms.review | An uncertain purchase or resend needs review. | sms:read |
deposit.credited | Crypto or Card / Bank funds have actually credited your balance. | account:read |
ticket.replied | Support posts a public reply; configure this event in the website dashboard. | Dashboard only |
webhooks:write permits endpoint management. Creating/updating subscriptions, rotating secrets and retrying private deliveries also require the corresponding read permissions above. webhooks:read allows endpoint settings and delivery metadata. The test action sends webhook.test with a harmless message.
Low-balance notifications fire once per downward crossing, then rearm when your balance reaches the threshold again. If the balance is already low when you create/re-enable the subscription or change its threshold, we queue one initial alert. Further spending below the same threshold does not send repeated alerts. The payload contains the balance at the time of the event; use GET /account for the latest amount.
{
"id": "evt_balance_123",
"type": "balance.low",
"accountId": "22222222-2222-4222-8222-222222222222",
"createdAt": 1792526400000,
"data": {
"currency": "EUR",
"balanceCents": 430,
"previousBalanceCents": 550,
"thresholdCents": 500
}
}Each POST contains JSON and these headers:
X-SMSLovers-Event | The event type. |
X-SMSLovers-Delivery-Id | Stable across retries for this endpoint. |
X-SMSLovers-Timestamp | Unix seconds for this attempt. |
X-SMSLovers-Signature | v1= followed by HMAC-SHA256 in lowercase hexadecimal. |
Calculate HMAC-SHA256 over timestamp + "." + exact raw JSON body, using the entire signing-secret string as the key. Do not base64-decode the secret or parse/re-serialize JSON before verification. Compare signatures in constant time, reject timestamps outside five minutes, then atomically deduplicate on the body’s id before applying business effects.
The signing secret is shown once when creating or rotating an endpoint. Store it privately. It is separate from your API key. API-key revocation stops API access; webhook subscriptions remain independent. Password reset/change, suspension and account merging pause existing webhook endpoints for security. Re-enable only the receivers you trust.
Return any 2xx response after securely persisting the event, then process it asynchronously. Delivery is at least once and ordering is not guaranteed. A timeout after your server accepted an event can cause a retry. IDs and raw payload stay the same; the signature timestamp is refreshed. Use createdAt and authoritative API reads when handling out-of-order updates.
We attempt delivery up to eight times with backoff starting at 15 seconds, then 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours and 6 hours. A numeric Retry-After may lengthen the delay, up to 24 hours. We do not follow redirects. HTTP 410 pauses the endpoint. Inspect status, attempt counts and HTTP codes in the dashboard/API, and manually retry a failed delivery after fixing your receiver. Logs are retained for 30 days.
Receivers must use a public DNS hostname with HTTPS on port 443. Private, loopback, link-local, reserved and mixed public/private DNS destinations are blocked. The destination is checked again and pinned for every connection. To change a URL, create a new endpoint and remove the old one. In-flight requests may complete after pause/removal.
Webhook configuration/actions allow 20 requests per hour per account; tests allow five per hour and have a shared service limit. Core payment and SMS checks run independently of webhook retries. SMS event payloads contain the relevant code only; fetch the order for full history. A resend outcome includes resendId and may end while the original order remains received. Events from card/crypto creation or unconfirmed payments are not sent as credits.
// Node.js 22+. Put behind your public HTTPS reverse proxy on port 443.
import http from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
const secret = process.env.SMSLOVERS_WEBHOOK_SECRET; // Full whsec_... string.
if (!secret) throw new Error('Set the signing secret privately');
const server = http.createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/webhooks/smslovers') {
res.writeHead(404).end(); return;
}
const timestamp = String(req.headers['x-smslovers-timestamp'] || '');
const signature = String(req.headers['x-smslovers-signature'] || '');
if (!/^\d{10}$/.test(timestamp) ||
Math.abs(Date.now() / 1000 - Number(timestamp)) > 300 ||
!/^v1=[a-f0-9]{64}$/.test(signature)) {
res.writeHead(401).end(); return;
}
try {
let size = 0; const chunks = [];
for await (const chunk of req) {
size += chunk.length;
if (size > 32768) { res.writeHead(413).end(); return; }
chunks.push(chunk);
}
const raw = Buffer.concat(chunks);
const expected = createHmac('sha256', secret)
.update(timestamp + '.').update(raw).digest();
const supplied = Buffer.from(signature.slice(3), 'hex');
if (!timingSafeEqual(expected, supplied)) {
res.writeHead(401).end(); return;
}
const event = JSON.parse(raw.toString('utf8'));
// REQUIRED: atomically persist event.id with a unique constraint and
// enqueue the work in your database. If already stored, return 204.
// await persistAndEnqueueOnce(event.id, event);
// Replace the line above with your own durable queue before production.
console.log('Verified event type:', event.type); // Never log private codes.
res.writeHead(204).end(); // Acknowledge after durable storage succeeds.
} catch {
res.writeHead(500).end(); // SMSLovers will retry with the same event ID.
}
});
server.requestTimeout = 15000;
server.listen(3000, '127.0.0.1');This example verifies requests behind your HTTPS proxy. Connect the indicated persistence/queue step before production so duplicate events cannot repeat a business action. Never trust an unsigned event or use a return-page visit as proof of payment.
All paths below are relative to /api/v1. Full field schemas and error responses are in the OpenAPI 3.1 specification. Expand an endpoint for its request and behavior.
/webhooksList webhook endpoints and event typesPermission: webhooks:read
Lists active/paused endpoints, never signing secrets. Up to five configured endpoints per account.
No request body.
Response schema: List webhook endpoints and event types ↗/webhooksCreate an event receiverPermission: webhooks:write
Also requires account:read for balance.low, sms:read for SMS events, account:read for deposit.credited. URLs are unique per account; retrying creation returns 409, so list endpoints before creating again. Rotate the secret if a creation response was lost. If already below the threshold, one initial low-balance event is queued.
{
"name": "My product",
"url": "https://api.example.com/webhooks/smslovers",
"events": [
"balance.low",
"sms.received"
],
"lowBalanceCents": 500,
"enabled": true
}/webhooks/{id}Update or pause a webhookPermission: webhooks:write
Partial update. Requires the read scopes for every subscribed event. The URL is immutable: create a new endpoint to change it. Pausing stops queued attempts; an in-flight request may complete.
{
"enabled": false
}/webhooks/{id}/deliveriesInspect webhook deliveriesPermission: webhooks:read
50 entries per page, newest first. Logs contain metadata, never signing secrets, SMS text or raw response bodies. Retained for 30 days.
Query parameters: page — Zero-based page..
No request body.
Response schema: Inspect webhook deliveries ↗/webhooks/{id}/rotate-secretRotate the signing secretPermission: webhooks:write
Also requires the subscribed event read scopes. Effective immediately for new attempts. An already submitted delivery may use the old secret. Update your verifier and allow a short transition window where appropriate.
{}/webhooks/{id}/testQueue a signed test eventPermission: webhooks:write
Sends webhook.test with a harmless message to this enabled endpoint. HTTP 202 means queued, not delivered. Five tests per hour per account and 30 per minute service-wide.
{}/webhooks/{id}/retryRetry a failed deliveryPermission: webhooks:write
Also requires the subscribed event read scopes. Only failed deliveries on an enabled endpoint can be retried. Reuses the same event ID, delivery ID and payload; the signature timestamp is fresh. HTTP 202.
{
"deliveryId": "whd_0123456789abcdef0123456789abcdef"
}/webhooks/{id}/removeRemove a receiverPermission: webhooks:write
Stops queued deliveries and hides the endpoint. An in-flight request may complete. Registering the same URL later generates a new secret.
{}/accountAccount and balancePermission: account:read
One shared EUR balance across the website, Discord and API. Key caps reset at 00:00 UTC; spentTodayCents counts gross SMS reservations, including subsequently refunded orders.
No request body.
Response schema: Account ↗/ledgerBalance ledgerPermission: account:read
Newest first. Includes reservations, refunds, confirmed deposits and bonuses.
Query parameters: page — Zero-based page.; limit — Results per page..
No request body.
Response schema: Balance ledger ↗/countriesCountry keysPermission: sms:read
Use key exactly as returned, such as US or Germany. Country keys are not universally ISO codes.
No request body.
Response schema: Country keys ↗/servicesSearch servicesPermission: sms:read
Alphabetical. Country lists describe coverage, not guaranteed live stock.
Query parameters: page — Zero-based page.; limit — Results per page.; q — Case-insensitive name search.; country — Exact country key, e.g. US..
No request body.
Response schema: Search services ↗/services/{id}Service and supported countriesPermission: sms:read
Use the documented request and response fields.
No request body.
Response schema: Service and supported countries ↗/quotesGet a discounted live quotePermission: sms:write
Quotes expire after 60 seconds and belong to this account, API key and API billing channel. Select an options[].id. Standard and Resend choices are returned only when available. available:false means no eligible discounted stock; try another service/country later. Quoting never spends balance.
{
"serviceId": 1,
"country": "US"
}/ordersBuy an SMS numberPermission: sms:write · Idempotency-Key required
Requires Idempotency-Key. Reserves priceCents from your balance once. Retry the exact quoteId with the same key, including after a timeout or quote expiry. Changing the quoteId with the same key returns 409. Five active purchases per account across all channels. reconciling means do not buy a replacement blindly; contact support.
{
"quoteId": "11111111-1111-4111-8111-111111111111"
}/ordersList your SMS ordersPermission: sms:read
Includes your website and Discord SMS orders too. status filters the stored lifecycle (not the derived resending/resend_review presentation).
Query parameters: page — Zero-based page.; limit — Results per page.; status — Optional filter..
No request body.
Response schema: List your SMS orders ↗/orders/{id}Get the number, code and historyPermission: sms:read
Poll every 5–10 seconds. This reads the current state; automatic background checks receive new SMS messages. Stop polling terminal orders. GET never places a new order or requests a resend.
No request body.
Response schema: Order ↗/orders/{id}/cancelCancel an unused numberPermission: sms:write
Available one minute after purchase while waiting. A confirmed cancellation or unused-number refund returns exactly the charged amount. Cancellation is naturally idempotent; a used or uncertain order returns 409.
{}/orders/{id}/resend-checkCheck resend availabilityPermission: sms:write
After the first code. A live check may say unavailable even when a Resend option was selected earlier; availability and expiry can change. Does not request a new SMS.
{}/orders/{id}/resendRequest another SMSPermission: sms:write · Idempotency-Key required
Requires Idempotency-Key. Free to the customer; no per-resend balance debit. Request the next code on the service you are verifying, then poll the same order ID. Previous codes remain in messages. Up to 25 resend attempts per number; actual eligibility and expiry vary. For a definitive 409, check availability before choosing a new idempotency key. For a timeout/5xx, retain the original key.
{}The first example shows a complete purchase and polling flow. Add durable storage for the idempotency key, request body and order ID before using it in production.
// Node.js 22+. Run on your server; persist keys before each write.
import { randomUUID } from 'node:crypto';
const base = 'https://smslovers.com/api/v1';
const key = process.env.SMSLOVERS_API_KEY;
if (!key) throw new Error('Set SMSLOVERS_API_KEY privately');
async function api(path, method = 'GET', body, requestKey) {
const response = await fetch(base + path, {
method, signal: AbortSignal.timeout(25000),
headers: { Authorization: 'Bearer ' + key,
...(body ? { 'Content-Type': 'application/json' } : {}),
...(requestKey ? { 'Idempotency-Key': requestKey } : {}) },
...(body ? { body: JSON.stringify(body) } : {})
});
const result = await response.json();
if (!response.ok) throw Object.assign(new Error(result.error.message), {
status: response.status, requestId: result.requestId,
retryAfter: response.headers.get('retry-after')
});
return result.data;
}
// Discover IDs/countries; this example uses Google (1) in US.
const quote = await api('/quotes', 'POST', { serviceId: 1, country: 'US' });
if (!quote.available) throw new Error('No discounted stock available');
const option = quote.options.find(o => o.label === 'Standard') ?? quote.options[0];
// Show option.priceCents to your customer and get purchase approval first.
const orderKey = randomUUID(); // SAVE in your database before the next call.
const purchaseBody = { quoteId: option.id }; // SAVE unchanged for retries.
const order = await api('/orders', 'POST', purchaseBody, orderKey);
// A retry must use the same orderKey and purchaseBody, even after timeout.
let current = order;
while (['purchasing', 'waiting', 'resending', 'cancel_requested'].includes(current.status)) {
await new Promise(r => setTimeout(r, 10000));
current = await api('/orders/' + order.id);
}
if (current.status === 'received') console.log(current.code); // String: keep zeroes.
else if (['reconciling', 'resend_review'].includes(current.status)) {
throw new Error('Needs review; do not create a replacement automatically');
} else console.log('Order ended:', current.status);
// Production: persist the order ID too; resume polling after a restart.
// On 429 wait Retry-After seconds. On timeout/5xx, back off and retry the
// SAME operation/key/body. Do not log keys or SMS codes in production.# Python 3, standard library. Read-only connection test.
import json, os, urllib.request, urllib.error
key = os.environ['SMSLOVERS_API_KEY']
request = urllib.request.Request(
'https://smslovers.com/api/v1/account',
headers={'Authorization': 'Bearer ' + key})
try:
with urllib.request.urlopen(request, timeout=25) as response:
account = json.load(response)['data']
print('EUR balance:', account['balanceCents'] / 100)
except urllib.error.HTTPError as error:
# Honor error.headers.get('Retry-After') on HTTP 429.
print('API request failed:', error.code)
raisePer key: 240 GET and 60 POST requests per minute. Per account: 600 API requests per minute. These budgets are separate from website browsing and automatic payment/SMS checks. Shared-network ingress protection allows 1,800 API requests per minute per IP.
| Operation | Account limit |
|---|---|
| Live quotes | 30 / minute |
| Purchases | 10 / minute |
| Cancellations | 10 / minute |
| Resend/check operations combined | 10 / minute |
| Webhook configuration | 20 / hour |
| Webhook tests | 5 / hour |
There is also a shared quote-capacity ceiling. A temporary pricing outage returns 503. Honor Retry-After on 429 and back off on 503. Polling every 10 seconds uses six requests per minute per order; five active orders use 30.
Persist a new Idempotency-Key (16–80 letters, numbers or hyphens) before each order, resend. Retry the same operation with the same key and identical body after timeouts/5xx. A changed order quote or checkout amount with the same key returns 409. Cancellation, receiving-address retrieval and ticket status updates are naturally idempotent. Quotes do not spend balance.
{
"error": {
"code": "rate_limited",
"message": "Too many attempts. Please try again later.",
"retryAfterSeconds": 60
},
"requestId": "example-request-id"
}400 invalid request; 401 invalid/expired/revoked key; 403 missing scope, cap or cross-origin request; 404 missing resource; 409 expired quote, insufficient balance, idempotency conflict or invalid state; 413 body over 32 KiB; 415 wrong content type; 429 rate limit; 503 service temporarily unavailable. Use requestId or the matching X-Request-Id header when contacting support. Do not log authorization headers or private SMS content.
Copy this prompt and send it to your preferred AI agent. It links the public docs and explains authentication, billing, retries and the main workflow. Enter your real key privately in your hosting environment.
Get the SMSLovers Discord bot with your own name and branding. Contact us to arrange setup.
Request a rebrand ↗Need an integration or an extra feature? Tell us what you want to build and we’ll give you a quote.
Get a quote ↗