Search by

otp-com / sdk-php

semiherdogan

PHP client for the otp.com OTP API: send, verify, and resend one-time passwords.

Package info

github.com/otp-com/sdk-php

pkg:composer/otp-com/sdk-php

Statistics

Installs: 11

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.3.0 2026-10-12 14:03 UTC

This package is auto-updated.

Last update: 2026-10-12 14:04:03 UTC


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.

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/sdk dispatch it.
  • Locally: ./update-sdk.sh sdk-php from a checkout of otp-com/sdk.

README.md is listed in .openapi-generator-ignore so it survives regeneration. When the contract changes, update it by hand.

License

MIT