Tutorial Integrasi OTP.ID di Laravel: Kirim dan Verifikasi OTP dari Backend

Tutorial Integrasi OTP.ID di Laravel: Kirim dan Verifikasi OTP dari Backend

Banyak aplikasi Laravel butuh satu langkah sederhana sebelum akun End User dianggap valid: pastikan nomor yang dimasukkan memang bisa menerima kode OTP. Di tutorial ini, backend Laravel akan memanggil API OTP.ID untuk mengirim kode, menyimpan otp_id, lalu memverifikasi kode yang diketik End User.

Fokusnya bukan membuat UI lengkap. Fokusnya adalah alur backend yang aman dan cukup jelas untuk dijadikan fondasi register, login OTP, atau verifikasi nomor HP.

Kapan integrasi ini dipakai?

Pakai pola ini saat aplikasi Laravel milik Client perlu memverifikasi nomor End User. Contohnya:

Dalam konteks OTP.ID, **Client** adalah bisnis atau aplikasi yang mengintegrasikan API OTP.ID. **End User** adalah pengguna akhir milik Client yang menerima kode OTP. API key OTP.ID selalu milik Client, jadi jangan pernah menaruhnya di JavaScript browser atau aplikasi mobile.

Prasyarat sebelum mulai

Sebelum menulis kode, siapkan ini:

  1. Aplikasi Laravel yang sudah berjalan.
  2. API key OTP.ID dari dashboard Client.
  3. Kredit/saldo OTP.ID yang cukup untuk mengirim OTP.
  4. Base URL API: `https://api.otp.id`.
  5. Channel yang ingin dipakai. Tutorial ini memakai `whatsapp` sebagai contoh.

OTP.ID tidak menyediakan sandbox terpisah. Uji integrasi dengan nomor milik sendiri atau nomor test internal yang aman. Gunakan placeholder saat menulis dokumentasi atau demo publik, misalnya <END_USER_PHONE_NUMBER> atau 628xxxxxxxxxx.

Alur integrasi singkat

Alur minimalnya seperti ini:

  1. End User mengisi nomor HP di aplikasi Laravel.
  2. Backend Laravel memanggil `POST /v3/request` ke OTP.ID.
  3. OTP.ID mengembalikan `otp_id` dan status pengiriman.
  4. Laravel menyimpan `otp_id` sementara, misalnya di session, cache, atau tabel verifikasi.
  5. End User mengetik kode OTP.
  6. Backend Laravel memanggil `POST /v3/verify` dengan `otp_id` dan kode OTP.
  7. Jika `verified:true`, aplikasi melanjutkan flow register/login/verifikasi.
Alur request dan verify OTP dari backend Laravel ke OTP.ID

Dalam contoh ini, OTP digenerate oleh OTP.ID lewat mode request. Kalau kode OTP harus dibuat oleh sistem Client sendiri, OTP.ID juga punya mode send, tetapi tutorial ini sengaja memakai mode yang lebih umum untuk onboarding awal.

Konfigurasi environment Laravel

Simpan API key di .env, bukan di controller atau repository Git.

OTP_ID_BASE_URL=https://api.otp.id
OTP_ID_API_KEY=<OTP_ID_API_KEY>
OTP_ID_DEFAULT_CHANNEL=whatsapp
OTP_ID_DEFAULT_BRAND=TokoAnda

Tambahkan konfigurasi di config/services.php:

return [
    // konfigurasi service lain...

    'otpid' => [
        'base_url' => env('OTP_ID_BASE_URL', 'https://api.otp.id'),
        'api_key' => env('OTP_ID_API_KEY'),
        'channel' => env('OTP_ID_DEFAULT_CHANNEL', 'whatsapp'),
        'brand' => env('OTP_ID_DEFAULT_BRAND', 'TokoAnda'),
    ],
];

Pastikan .env tidak masuk Git. Kalau memakai deployment platform, simpan nilai OTP_ID_API_KEY di secret manager atau environment variable platform tersebut.

