otp-com/sdk-php

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

Maintainers

Package info

github.com/otp-com/sdk-php

pkg:composer/otp-com/sdk-php

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-11 20:30 UTC

This package is auto-updated.

Last update: 2026-08-11 20:33: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.
$sent = $otp->sendOtp(new SendRequest([
    'recipient' => '+14155552671',
    'locale' => 'en',
]));

$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

// 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: the code comes back to the user

Verification is identical on every channel, but WhatsApp delivery has one extra step. When routing picks WhatsApp, the code has not been sent yet and the response carries an action URL:

$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,
    // we reply with the code, and the OTP stays 'pending' until they enter it.
    redirect($sent->getActionUrl());
}

Then call verifyOtp exactly as on SMS. The action URL is null on every other channel. Don't poll for a WhatsApp OTP to approve itself: nothing leaves pending without a verifyOtp call. If the user has no WhatsApp, resend on a channel they do have.

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'

Useful for reconciliation and support tooling. It is not a substitute for verifyOtp, which is what actually approves an OTP.

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

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