PHP Bulk SMS API Integration for Reliable OTP Delivery
Learn how to integrate Bulk SMS API (e.g., bulkmsg.in) with PHP for instant OTP delivery. Complete code example, error handling, retries, and best practices for user registration and login using cURL and secure OTP storage.
How to reliably integrate Bulk SMS API (e.g., bulkmsg.in) with a PHP application for instant OTP delivery during user registration and login?
Context:
I am using an HTTP-based Bulk SMS API that requires parameters like API key, sender ID, mobile number (with country code), message content, and route type. The goal is to send OTP SMS instantly upon form submission.
Expected Behavior:
- Instant SMS delivery after form submission.
- Clear API response confirming successful delivery.
Actual Behavior:
- API requests execute without PHP errors.
- SMS delivery is sometimes delayed or fails entirely.
- Response status is inconsistent or unclear.
What I Have Tried:
- Verified API credentials, sender ID, and DLT-approved message template.
- Ensured correct mobile number format (including country code).
- Tested API successfully via Postman.
Question: Provide a complete PHP code example for API integration, best practices for handling responses, error checking, retries for delays, and tips for reliable OTP delivery in production.
Use a secure cURL-based POST flow, generate a cryptographically-random OTP, store only a hashed OTP server-side, and send the plaintext OTP to the Bulk SMS API (API key, sender ID, route/DLT template, E.164 mobile) while parsing the provider response to decide success/queue/retry. Add immediate lightweight retries for transient errors, push ambiguous sends to a background retry queue (or fallback provider), and rely on delivery receipts/webhooks for final confirmation to make your php sms api integration reliable for sms otp.
Contents
- PHP Bulk SMS API Integration for OTP (code example)
- Handling API responses, errors and retries (api php)
- OTP generation, secure storage and verification (sms otp)
- Webhooks, delivery receipts and background jobs
- Production checklist: reliability, DLT & provider fallback
- Debugging delayed or failed SMS (what to check first)
- Sources
- Conclusion
PHP Bulk SMS API Integration for OTP (code example)
Below is a compact, realistic PHP example you can adapt to any HTTP-based Bulk SMS API (GET or POST). It demonstrates: secure OTP generation, storing a hashed OTP, sending via cURL with sensible timeouts and SSL checks, parsing common response types, and inserting failed/ambiguous sends into a retry queue table for background processing.
Notes before the code:
- Replace environment variables with your real values (never hardcode secrets).
- Adjust param names to match your provider (param keys vary: authkey/user/password/api_key).
- Prefer POST for security (body instead of query) when provider supports it — see an example integration guide: https://www.bulksms.com/resources/insights/how-to-integrate-php-into-sms-api.htm.
- For provider-specific samples (GET params), see https://www.bulksmsgateway.in/php and provider OTP examples like MSG91: http://world.msg91.com/apidoc/otp/php-sample-code-otp.php.
- For cURL options reference, see the PHP manual: https://www.php.net/manual/en/curl.examples-basic.php
Example: send_otp.php
<?php
// send_otp.php - minimal example (PDO + cURL)
// .env / server vars expected:
// SMS_API_URL, SMS_API_KEY, SMS_SENDER, SMS_ROUTE, OTP_TTL_SECONDS, OTP_SIGNING_SECRET, DB_DSN, DB_USER, DB_PASS
ini_set('display_errors', 0);
date_default_timezone_set('UTC');
$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
function generateOtp(int $digits = 6): string {
$min = (int) pow(10, $digits - 1);
$max = (int) pow(10, $digits) - 1;
return (string) random_int($min, $max);
}
function formatE164(string $raw, string $defaultCountry = ''): string {
// For production, use giggsey/libphonenumber-for-php or Google's libphonenumber.
$raw = preg_replace('/\D+/', '', $raw);
if (strpos($raw, '00') === 0) {
$raw = substr($raw, 2);
}
if ($raw[0] !== '+' && strlen($raw) <= 10 && $defaultCountry) {
// naive: prepend default country (e.g., '91' for India) if short local number
return '+' . $defaultCountry . $raw;
}
return '+' . ltrim($raw, '+');
}
function storeOtp(PDO $pdo, string $mobile, string $otpHash, int $ttl): int {
$stmt = $pdo->prepare('INSERT INTO otps (mobile, otp_hash, expires_at, created_at, attempts, status) VALUES (:mobile, :hash, :exp, NOW(), 0, "pending")');
$expires = (new DateTime())->add(new DateInterval("PT{$ttl}S"))->format('Y-m-d H:i:s');
$stmt->execute([':mobile' => $mobile, ':hash' => $otpHash, ':exp' => $expires]);
return (int) $pdo->lastInsertId();
}
function httpRequest(string $url, array $data = [], string $method = 'POST', array $headers = [], int $connectTimeout = 5, int $timeout = 10): array {
$ch = curl_init();
if (strtoupper($method) === 'GET' && !empty($data)) {
$url .= (strpos($url, '?') === false ? '?' : '&') . http_build_query($data);
}
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $connectTimeout);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
if (strtoupper($method) === 'POST') {
// Choose content type by header; default to application/x-www-form-urlencoded
$isJson = false;
foreach ($headers as $h) {
if (stripos($h, 'application/json') !== false) $isJson = true;
}
$body = $isJson ? json_encode($data) : http_build_query($data);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
if (!$isJson) $headers[] = 'Content-Type: application/x-www-form-urlencoded';
}
if (!empty($headers)) curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$response = curl_exec($ch);
$curlErr = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['ok' => $curlErr === '', 'curl_error' => $curlErr, 'http_code' => $httpCode, 'body' => $response];
}
function interpretProviderResponse(string $body = null, int $httpCode = 0): array {
// Normalize and try JSON
$result = ['status' => 'unknown', 'detail' => $body, 'code' => null];
if ($body === null) {
$result['status'] = 'transient_error';
return $result;
}
$trim = trim($body);
$json = json_decode($trim, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($json)) {
// common patterns
$lowerStatus = null;
foreach (['status', 'message', 'result', 'success'] as $k) {
if (isset($json[$k])) {
$lowerStatus = strtolower((string) $json[$k]);
break;
}
}
if ($lowerStatus !== null) {
if (str_contains($lowerStatus, 'success') || str_contains($lowerStatus, 'sent') || str_contains($lowerStatus, 'ok') || str_contains($lowerStatus, 'queued') || $json['status'] == 200) {
$result['status'] = 'success';
} else {
$result['status'] = 'permanent_error';
}
$result['detail'] = $trim;
return $result;
}
}
// pipe-delimited or numeric codes (many gateways return "1701|Message queued|<id>")
if (strpos($trim, '|') !== false) {
$parts = explode('|', $trim);
$result['code'] = $parts[0] ?? null;
$lower = strtolower($trim);
if (str_contains($lower, 'accept') || str_contains($lower, 'queued') || str_contains($lower, 'sent')) {
$result['status'] = 'success';
} elseif (str_contains($lower, 'invalid') || str_contains($lower, 'error') || str_contains($lower, 'failed')) {
// 4xx-like
$result['status'] = ($httpCode >= 500) ? 'transient_error' : 'permanent_error';
} else {
$result['status'] = 'unknown';
}
return $result;
}
// fallback: inspect http code and keywords
$lower = strtolower($trim);
if ($httpCode >= 500 || str_contains($lower, 'timeout') || str_contains($lower, 'error connecting')) {
$result['status'] = 'transient_error';
} elseif ($httpCode >= 400 || str_contains($lower, 'invalid') || str_contains($lower, 'unauthorised') || str_contains($lower, 'unauthorized')) {
$result['status'] = 'permanent_error';
} elseif (str_contains($lower, 'success') || str_contains($lower, 'sent') || str_contains($lower, 'ok') || str_contains($lower, 'queued')) {
$result['status'] = 'success';
} else {
$result['status'] = 'unknown';
}
return $result;
}
function sendOtpAttempt(PDO $pdo, string $mobileRaw, int $userId = null): array {
$mobile = formatE164($mobileRaw, getenv('DEFAULT_COUNTRY') ?: '');
$otp = generateOtp((int) getenv('OTP_DIGITS') ?: 6);
$secret = getenv('OTP_SIGNING_SECRET') ?: 'change-this-secret';
$otpHash = hash_hmac('sha256', $otp, $secret);
$ttl = (int) getenv('OTP_TTL_SECONDS') ?: 300;
// Store hashed OTP
$otpId = storeOtp($pdo, $mobile, $otpHash, $ttl);
// Build provider params - adapt keys to your Bulk API
$apiUrl = rtrim(getenv('SMS_API_URL'), '/');
$params = [
// provider-specific keys: change to match your provider docs
'api_key' => getenv('SMS_API_KEY'),
'sender' => getenv('SMS_SENDER'),
'mobile' => $mobile,
'message' => "Your verification code is {$otp}. It expires in " . ($ttl/60) . " min.",
'route' => getenv('SMS_ROUTE') ?: 'otp',
// optional DLT fields for India:
'dlt_template_id' => getenv('DLT_TEMPLATE_ID') ?: null,
'dlt_entity_id' => getenv('DLT_ENTITY_ID') ?: null,
];
// Remove nulls
$params = array_filter($params, fn($v) => $v !== null && $v !== '');
// Try a single immediate attempt (short). If transient failure or ambiguous, push to resend queue.
$resp = httpRequest($apiUrl, $params, 'POST', ['Accept: application/json'], 5, 10);
$interp = interpretProviderResponse($resp['body'] ?? null, $resp['http_code'] ?? 0);
// persist send attempt
$stmt = $pdo->prepare('INSERT INTO sms_sends (otp_id, mobile, provider_response, http_code, curl_error, status, attempts, created_at) VALUES (:otp_id, :mobile, :resp, :http, :curl, :status, 1, NOW())');
$stmt->execute([
':otp_id' => $otpId,
':mobile' => $mobile,
':resp' => substr($resp['body'] ?? '', 0, 2000),
':http' => $resp['http_code'] ?? 0,
':curl' => $resp['curl_error'] ?? '',
':status' => $interp['status']
]);
if ($interp['status'] === 'success') {
// mark otp as sent
$pdo->prepare('UPDATE otps SET status = "sent" WHERE id = :id')->execute([':id' => $otpId]);
// DO NOT log the plaintext OTP. Only return success flag.
return ['ok' => true, 'message' => 'OTP sent (provider accepted).'];
}
// transient or unknown -> enqueue for background retry
$pdo->prepare('INSERT INTO sms_queue (otp_id, mobile, params_json, queued_at, attempts) VALUES (:otp_id, :mobile, :params, NOW(), 0)')
->execute([':otp_id' => $otpId, ':mobile' => $mobile, ':params' => json_encode($params)]);
return ['ok' => false, 'message' => 'OTP queued for retry.'];
}
// Usage (example endpoint):
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$mobileInput = $_POST['mobile'] ?? '';
if (!preg_match('/\d{6,15}/', $mobileInput)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid mobile']);
exit;
}
$result = sendOtpAttempt($pdo, $mobileInput);
header('Content-Type: application/json');
echo json_encode($result);
}
Database table examples (MySQL):
CREATE TABLE otps (
id INT AUTO_INCREMENT PRIMARY KEY,
mobile VARCHAR(32) NOT NULL,
otp_hash VARCHAR(255) NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL,
attempts INT DEFAULT 0,
status ENUM('pending','sent','delivered','failed') DEFAULT 'pending'
);
CREATE TABLE sms_sends (
id INT AUTO_INCREMENT PRIMARY KEY,
otp_id INT NULL,
mobile VARCHAR(32),
provider_response TEXT,
http_code INT,
curl_error VARCHAR(255),
status VARCHAR(32),
attempts INT,
created_at DATETIME
);
CREATE TABLE sms_queue (
id INT AUTO_INCREMENT PRIMARY KEY,
otp_id INT,
mobile VARCHAR(32),
params_json TEXT,
queued_at DATETIME,
attempts INT DEFAULT 0
);
Handling API responses, errors and retries in PHP (api php)
Why are responses inconsistent? Because gateways differ: some return JSON, some pipe-delimited strings, some ‘accepted for delivery’ without final status. So you must standardize provider responses into categories:
- success — provider accepted and queued/sent (good but not final).
- transient_error — network timeout, HTTP 5xx, DNS failures, temporary carrier congestion (retry).
- permanent_error — bad credentials, invalid number, template mismatch (don’t retry).
- unknown — ambiguous text or unexpected format (log + queue for manual retry).
Practical guidance:
- Treat HTTP 5xx or curl errors (timeout, connection refused) as transient. Retry with backoff.
- Treat HTTP 4xx like 401/403/400 as permanent (fix config).
- Parse provider message/body for keywords: “queued”, “accepted”, “sent”, “success” → success; “invalid”, “error”, “failed” → permanent fail. The example parser above shows a pragmatic approach.
- For providers that return only an “accepted” status, rely on delivery-report webhooks or secondary status API to confirm delivery (see next section).
Retry strategy:
- Immediate retries: 1–2 very short retries (0.5–2s intervals) for transient curl errors only. Don’t make the user wait for 30 seconds.
- Background retries: if immediate attempts fail or response unknown, insert row into an sms_queue table and process with a worker/cron that retries with exponential backoff (attempts++ and sleep 2^attempt seconds).
- Maximum total retries: 3–5 attempts depending on SLA. After that, escalate: either notify ops, mark OTP failed, or fall back to another provider.
Fallback providers and strategy pattern:
- Implement a provider interface and driver classes; on final failure with primary provider, try the next provider.
- Keep provider health metrics (success rate, avg latency) and use round-robin or priority.
Example of transient detection: curl_error non-empty, or http_code >= 500, or response containing ‘timeout’.
Log everything structured (JSON) — request params, raw response, http code, curl error, timestamp. This helps support traceability when contacting the gateway.
For provider-specific parsing examples see BulkSMS code sample that uses explode to parse pipe-delimited responses: https://www.bulksms.com/resources/insights/how-to-integrate-php-into-sms-api.htm and provider GET example here: https://www.bulksmsgateway.in/php.
OTP generation, secure storage and verification (sms otp)
Security and UX rules:
- Use cryptographically-strong generation: random_int(100000, 999999) for 6-digit OTPs.
- Store only a hash of the OTP (HMAC-SHA256 with a server-side secret) plus expiry and attempt counters.
- OTP length: 4–6 digits common; 6 gives better entropy.
- TTL: 3–5 minutes usually; store expires_at for server-side check.
- Limit verification attempts (e.g., max 3 attempts) and apply exponential backoff or temporary block on abuse.
- Do not log OTP plaintext; never echo OTP back to client or write to logs.
Verification sketch:
- Client submits mobile + code.
- Server loads latest OTP for mobile where status is ‘sent’ and not expired; compare using hash_equals(hash_hmac(…), stored_hash).
- On success: mark OTP as used and return success.
- On failure: increment attempts; if attempts >= max -> mark blocked, inform user to request a new OTP after cooldown.
Example verify pseudocode (server-side):
- SELECT otp_hash, expires_at, attempts FROM otps WHERE mobile = :mobile ORDER BY created_at DESC LIMIT 1
- if now > expires_at => expired
- if attempts >= MAX => reject
- if hash_equals(stored_hash, hash_hmac($submitted, secret)) => success
You can find ready-made OTP libraries and Laravel packages that implement retries/resend and DB-backed OTPs: https://github.com/ferdousulhaque/laravel-otp-validate.
Webhooks, delivery receipts and background jobs
Delivery receipts (DR) are authoritative: many gateways will call your callback endpoint with final status (delivered/failed). Use DRs to update OTP status from “sent” to “delivered” or “failed.”
Webhook handler best practices:
- Require an HMAC or token parameter from provider or check request IPs.
- Acknowledge webhook quickly (HTTP 200) and process body asynchronously if heavy.
- Update sms_sends and otps tables with provider message_id and final status.
Example webhook flow:
- Provider POSTs { message_id, mobile, status, error_code } to /sms/delivery-receipt.php
- Validate signature
- Update SMS record and set otp status to delivered/failed if associated
Background worker / retry processor:
- A CLI script (cron or supervisor) polls sms_queue for attempted < max and next retry time <= now.
- For queue items, call provider again or switch provider.
- Record attempts, last_error, and backoff.
Why background jobs? Because network retries shouldn’t block the user’s registration HTTP response. You can still do one immediate attempt synchronously, then push to background if not confirmed.
Production checklist: reliability, DLT & provider fallback
Operational items to ensure “instant” delivery as often as possible:
- TLS & SSL: Always call HTTPS endpoint with CURLOPT_SSL_VERIFYPEER and CURLOPT_SSL_VERIFYHOST enabled. See https://www.php.net/manual/en/curl.examples-basic.php.
- Environment secrets: keep API key, sender ID and signing secrets in environment variables or a secrets manager.
- E.164 phone formatting: normalize numbers before sending. Use libphonenumber for production.
- DLT / templates (India): ensure the message text exactly matches DLT-approved template and include template/entity IDs where required — non-matching templates are often queued or blocked.
- Sender ID: ensure sender ID is approved and matches templates.
- Provider balance & rate-limits: monitor SMS credit and per-second rate limits; running out of balance causes failures.
- Delivery receipts: enable webhooks/status callbacks to confirm final delivery, rather than relying only on the immediate API response.
- Metrics & alerts: track success rate, average latency, queue depth, and set alerts on rising failure rate.
- Multi-provider strategy: keep at least one fallback provider; use heuristics to choose the fastest provider based on recent health data.
- Logs: keep request/response logs for 7–30 days for troubleshooting (but never log OTP plaintext).
- Legal & compliance: store consent, manage opt-outs, obey local regulations (e.g., CTIA, GDPR, DLT in India).
For a practical guide on integrating PHP with SMS APIs (POST recommended), see https://www.bulksms.com/resources/insights/how-to-integrate-php-into-sms-api.htm and general PHP SMS examples like https://messente.com/blog/most-recent/php-sms-api.
Debugging delayed or failed SMS (what to check first)
If Postman works but your app is inconsistent, check these in order:
- Server environment differences: PHP version, cURL version/libssl, network egress firewall rules, IPv6 vs IPv4 DNS differences.
- Timeouts: Are your cURL timeouts too short or too long? Short timeouts cause errors; long timeouts block threads.
- Provider quotas / throttling: Are you hitting per-second limits? Provider may accept but throttle delivery.
- Template mismatch / DLT rejection: Provider may accept request but carriers reject due to template mismatch (common in India).
- Delivery receipts: Ask provider for message_id on send and check receipt; use that id when contacting support.
- Logs: Compare raw HTTP request/response from working Postman call vs your code. Capture headers, body, and response.
- Network traces: trace route to provider, DNS resolution failures.
- Provider support: supply them with request_id or raw response for investigations.
If delays persist despite a clean integration, add a fallback provider and voice fallback (call-based OTP) for critical paths.
Sources
- PHP cURL examples — official manual: https://www.php.net/manual/en/curl.examples-basic.php
- How to integrate with the SMS API from PHP — BulkSMS.com guide: https://www.bulksms.com/resources/insights/how-to-integrate-php-into-sms-api.htm
- Bulk SMS Gateway - PHP example (GET-based sample): https://www.bulksmsgateway.in/php
- MSG91 OTP PHP sample: http://world.msg91.com/apidoc/otp/php-sample-code-otp.php
- Messente: How to Integrate an SMS API with PHP: https://messente.com/blog/most-recent/php-sms-api
- cURL POST with JSON example: https://phppot.com/php/php-curl-post/
- SMS API integration best practices (security & retries) — ApiX-Drive: https://apix-drive.com/en/blog/other/sms-api-integration-in-php
- Laravel OTP package (example of retry/resend workflows): https://github.com/ferdousulhaque/laravel-otp-validate
Conclusion
To make php sms api integration for sms otp reliable, generate a secure OTP, store only its hash, POST the message with short, safe cURL settings, parse provider replies into clear status categories, and move ambiguous or transient failures into a background retry/fallback flow—while using delivery receipts (webhooks) as the final confirmation. With environment-stored credentials, DLT/template compliance, monitoring, and at least one fallback provider you’ll minimize delays and get clear, actionable responses from your Bulk SMS API.