Reference

SMS pumping

Check before sending an OTP.

Call this server-side once per proposed send. Send only on allow; on challenge, require an independent human or account check before continuing. Do not send on block. A successful HTTP response does not authorize a blocked send. This endpoint does not send SMS or prove ownership of a phone.

POST https://nolgard.com/v1/sms-check
Content-Type: application/json

{"secret":"sk_…","phone":"+14155550100","accountId":"user_123","ip":"203.0.113.4","action":"signup"}

200 {"decision":"allow","reasons":[],"country":"US"}

secret and phone are required. accountId, ip and action are optional. Supply the end-user IP from a trusted server integration instead of an untrusted browser field. Action is accepted but does not partition counters. Decisions are allow, challenge or block; block takes precedence. All matching reasons are returned.

ReasonDecisionTrigger, including this attempt
invalid_numberblockNot a canonical + E.164 number (8–15 digits), unknown calling code, or invalid NANP length. This is syntax validation rather than a check of carrier reachability.
country_not_allowedblockDestination country missing from the configured allowlist, including an unknown country.
high_risk_destinationchallengeStatic IRSF-risk destination heuristic, unless explicitly country-allowlisted.
prefix_burstblock6th distinct number in 1 hour sharing country + first 4 national digits.
sequential_numbersblockAt least 3 distinct numbers in 1 hour within ±20 of the current number (inclusive); exact hashed-neighbor comparison.
network_velocitychallenge11th attempt from the supplied IP network prefix in 1 hour; skipped without IP.
account_velocitychallenge6th attempt for the account in a rolling 24 hours; skipped without accountId.

All counters are per site and count checked attempts, including blocked attempts and retries. Delivered messages are not counted. Repeated checks of one number do not increase distinct-number counts. Configure sms: {"allowCountries":["US","GB"]} through site settings or PATCH /api/sites/:key. null removes country restrictions; [] permits no countries. Allowlisting suppresses only the destination-risk challenge, never velocity checks.

Test numbers

Use any public test secret: test-allow-secret, test-bot-secret, test-limit-secret or test-shadow-secret. The phone selects the result regardless of test-secret variant. No counters are stored. These numbers behave no differently with production secrets.

PhoneResult
+15005550000allow
+15005550001challenge / high_risk_destination
+15005550002block / prefix_burst
+15005550003block / sequential_numbers
+15005550004challenge / network_velocity
+15005550005challenge / account_velocity
+15005550006block / country_not_allowed
+15005550007block / invalid_number (country null)

Limits and privacy

This complements provider controls such as Twilio Verify Fraud Guard, spending limits and geo permissions; it does not replace them. The curated static list in src/core/phone.ts is a conservative challenge heuristic rather than a live premium-rate or carrier database. Country comes from calling codes, with NANP area-code and Kazakhstan disambiguation; other shared codes use their primary territory. Non-geographic networks return null. Number portability, assignment and SMS capability are not checked.

Only per-site HMACs of numbers, destination prefixes, IP network prefixes and account IDs are stored with country and time, for up to 24 hours plus scheduled cleanup delay. Exact sequential comparisons hash neighboring candidate numbers transiently. Identifier-free blocked-check timestamps remain for 30 days plus cleanup delay. No raw number, IP or account ID is persisted. During migration rollout, unavailable counter tables skip velocity checks; static destination controls still apply. The overview shows SMS blocked in the selected range.

Errors: 400 invalid-input for missing/wrong-typed fields, 401 invalid-secret, 403 read-only for demo, 429 rate-limited above 120 requests/minute/site. A malformed phone string returns HTTP 200 with invalid_number rather than an HTTP input error.