إنتقل إلى المحتوى الرئيسي

SMS OTP provider contract

Sham Bus treats OTP as a security subsystem, not as a general notification. Production identity codes are delivered by SMS through a Verify-style provider. Meta and WhatsApp are not permitted in this path.

The application supports two adapters:

  • OTP_PROVIDER=twilio: Twilio Verify owns code generation, expiry, send limits and verification checks.
  • OTP_PROVIDER=external: a certified vendor or private gateway implements the HTTP contract below.
  • OTP_PROVIDER=disabled: pre-launch fail-closed mode only. It requires PUBLIC_SITE_MODE=coming-soon, a six-digit DEMO_OTP_CODE, and DEMO_OTP_HASH_SECRET; only reserved +963900... demo identities use the local one-time challenge. Every real number receives 503 without provider traffic.

/api/health reports this deliberate pre-launch state as otp.status=disabled instead of an outage, while still naming the provider as disabled. That exception applies only in coming-soon mode. A live deployment with OTP disabled remains degraded and is rejected by deployment preflight.

Telegram verification remains a separate, explicitly selected flow. It is never an automatic fallback after an SMS failure.

External gateway contract

All endpoints must use HTTPS outside localhost. Requests use Authorization: Bearer <OTP_EXTERNAL_API_KEY> and Content-Type: application/json. The send endpoint also receives an Idempotency-Key header when the caller supplies a request identifier.

Create a verification

POST /v1/verifications

{
"destination": "+963912345678",
"channel": "sms",
"purpose": "login",
"locale": "ar"
}

Accepted purposes are login, registration, account_recovery and step_up. A successful response must contain a stable request identifier and one of the accepted states:

{
"requestId": "verification_01J...",
"status": "pending"
}

Accepted send states are accepted, pending and sent. A 2xx response without both a request identifier and an accepted state is treated as an unknown, retryable failure—not as a successful delivery.

Check a verification

POST /v1/verification-checks

{
"destination": "+963912345678",
"code": "123456",
"purpose": "login"
}

The only successful verification state is:

{
"requestId": "verification_01J...",
"status": "approved"
}

denied, expired and invalid are safe user denials. Unknown 2xx states are retryable provider failures. HTTP 400, 404 and 422 are treated as invalid/expired challenges; 408, 429 and 5xx may be retried by the orchestration layer.

Error bodies may use this shape, but provider text must be suitable for end users because the adapter truncates and returns it:

{
"error": {
"code": "DESTINATION_BLOCKED",
"message": "تعذر إرسال رمز التحقق إلى هذا الرقم"
}
}

Production configuration

OTP_PROVIDER=external
OTP_EXTERNAL_PROVIDER_NAME=<legal-vendor-name>
OTP_EXTERNAL_BASE_URL=https://verify.vendor.example
OTP_EXTERNAL_API_KEY=<server-only-key>
OTP_EXTERNAL_SEND_PATH=/v1/verifications
OTP_EXTERNAL_VERIFY_PATH=/v1/verification-checks

The deployment script refuses an unsupported provider, incomplete credentials, a non-HTTPS external URL, or disabled mode on a live public site. NABDA_* values are ignored and produce a migration warning.

Vendor acceptance checklist

Before enabling a vendor in production, record and approve:

  • legal company name, certification reference and current security report;
  • data-processing agreement, data residency, subprocessors and retention;
  • confirmed Syria (+963) routing, sender registration and sanctions/compliance position;
  • per-country pricing, rate limits, delivery receipts and incident SLA;
  • code expiry, maximum verification attempts, fraud controls and number redaction;
  • API-key rotation, IP allowlisting or mTLS support, and an emergency disable procedure;
  • live tests for accepted, delivered, invalid, expired, throttled and provider-outage states.

Do not put API keys, OTP values or full phone numbers in logs, analytics, Slack alerts or issue trackers.

Backward compatibility

Older clients may submit method: "whatsapp". The server converts that request to SMS, returns actualChannel: "sms" and sets fallbackToSms: true. It does not call Meta. New web and mobile clients expose only SMS and Telegram.

Demo and local development

Reserved demo numbers (+963900...) never reach an external provider. Their challenge is stored as an HMAC digest with a five-minute expiry and five-attempt cap. Configure independent production secrets:

DEMO_OTP_HASH_SECRET=<openssl-rand-hex-32>
DEMO_OTP_CODE=<optional-six-digit-code-for-reserved-demo-identities>

USE_DEV_OTP=true is ignored in production. Local development can use DEV_OTP_CODE, but the code is exposed only when the development-only response flag is also enabled.