Membuat service client OTP.ID

Buat service kecil agar controller tidak berisi detail HTTP mentah.

Contoh file: app/Services/OtpIdClient.php

<?php

namespace App\Services;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Response;

class OtpIdClient
{
    private string $baseUrl;
    private string $apiKey;

    public function __construct()
    {
        $this->baseUrl = rtrim((string) config('services.otpid.base_url'), '/');
        $this->apiKey = (string) config('services.otpid.api_key');
    }

    private function http(): PendingRequest
    {
        return Http::baseUrl($this->baseUrl)
            ->acceptJson()
            ->asJson()
            ->withToken($this->apiKey)
            ->timeout(15);
    }

    public function requestOtp(string $destination, ?string $externalId = null): Response
    {
        return $this->http()->post('/v3/request', [
            'channel' => config('services.otpid.channel', 'whatsapp'),
            'destination' => $destination,
            'brand' => config('services.otpid.brand', 'TokoAnda'),
            'otp_length' => 6,
            'ttl' => 300,
            'external_id' => $externalId,
        ]);
    }

    public function verifyOtp(string $otpId, string $otp): Response
    {
        return $this->http()->post('/v3/verify', [
            'otp_id' => $otpId,
            'otp' => $otp,
        ]);
    }
}

Catatan penting:

Route dan controller minimal

Tambahkan route demo di routes/web.php atau routes/api.php. Untuk aplikasi nyata, sesuaikan middleware, throttling, dan penyimpanan datanya.

use App\Http\Controllers\OtpVerificationController;
use Illuminate\Support\Facades\Route;

Route::post('/otp/request', [OtpVerificationController::class, 'request']);
Route::post('/otp/verify', [OtpVerificationController::class, 'verify']);

Buat controller:

<?php

namespace App\Http\Controllers;

use App\Services\OtpIdClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class OtpVerificationController extends Controller
{
    public function request(Request $request, OtpIdClient $otpId): JsonResponse
    {
        $validated = $request->validate([
            'phone' => ['required', 'string', 'min:10', 'max:20'],
        ]);

        $phone = preg_replace('/\D+/', '', $validated['phone']);
        $externalId = 'otp-' . Str::uuid()->toString();

        $response = $otpId->requestOtp($phone, $externalId);
        $body = $response->json();

        if (! $response->successful() || data_get($body, 'success') !== true) {
            return response()->json([
                'message' => 'Gagal meminta OTP.',
                'error_code' => data_get($body, 'error.code'),
                'error_message' => data_get($body, 'error.message'),
            ], $response->status());
        }

        if (data_get($body, 'data.status') === 'failed') {
            return response()->json([
                'message' => 'OTP belum berhasil dikirim. Coba lagi atau pilih channel lain.',
            ], 422);
        }

        session([
            'otp_id' => data_get($body, 'data.otp_id'),
            'otp_destination' => $phone,
        ]);

        return response()->json([
            'message' => 'OTP berhasil diminta.',
            'otp_id' => data_get($body, 'data.otp_id'),
            'status' => data_get($body, 'data.status'),
            'expires_at' => data_get($body, 'data.expires_at'),
        ]);
    }

    public function verify(Request $request, OtpIdClient $otpId): JsonResponse
    {
        $validated = $request->validate([
            'otp' => ['required', 'string', 'min:4', 'max:8'],
        ]);

        $otpIdValue = session('otp_id');

        if (! $otpIdValue) {
            return response()->json([
                'message' => 'Sesi OTP tidak ditemukan. Minta kode baru.',
            ], 422);
        }

        $response = $otpId->verifyOtp($otpIdValue, $validated['otp']);
        $body = $response->json();

        if ($response->successful() && data_get($body, 'data.verified') === true) {
            session()->forget(['otp_id', 'otp_destination']);

            return response()->json([
                'message' => 'Nomor berhasil diverifikasi.',
            ]);
        }

        if ($response->successful() && data_get($body, 'data.verified') === false) {
            return response()->json([
                'message' => 'Kode OTP salah.',
                'reason' => data_get($body, 'data.reason'),
            ], 422);
        }

        return response()->json([
            'message' => 'Verifikasi OTP gagal.',
            'error_code' => data_get($body, 'error.code'),
            'error_message' => data_get($body, 'error.message'),
        ], $response->status());
    }
}

