NexDeliv

Panduan

Cara kirim email OTP dengan Node.js, PHP/Laravel, dan Python

Kode OTP lewat email dipakai untuk masuk, memverifikasi pendaftaran, dan mengatur ulang kata sandi. Panduan ini menunjukkan cara membuat kode yang aman, mengirimnya dengan satu panggilan API (tanpa mengatur SMTP), dan memverifikasinya. Pilih bahasa Anda di tab tiap langkah: Node.js 18 atau lebih baru, PHP 8 dan Laravel 10 atau lebih baru, atau Python 3.9 atau lebih baru.

Diperbarui 28 Oktober 2026

#Yang Anda butuhkan

  • Akun NexDeliv. Paket Free langsung berjalan, tanpa kartu.
  • Domain pengirim yang terverifikasi. Di dashboard, menu Domain: tambahkan domain, pasang tiga catatan CNAME yang ditampilkan di DNS (hanya DNS, tanpa proxy), lalu tunggu statusnya Verified. Itu bisa memakan beberapa menit sampai sehari.
  • API key. Di menu API key. Key hanya ditampilkan sekali: simpan sebagai variabel lingkungan NEXDELIV_KEY (di Laravel, di .env), jangan di kode atau di repositori.
  • Rahasia acak yang panjang untuk menandatangani kode, disimpan sebagai variabel lingkungan OTP_SECRET.

Hanya alamat pengirim di domain yang sudah terverifikasi yang diterima (misalnya [email protected]). Mencoba alamat lain dijawab 403 from_not_allowed.

Contoh Python memakai library requests dan Flask (pip install requests flask); contoh Node.js tidak memerlukan library selain Express untuk endpoint; PHP biasa memakai ekstensi curl. Laravel punya bagian sendiri di bawah, karena Http, Cache, dan RateLimiter bawaannya mengerjakan banyak hal sekaligus.

#1. Buat dan simpan kode dengan aman

Pakai generator acak kriptografis: crypto.randomInt di Node.js, random_int di PHP, secrets di Python. Jangan pakai Math.random, rand, mt_rand, atau modul random: semuanya bisa ditebak. Simpan kode sebagai HMAC dengan rahasia server (OTP_SECRET), bukan sebagai teks. Kode enam angka hanya punya satu juta kemungkinan, jadi hash saja tidak cukup melindungi bila database bocor; itu sebabnya masa berlaku yang singkat dan batas percobaan sama pentingnya.

import { createHmac, randomInt, timingSafeEqual } from "node:crypto";

// 6 angka dari generator kriptografis (bukan generator biasa, yang bisa ditebak)
export const newCode = () => String(randomInt(0, 1_000_000)).padStart(6, "0");

// Kode disimpan sebagai HMAC dengan rahasia server, tidak pernah sebagai teks
const hashCode = (email, code) =>
  createHmac("sha256", process.env.OTP_SECRET).update(`${email}:${code}`).digest("hex");

// Map hanya untuk contoh; di produksi pakai Redis atau tabel database
const pending = new Map();

export function remember(email, code) {
  pending.set(email, {
    hash: hashCode(email, code),
    expiresAt: Date.now() + 10 * 60_000, // 10 menit
    attempts: 0,
  });
}

export function check(email, code) {
  const entry = pending.get(email);
  if (!entry || entry.expiresAt < Date.now()) return false;
  if (++entry.attempts > 5) { pending.delete(email); return false; } // 5 salah: kode hangus
  const ok = timingSafeEqual(Buffer.from(entry.hash), Buffer.from(hashCode(email, code)));
  if (ok) pending.delete(email); // sekali pakai
  return ok;
}
  • Berlaku 10 menit, lalu hangus.
  • Lima percobaan salah menghanguskan kode.
  • Sekali pakai: kode dihapus begitu benar.
  • Perbandingan memakai fungsi waktu tetap (timingSafeEqual, hash_equals, compare_digest), bukan operator sama dengan.
  • Contoh Node.js dan Python memakai penyimpanan di memori supaya singkat. Di produksi pakai Redis atau tabel database dengan kolom kedaluwarsa, karena memori hilang saat server dimulai ulang dan tidak dibagi antar server. Di PHP biasa, simpan hash, waktu kedaluwarsa, dan jumlah percobaan di tabel database atau cache.

#2. Kirim lewat API

POST/v1/email

Satu permintaan, satu pesan. Jawaban 202 berarti pesan diterima untuk dikirim, dan isinya id pesan. Selain 202 berarti ada masalah, dengan kode error di isi JSON.

