Cara Integrasi API OTP WhatsApp di Laravel

Cara Integrasi API OTP WhatsApp di Laravel

Kode OTP sering terlihat sederhana: kirim angka, tunggu End User mengetik ulang, lalu lanjutkan proses. Di backend, detailnya lebih banyak. API key harus aman, nomor harus divalidasi, request ulang tidak boleh membuat spam, dan aplikasi perlu tahu kapan kode salah masih bisa dicoba lagi atau transaksi sudah kedaluwarsa.

Artikel ini membahas cara integrasi API OTP WhatsApp di Laravel memakai HTTP client server-side. Saat knowledge OTP.ID ini dipakai, SDK resmi tersedia untuk Go, PHP, Node.js, dan Python. Belum ada framework binding Laravel yang terdokumentasi sebagai SDK resmi, jadi contoh di bawah memakai HTTP backend Laravel langsung ke API V3 OTP.ID.

Kenapa integrasi OTP WhatsApp harus lewat backend Laravel

API key OTP.ID adalah kredensial Client. Artinya, key itu milik aplikasi atau bisnis yang mengintegrasikan OTP.ID, bukan milik End User. Jangan menaruh API key di JavaScript frontend, aplikasi mobile, atau bundle yang bisa dibongkar dari browser.

Alur yang aman biasanya seperti ini:

  1. End User memasukkan nomor HP di aplikasi.
  2. Frontend memanggil backend Laravel milik Client.
  3. Backend Laravel memanggil API OTP.ID dengan API key dari environment variable.
  4. OTP.ID mengirim Kode OTP via Channel WhatsApp.
  5. End User memasukkan Kode OTP.
  6. Backend Laravel memanggil endpoint verify OTP.ID.
  7. Aplikasi membuat session, menandai nomor verified, atau melanjutkan flow hanya setelah hasil verify benar.

Dengan pola ini, frontend tidak pernah melihat API key. Frontend hanya tahu status yang perlu ditampilkan ke End User.

Alur request dan verify di OTP.ID

API publik OTP.ID memakai V3. Untuk WhatsApp OTP, backend Laravel memanggil POST /v3/request dengan channel: "whatsapp". OTP.ID mengembalikan otp_id. Simpan nilai ini di sisi backend karena otp_id dibutuhkan saat memverifikasi kode.

Alur request dan verify OTP WhatsApp dari backend Laravel

Payload request yang umum dipakai:

{
  "channel": "whatsapp",
  "destination": "628xxxxxxxxxx",
  "brand": "TokoAnda",
  "otp_length": 6,
  "ttl": 300,
  "external_id": "login-12345"
}

Beberapa catatan penting:

Saat End User memasukkan Kode OTP, backend memanggil POST /v3/verify dengan otp_id dan otp.

{
  "otp_id": "OTP20260815ABCD000123",
  "otp": "123456"
}

Kode salah bukan berarti server error. OTP.ID mengembalikan HTTP 200 dengan verified: false dan reason: "mismatch". Kasus seperti expired, terlalu banyak percobaan, atau OTP sudah dipakai akan muncul sebagai error V3 yang perlu ditangani berbeda.

Siapkan konfigurasi Laravel

Simpan konfigurasi OTP.ID di .env:

OTPID_BASE_URL=https://api.otp.id
OTPID_API_KEY=isi_api_key_dari_dashboard
OTPID_BRAND=TokoAnda

Lalu tambahkan ke config/services.php:

return [
    // service lain...

    'otpid' => [
        'base_url' => env('OTPID_BASE_URL', 'https://api.otp.id'),
        'api_key' => env('OTPID_API_KEY'),
        'brand' => env('OTPID_BRAND', 'TokoAnda'),
    ],
];

Pastikan .env tidak masuk git. Kalau API key dicurigai bocor, rotate dari dashboard OTP.ID.

Buat service OTP.ID

Contoh berikut memakai Illuminate\Support\Facades\Http. Buat file app/Services/OtpIdClient.php.

<?php

namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;

class OtpIdClient
{
    public function requestWhatsappOtp(string $destination, string $externalId): array
    {
        return $this->post('/v3/request', [
            'channel' => 'whatsapp',
            'destination' => $destination,
            'brand' => config('services.otpid.brand'),
            'otp_length' => 6,
            'ttl' => 300,
            'external_id' => $externalId,
        ]);
    }

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

    private function post(string $path, array $payload): array
    {
        $baseUrl = rtrim((string) config('services.otpid.base_url'), '/');
        $apiKey = (string) config('services.otpid.api_key');

        try {
            $response = Http::withToken($apiKey)
                ->acceptJson()
                ->asJson()
                ->timeout(15)
                ->post($baseUrl . $path, $payload)
                ->throw();
        } catch (ConnectionException $e) {
            throw new \RuntimeException('Tidak bisa menghubungi OTP.ID.', previous: $e);
        } catch (RequestException $e) {
            $body = $e->response?->json();
            $code = data_get($body, 'error.code', 'OTPID_REQUEST_FAILED');
            $message = data_get($body, 'error.message', 'Request OTP.ID gagal.');

            throw new \RuntimeException($code . ': ' . $message, previous: $e);
        }

        return $response->json();
    }
}

