otp-id / otp-id-php
Official PHP SDK for the OTP.ID V3 API — multi-channel OTP delivery and verification (WhatsApp, SMS, Voice, Email, Missed Call, and WhatsApp Inbound) with prepaid billing.
Requires
- php: >=7.4
- ext-curl: *
- ext-json: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3
- phpstan/phpstan: ^1.12 || ^2
- phpunit/phpunit: ^9.6
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Official PHP SDK for the OTP.ID V3 API — multi-channel OTP delivery and verification (WhatsApp, SMS, Voice, Email, Missed Call, and WhatsApp Inbound) with prepaid billing.
- Zero runtime dependencies — no Composer packages required, only the
curlandjsonPHP extensions. - Faithful to the API: one method per endpoint, no hidden retries.
- PHP 7.4+ compatible — works on Indonesian shared hosting that has not moved to PHP 8 yet.
- Full API reference: https://docs.otp.id
Install
composer require otp-id/otp-id-php
Requires PHP >= 7.4.
Quickstart
<?php require __DIR__ . '/vendor/autoload.php'; use OtpId\Channel; use OtpId\Client; $client = new Client(getenv('OTPID_API_KEY')); // 1. Send an OTP (code generated by OTP.ID, never returned to you). $res = $client->requestOtp([ 'channel' => Channel::WHATSAPP, 'destination' => '6281234567890', 'brand' => 'MyApp', // shown in the OTP message; defaults to your merchant brand_name 'external_id' => 'order-8821', // optional idempotency key ]); echo "otp_id: {$res->otpId} balance: {$res->lastBalance}\n"; // 2. Later, verify what the user typed. $v = $client->verifyOtp($res->otpId, '482913'); if ($v->verified) { echo "verified!\n"; } else { echo "wrong code: {$v->reason}\n"; // "mismatch" — not an error }
Error handling
Every non-success API response is an OtpId\Exception\ApiException:
use OtpId\ErrorCode; use OtpId\Exception\ApiException; try { $res = $client->requestOtp($params); } catch (ApiException $e) { switch ($e->getErrorCode()) { case ErrorCode::INSUFFICIENT_BALANCE: // top up first break; case ErrorCode::DUPLICATE_EXTERNAL_ID: $existing = $e->getDetails()['existing_otp_id'] ?? null; // recover the original transaction break; case ErrorCode::RATE_LIMITED: // slow down (20 requests per second per API key) break; } } catch (\OtpId\Exception\OtpIdException $e) { // ApiException (see above) or ConnectionException — a network-layer // failure (DNS, connection refused, TLS, timeout). }
Invalid SDK usage — an empty API key or an empty otp_id passed to
verifyOtp()/otpStatus() — throws a plain \InvalidArgumentException,
not OtpIdException. Treat it as a programming error to fix in your code,
not something to catch at runtime.
A wrong code on verifyOtp() is not an error: the server answers HTTP
200 with verified: false, and the SDK returns a VerifyResult with
reason: "mismatch". Expired, locked, or already-used transactions do
throw an ApiException (OTP_EXPIRED, TOO_MANY_ATTEMPTS,
ALREADY_USED).
Channels
| Constant | Value | Notes |
|---|---|---|
Channel::WHATSAPP |
whatsapp |
requestOtp() + sendOtp() |
Channel::SMS |
sms |
requestOtp() + sendOtp() |
Channel::VOICE |
voice |
requestOtp() only |
Channel::EMAIL |
email |
requestOtp() + sendOtp() |
Channel::MISSCALL |
misscall |
use requestOtp() for misscall; user completes the caller's number |
Channel::WHATSAPP_INBOUND |
whatsapp_inbound |
requestOtp() only; user messages OTP.ID |
Bring your own code
$res = $client->sendOtp('482913', [ 'channel' => Channel::SMS, 'destination' => '6281234567890', ]);
WhatsApp Inbound (user-initiated)
No code to type: show the user $res->verification->waLink and let
OTP.ID match their incoming message. Never call verifyOtp() for these
transactions — detect completion via the otp.verified webhook or by
polling otpStatus().
$res = $client->requestOtp(['channel' => Channel::WHATSAPP_INBOUND]); echo "Ask the user to tap: {$res->verification->waLink}\n";
Missed Call
The last digits of the calling number are the code. Show
$res->verification->prefix and ask the user to complete the number,
then pass the completed digits to verifyOtp().
Account & top-up
$acc = $client->account(); echo "balance: {$acc->saldo}\n"; $topup = $client->createTopup(100000, 3); // 10000 | 100000 | 500000 | 1000000 | 2000000 echo "pay at: {$topup->paymentUrl}\n";
Webhook: otp.verified
OTP.ID signs every webhook with HMAC-SHA256(secret, timestamp + "." + body).
Webhook::parseVerifiedEvent() checks the signature (constant-time),
rejects timestamps outside ±5 minutes by default (replay protection,
configurable via the $tolerance parameter), and decodes the payload:
<?php require __DIR__ . '/vendor/autoload.php'; use OtpId\Exception\OtpIdException; use OtpId\Webhook; $body = file_get_contents('php://input'); $timestamp = $_SERVER['HTTP_X_OTPID_TIMESTAMP'] ?? ''; $signature = $_SERVER['HTTP_X_OTPID_SIGNATURE'] ?? ''; try { $event = Webhook::parseVerifiedEvent( getenv('OTPID_WEBHOOK_SECRET'), $timestamp, $signature, $body, ); } catch (OtpIdException $e) { http_response_code(401); exit; } echo "verified: {$event->otpId} external_id: {$event->externalId}\n"; http_response_code(200);
Configuration
use OtpId\Client; use OtpId\Transport\TransportInterface; $client = new Client($apiKey, [ 'base_url' => 'https://api.otp.id', // default 'timeout' => 10.0, // seconds; default 30.0 'transport' => $customTransport, // implements TransportInterface; default is CurlTransport ]);
The SDK never retries a request. If you add retries, only retry
requestOtp()/sendOtp() calls that carry an external_id (the server
replays them idempotently) — retrying without one may deliver a second
OTP.
Examples
Runnable, per-channel examples live in examples/:
whatsapp.php, sms.php, voice.php, email.php, misscall.php,
whatsapp_inbound.php, send.php. Each reads OTPID_API_KEY and
OTPID_DESTINATION from the environment and is run with
php examples/<file>.php.
License
MIT — see LICENSE.