Untuk production, jangan hanya mengandalkan session kalau flow berjalan lintas device atau butuh audit. Simpan otp_id, nomor tujuan, status, dan external_id di tabel khusus, lalu scope-kan ke akun atau intent verifikasi yang benar.

Handling response dan error yang wajib dipikirkan

Ada beberapa response yang perlu ditangani secara eksplisit:

| Kondisi | Sumber response | Tindakan di aplikasi Laravel |

|---|---|---|

| Request sukses | success:true, data.status bukan failed | Simpan otp_id, tampilkan input OTP. |

| Pengiriman gagal | HTTP 200 dengan data.status:"failed" | Beri pesan coba lagi; Client bisa memutuskan channel lain. |

| Kode salah | HTTP 200 dengan verified:false, reason:"mismatch" | Tampilkan pesan kode salah tanpa menganggap API error. |

| OTP expired | OTP_EXPIRED | Minta End User request kode baru. |

| Terlalu banyak percobaan | TOO_MANY_ATTEMPTS | Kunci flow sementara dan minta request baru. |

| OTP sudah dipakai | ALREADY_USED | Jangan terima kode yang sama lagi. |

| Kredit tidak cukup | INSUFFICIENT_BALANCE | Tampilkan error internal ke admin/support, bukan detail saldo ke End User. |

| Rate limit | RATE_LIMITED / DESTINATION_RATE_LIMITED | Terapkan cooldown UI dan jangan retry agresif. |

Ilustrasi lapisan keamanan API key dan validasi OTP di backend Laravel

Jangan menampilkan error_message mentah ke End User tanpa kurasi. Pesan teknis berguna untuk developer, tetapi End User cukup butuh instruksi seperti “Kode salah”, “Kode kedaluwarsa”, atau “Tunggu sebentar sebelum meminta kode baru”.

README/demo guide singkat

Struktur demo minimal:

laravel-otp-id-demo/
├── app/Http/Controllers/OtpVerificationController.php
├── app/Services/OtpIdClient.php
├── config/services.php
├── routes/web.php
└── .env.example

Isi .env.example:

OTP_ID_BASE_URL=https://api.otp.id
OTP_ID_API_KEY=<OTP_ID_API_KEY>
OTP_ID_DEFAULT_CHANNEL=whatsapp
OTP_ID_DEFAULT_BRAND=TokoAnda

Cara menjalankan demo lokal:

composer install
cp .env.example .env
php artisan key:generate
php artisan serve

Contoh request lokal:

curl -X POST http://127.0.0.1:8000/otp/request \
  -H "Content-Type: application/json" \
  -d '{"phone":"<END_USER_PHONE_NUMBER>"}'

Lalu verifikasi:

curl -X POST http://127.0.0.1:8000/otp/verify \
  -H "Content-Type: application/json" \
  -d '{"otp":"123456"}'

Ganti 123456 dengan kode yang diterima di nomor test. Jangan menaruh kode OTP asli di log, screenshot, atau dokumentasi publik.

Checklist sebelum production

Sebelum fitur OTP dipakai End User sungguhan, cek ini:

Penutup

Dengan pola ini, Laravel tetap memegang kontrol flow aplikasi, sementara OTP.ID menangani pengiriman dan verifikasi kode OTP lewat API. Bagian yang paling penting untuk production bukan hanya request berhasil, tapi juga cara aplikasi menyimpan otp_id, membatasi resend, dan memberi pesan yang jelas saat verifikasi gagal.

← SEMUA ARTIKEL