Search by

otp-id / otp-id-php

dewa1995

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.

v0.2.0 2026-08-14 07:19 UTC

This package is auto-updated.

Last update: 2026-08-14 07:21:08 UTC


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.

CI

  • Zero runtime dependencies — no Composer packages required, only the curl and json PHP 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.