otp-com / sdk-php
PHP client for the otp.com OTP API: send, verify, and resend one-time passwords.
Requires
- php: ^8.1
- ext-curl: *
- ext-json: *
- ext-mbstring: *
- guzzlehttp/guzzle: ^7.3
- guzzlehttp/psr7: ^1.7 || ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.5
- phpunit/phpunit: ^8.0 || ^9.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
PHP client for the otp.com OTP API: send a one-time password, verify the code the user entered, resend it on another channel.
Requires PHP 8.1+ and Guzzle 7.
- API contract: otp-com/sdk (
openapi.yaml) - Method and model reference:
docs/ - Other languages: Node.js · Go · Python · MCP server
Install
composer require otp-com/sdk-php
Quickstart
Get an API key from the otp.com panel under API Keys. otp_live_… sends for real, otp_test_…
runs in sandbox. Keep it server-side; it is a bearer credential.
<?php use OtpCom\Sdk\Api\OTPApi; use OtpCom\Sdk\Configuration; use OtpCom\Sdk\Model\SendRequest; use OtpCom\Sdk\Model\VerifyRequest; $config = Configuration::getDefaultConfiguration() ->setAccessToken(getenv('OTP_API_KEY')); $otp = new OTPApi(new GuzzleHttp\Client(), $config); // 1. Send. You pass the recipient; your account routing picks the channel. // client_ip = the END USER's IP from your request context, not your server's: // requests without it share a much tighter rate limit. $sent = $otp->sendOtp(new SendRequest([ 'recipient' => '+14155552671', 'locale' => 'en', 'client_ip' => '81.2.69.142', ])); $sent->getOtpId(); // keep this: you verify against it $sent->getChannel(); // 'sms' | 'whatsapp' | 'email' | 'telegram' $sent->getMaskedRecipient(); // '+14****71', safe to show the user $sent->getActionUrl(); // WhatsApp only, see below $sent->getCompletion(); // 'code' | 'inbound', see below // 2. Verify whatever the user typed in. $result = $otp->verifyOtp(new VerifyRequest([ 'otp_id' => $sent->getOtpId(), 'code' => '123456', ])); if ($result->getMatched()) { // The code was correct; $result->getStatus() is 'approved'. }
The code itself is never returned by the API. recipient is a phone number in E.164 or an email
address; which one is valid depends on the channels enabled for your app.
Model constructors take the wire field names (otp_id, not otpId).
Retries that must not double-send
Pass an idempotency key and a repeat of the same call replays the first response instead of sending
a second code. Reusing a key with a different body is a 409.
$otp->sendOtp(new SendRequest(['recipient' => $recipient]), "signup:{$userId}");
WhatsApp: how the OTP completes
Every send, resend and status response carries completion, which tells you how this OTP finishes.
SMS, email and Telegram always complete with code. Which WhatsApp mode applies is configured per
account:
completion |
action URL | What happens |
|---|---|---|
code |
null |
The code was delivered (any channel, or WhatsApp in template mode). The user enters it; you call verifyOtp. |
code |
set | WhatsApp code-back. The user opens the link and sends us the prefilled message; we reply with the code. Then verifyOtp as on SMS. |
inbound |
set | WhatsApp inbound approval. The prefilled message itself approves the OTP; there is no code. Poll getOtpStatus every 3 to 5 seconds until the status leaves pending, or take the otp.approved webhook. |
use OtpCom\Sdk\Model\Completion; $sent = $otp->sendOtp(new SendRequest(['recipient' => $recipient])); if ($sent->getActionUrl() !== null) { // Open it for the user. They send us the prefilled message from their own WhatsApp. redirect($sent->getActionUrl()); } if ($sent->getCompletion() === Completion::INBOUND) { // No code is coming. Wait for that message to approve the OTP. $current = $otp->getOtpStatus($sent->getOtpId()); } else { // A code was (or will be) delivered; verifyOtp with whatever the user typed in. }
Calling verifyOtp on an inbound OTP never matches while it is pending and spends an attempt;
once approved it simply reports the approved OTP. Sandbox OTPs always complete with code; on
WhatsApp they still return a sample action URL, verify with the sandbox code. If the user has no
WhatsApp, resend on a channel they do have: leaving WhatsApp for a code channel sets completion
back to code.
Resending
use OtpCom\Sdk\Model\ResendRequest; // Advance to the next channel in your routing order. $otp->resendOtp(new ResendRequest(['otp_id' => $sent->getOtpId()])); // Or move it onto a specific channel, e.g. the user has no WhatsApp. $otp->resendOtp(new ResendRequest(['otp_id' => $sent->getOtpId(), 'channel' => 'sms']));
A resend before the cooldown elapses is a 429; a channel that isn't enabled for your app or the
recipient is a 409.
Checking status
$current = $otp->getOtpStatus($sent->getOtpId()); $current->getStatus(); // 'pending' | 'approved' | 'failed' | 'expired'
On code OTPs this is for reconciliation and support tooling, not a substitute for verifyOtp,
which is what actually approves them. On inbound OTPs it is how you learn the approval without
the webhook.
Exchanging a mobile verification token
If your app uses one of the mobile SDKs, the device verifies against the client API and receives a
verification_token instead of a result. Exchange it on your backend to learn which recipient was
actually verified; what the app claims about its own verification is not trustworthy.
use OtpCom\Sdk\Api\VerificationsApi; use OtpCom\Sdk\Model\VerificationExchangeRequest; $verifications = new VerificationsApi(new GuzzleHttp\Client(), $config); $verified = $verifications->exchangeVerification(new VerificationExchangeRequest([ 'verification_token' => $tokenFromTheApp, ])); $verified->getRecipient(); // the number or address the device actually proved
Errors
Any non-2xx response throws OtpCom\Sdk\ApiException. The body is always
{"error": {"type", "message", "details"?}}, where type is a stable machine-readable class.
use OtpCom\Sdk\ApiException; try { $otp->sendOtp(new SendRequest(['recipient' => $recipient])); } catch (ApiException $e) { $error = json_decode($e->getResponseBody(), true)['error']; error_log("{$e->getCode()} {$error['type']} {$error['message']}"); throw $e; }
| Status | When |
|---|---|
401 |
Missing or invalid API key, disabled app, or suspended company |
404 |
Unknown otp_id (also returned for another company's OTP, to avoid probing) |
409 |
No enabled channel, channel not enabled, resend not allowed, or idempotency-key conflict |
422 |
Request body failed validation |
429 |
Resend cooldown has not elapsed |
Configuration
$config = Configuration::getDefaultConfiguration() ->setAccessToken(getenv('OTP_API_KEY')) // required ->setHost('https://api.otp.com/api/v1') // default ->setUserAgent('acme-checkout/2.1'); $client = new GuzzleHttp\Client(['timeout' => 10]); $otp = new OTPApi($client, $config);
Pass your own Guzzle client to control timeouts, proxies, retries, and logging middleware.
API reference
| Method | Endpoint | Returns |
|---|---|---|
sendOtp |
POST /otp/send |
OtpResponse |
verifyOtp |
POST /otp/verify |
VerifyResponse |
resendOtp |
POST /otp/resend |
OtpResponse |
getOtpStatus |
GET /otp/{otp_id} |
OtpStatusResponse |
exchangeVerification |
POST /verifications/exchange |
VerificationExchangeResponse |
Regenerating
Everything in this repo except this README is generated from
openapi.yaml by
OpenAPI Generator. Fix the contract, not lib/; a pull request
against generated files will be overwritten by the next regeneration.
- In CI: run the Regenerate from spec workflow, or let
otp-com/sdkdispatch it. - Locally:
./update-sdk.sh sdk-phpfrom a checkout ofotp-com/sdk.
README.md is listed in .openapi-generator-ignore so it survives regeneration. When the contract
changes, update it by hand.
License
MIT