export async function sendCode(email, code) {
  const response = await fetch("https://api.nexdeliv.com/v1/email", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.NEXDELIV_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from: "Toko Anda <[email protected]>",
      to: email,
      subject: "Kode masuk Anda",
      text: `Kode masuk Anda: ${code}\nBerlaku 10 menit. Jika bukan Anda, abaikan email ini.`,
      html: `<p>Kode masuk Anda:</p><p><strong>${code}</strong></p><p>Berlaku 10 menit. Jika bukan Anda, abaikan email ini.</p>`,
    }),
    signal: AbortSignal.timeout(10_000), // jangan menunggu tanpa batas
  });
  const body = await response.json().catch(() => ({}));
  if (response.status === 202) return body.id; // id pesan, untuk dicocokkan dengan webhook
  const error = new Error(body.error ?? `http_${response.status}`);
  error.status = response.status;
  error.retryAfter = Number(response.headers.get("retry-after")) || 0;
  throw error;
}

#3. Rangkai jadi dua endpoint

Satu endpoint meminta kode, satu lagi memeriksanya. Contoh dengan Express (Node.js) dan Flask (Python); di PHP biasa langkahnya sama, dan di Laravel lihat bagian berikutnya.

import express from "express";
import { check, newCode, remember } from "./otp.js";
import { sendCode } from "./send.js";

const app = express();
app.use(express.json());

app.post("/otp/request", async (req, res) => {
  const email = String(req.body.email ?? "").trim().toLowerCase();
  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return res.status(400).json({ error: "invalid_email" });
  // Di sini: batasi permintaan kode per alamat dan per IP (misalnya 3 per 10 menit).

  const code = newCode();
  remember(email, code);
  try {
    await sendCode(email, code);
  } catch (error) {
    console.error("kirim kode gagal:", error.message, error.status); // jangan catat kode atau isi email
    return res.status(502).json({ error: "tidak_bisa_kirim" });
  }
  res.status(204).end();
});

app.post("/otp/verify", (req, res) => {
  const email = String(req.body.email ?? "").trim().toLowerCase();
  const ok = check(email, String(req.body.code ?? ""));
  res.status(ok ? 200 : 400).json({ ok });
});

app.listen(3000);

Beri batas pada permintaan kode untuk tiap alamat dan tiap IP, misalnya tiga per sepuluh menit. Tanpa itu, siapa pun bisa memakai pengirim Anda untuk membanjiri satu kotak masuk, dan menghabiskan jatah email bulanan Anda.

#Laravel: service, cache, dan batas permintaan

Laravel sudah punya semua yang dibutuhkan: Http untuk memanggil API, Cache untuk masa berlaku, RateLimiter untuk batas permintaan. Jadi langkah 1 sampai 3 di atas menjadi satu service dan satu controller. Mulai dari konfigurasi:

.env dan config/services.php
# .env
NEXDELIV_KEY=nxd_isi_dengan_key_anda
OTP_SECRET=isi-dengan-teks-acak-yang-panjang

// config/services.php
'nexdeliv' => [
    'key' => env('NEXDELIV_KEY'),
    'from' => 'Toko Anda <[email protected]>',
    'secret' => env('OTP_SECRET'),
],

Lalu service yang membuat, mengirim, dan memeriksa kode:

OtpMailer.php
<?php
// app/Services/OtpMailer.php
namespace App\Services;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;

class OtpMailer
{
    public function request(string $email): void
    {
        $email = strtolower(trim($email));
        $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);

        Cache::put($this->key($email), ['hash' => $this->hash($email, $code), 'attempts' => 0], now()->addMinutes(10));

        $response = Http::withToken(config('services.nexdeliv.key'))
            ->acceptJson()
            ->timeout(10)
            ->post('https://api.nexdeliv.com/v1/email', [
                'from' => config('services.nexdeliv.from'),
                'to' => $email,
                'subject' => 'Kode masuk Anda',
                'text' => "Kode masuk Anda: {$code}\nBerlaku 10 menit. Jika bukan Anda, abaikan email ini.",
            ]);

        if ($response->status() !== 202) {
            Cache::forget($this->key($email)); // kode yang tidak terkirim tidak perlu disimpan
            throw new RuntimeException($response->json('error') ?? 'send_failed');
        }
    }

    public function verify(string $email, string $code): bool
    {
        $email = strtolower(trim($email));
        $entry = Cache::get($this->key($email));
        if (! $entry) {
            return false;
        }
        if ($entry['attempts'] >= 5) {
            Cache::forget($this->key($email)); // 5 salah: kode hangus
            return false;
        }
        $ok = hash_equals($entry['hash'], $this->hash($email, $code));
        if ($ok) {
            Cache::forget($this->key($email)); // sekali pakai
        } else {
            $entry['attempts']++;
            Cache::put($this->key($email), $entry, now()->addMinutes(10));
        }
        return $ok;
    }

    private function key(string $email): string
    {
        return 'otp:' . sha1($email);
    }

    private function hash(string $email, string $code): string
    {
        return hash_hmac('sha256', $email . ':' . $code, config('services.nexdeliv.secret'));
    }
}