Contoh ini sengaja tipis. Di aplikasi production, biasanya Client menambahkan typed exception sendiri agar controller bisa membedakan INSUFFICIENT_BALANCE, DESTINATION_RATE_LIMITED, dan error lain tanpa membaca string message.

Endpoint Laravel untuk request OTP

Contoh controller sederhana:

<?php

namespace App\Http\Controllers;

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

class OtpController extends Controller
{
    public function request(Request $request, OtpIdClient $otpId): JsonResponse
    {
        $validated = $request->validate([
            'phone' => ['required', 'regex:/^62[0-9]{8,15}$/'],
        ]);

        $externalId = 'login-' . Str::uuid()->toString();

        $result = $otpId->requestWhatsappOtp(
            destination: $validated['phone'],
            externalId: $externalId,
        );

        $data = data_get($result, 'data', []);

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

        // Simpan otp_id sesuai flow aplikasi: database, cache, atau session server-side.
        // Jangan simpan Kode OTP plaintext ke log aplikasi.
        session([
            'otp_id' => data_get($data, 'otp_id'),
            'otp_destination' => $validated['phone'],
        ]);

        return response()->json([
            'message' => 'Kode OTP dikirim.',
            'otp_id' => data_get($data, 'otp_id'),
        ]);
    }
}

Untuk flow login, simpan otp_id di tempat yang bisa dikaitkan dengan percobaan login tersebut. Untuk flow registrasi, kaitkan dengan nomor yang sedang diverifikasi. Jangan membuat session login permanen sebelum verify berhasil.

Endpoint Laravel untuk verify OTP

Setelah End User mengetik kode, backend memanggil OTP.ID.

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

    $otpIdValue = session('otp_id');

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

    $result = $otpId->verifyOtp($otpIdValue, $validated['otp']);
    $data = data_get($result, 'data', []);

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

        // Lanjutkan flow aplikasi: tandai nomor verified atau buat session login.
        return response()->json([
            'message' => 'Nomor berhasil diverifikasi.',
        ]);
    }

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

    return response()->json([
        'message' => 'Kode OTP tidak bisa diverifikasi. Minta kode baru.',
        'reason' => data_get($data, 'reason'),
    ], 422);
}
Verifikasi OTP Laravel yang menjaga API key tetap di backend

Di contoh ini, mismatch dibuat sebagai response 422 ke frontend agar UI bisa menampilkan pesan yang wajar. Di sisi OTP.ID sendiri, mismatch adalah HTTP 200 karena kode salah termasuk alur normal.

Error handling yang perlu dipikirkan sejak awal

Integrasi OTP yang enak dipakai bukan cuma jalur sukses. Siapkan mapping error di Laravel sejak awal.

Kondisi dari OTP.IDArti untuk aplikasiRespons UI yang masuk akal
UNAUTHORIZEDAPI key kosong/salahJangan tampilkan detail ke End User; alert tim teknis.
INSUFFICIENT_BALANCEKredit prepaid tidak cukupMinta tim operasional top up kredit.
DUPLICATE_EXTERNAL_IDexternal_id dipakai ulang dengan parameter berbedaPerbaiki logika retry di backend.
RATE_LIMITEDAPI key melewati 20 request/detikTunda request dan cek lonjakan trafik.
DESTINATION_RATE_LIMITEDNomor yang sama terlalu sering meminta OTPTampilkan cooldown kirim ulang.
CHANNEL_UNAVAILABLEChannel sedang tidak tersediaBeri opsi mencoba lagi atau Channel lain, sesuai desain aplikasi.
status: "failed"Pengiriman gagal dan tidak didebitAplikasi Client menentukan langkah berikutnya.

Jangan menampilkan detail internal ke End User. Pesan seperti "kode salah", "minta kode baru", atau "coba lagi beberapa saat" biasanya lebih aman daripada membocorkan kondisi teknis.

Checklist sebelum production

Sebelum flow OTP Laravel dipakai di production, cek hal-hal ini:

Bacaan terkait

Penutup

Integrasi API OTP WhatsApp di Laravel sebaiknya dibuat sederhana, tapi tidak asal tempel. Letakkan request dan verify di backend, simpan API key di environment variable, simpan otp_id dengan rapi, lalu tangani error yang memang akan terjadi di flow nyata.

Kalau Client ingin mulai mencoba, registrasi bisa dilakukan di https://app.otp.id. Untuk kontrak API terbaru, gunakan dokumentasi di https://docs.otp.id sebagai rujukan saat implementasi.

← SEMUA ARTIKEL