Terakhir controller dengan batas tiga permintaan kode per sepuluh menit untuk tiap alamat. Tanpa batas ini, siapa pun bisa memakai pengirim Anda untuk membanjiri satu kotak masuk dan menghabiskan jatah email bulanan Anda:

OtpController.php
<?php
// app/Http/Controllers/OtpController.php
namespace App\Http\Controllers;

use App\Services\OtpMailer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Throwable;

class OtpController extends Controller
{
    public function request(Request $request, OtpMailer $otp)
    {
        $data = $request->validate(['email' => ['required', 'email:rfc']]);
        $email = strtolower($data['email']);

        // 3 kode per 10 menit untuk tiap alamat; tanpa ini, orang lain bisa memakai pengirim Anda untuk membanjiri satu alamat
        $limit = 'otp:' . sha1($email);
        if (RateLimiter::tooManyAttempts($limit, 3)) {
            return response()->json(['error' => 'terlalu_sering'], 429);
        }
        RateLimiter::hit($limit, 600);

        try {
            $otp->request($email);
        } catch (Throwable $e) {
            Log::warning('kirim kode gagal: ' . $e->getMessage()); // jangan catat kode atau isi email
            return response()->json(['error' => 'tidak_bisa_kirim'], 502);
        }
        return response()->noContent();
    }

    public function verify(Request $request, OtpMailer $otp)
    {
        $data = $request->validate(['email' => ['required', 'email'], 'code' => ['required', 'digits:6']]);
        $ok = $otp->verify($data['email'], $data['code']);
        return response()->json(['ok' => $ok], $ok ? 200 : 400);
    }
}

// routes/api.php
// Route::post('/otp/request', [OtpController::class, 'request'])->middleware('throttle:20,1');
// Route::post('/otp/verify', [OtpController::class, 'verify'])->middleware('throttle:20,1');

#4. Tangani error

StatuserrorArtinya, dan yang dilakukan
202-Diterima. Kode sudah di jalan; hasil akhirnya (terkirim atau bounce) datang lewat webhook atau terlihat di menu Riwayat.
403from_not_allowedAlamat pengirim tidak di domain yang sudah terverifikasi di akun Anda. Periksa menu Domain dan alamat di from.
422recipient_suppressedAlamat itu pernah bounce permanen atau mengeluh, jadi tidak dikirimi lagi. Jangan coba lagi: minta pengguna memeriksa alamatnya atau memakai alamat lain.
429rate_limited, busyTerlalu cepat. Tunggu sesuai header Retry-After, coba satu kali lagi, lalu menyerah. Jangan membuat putaran ulang tanpa batas.
429monthly_limitJatah email bulan ini habis. Mengulang tidak membantu; beli paket tambahan atau naik paket di dashboard.
402plan_expiredPaket berakhir. Pengiriman mati sampai Anda membayar di dashboard. (Paket Free tidak berakhir.)
429free_capacityKuota email harian bersama akun Free sudah penuh. Dibuka lagi pukul 00:00 UTC; paket berbayar dan email prabayar tidak terpengaruh.
500, 502internal, send_failedMasalah di sisi kami atau penyedia email. Coba lagi sebentar lagi, satu atau dua kali.

Ulangi hanya 429 (setelah jeda sesuai Retry-After), 500, dan 502, satu atau dua kali. Error 4xx lain akan gagal dengan cara yang sama kalau dikirim ulang apa adanya.

Kepada pengguna cukup katakan bahwa kode tidak bisa dikirim dan mereka boleh mencoba lagi. Jangan tampilkan alasan teknisnya, dan jangan menuliskan kode atau alamat email ke log Anda.

#5. Uji, lalu pantau

  • Menu Kirim uji di dashboard mengirim pesan nyata dengan bidang yang sama, jadi Anda bisa memastikan domain dan tampilan email sebelum menulis kode.
  • Menu Riwayat menunjukkan nasib setiap penerima: Delivered, Bounced, Complaint, Rejected.
  • Webhook memberi tahu server Anda saat pesan terkirim atau bounce, dengan tanda tangan yang bisa diperiksa. Panel Kesehatan pengiriman di bawah Riwayat menunjukkan persentase bounce dan keluhan.

Kalau email tidak sampai di kotak masuk, hampir selalu penyebabnya DNS atau reputasi, bukan kode Anda. Baca panduan SPF, DKIM, dan DMARC.

Baca selanjutnya