#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
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:
<?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:
<?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
| Status | error | Artinya, dan yang dilakukan |
|---|---|---|
| 202 | - | Diterima. Kode sudah di jalan; hasil akhirnya (terkirim atau bounce) datang lewat webhook atau terlihat di menu Riwayat. |
| 403 | from_not_allowed | Alamat pengirim tidak di domain yang sudah terverifikasi di akun Anda. Periksa menu Domain dan alamat di from. |
| 422 | recipient_suppressed | Alamat itu pernah bounce permanen atau mengeluh, jadi tidak dikirimi lagi. Jangan coba lagi: minta pengguna memeriksa alamatnya atau memakai alamat lain. |
| 429 | rate_limited, busy | Terlalu cepat. Tunggu sesuai header Retry-After, coba satu kali lagi, lalu menyerah. Jangan membuat putaran ulang tanpa batas. |
| 429 | monthly_limit | Jatah email bulan ini habis. Mengulang tidak membantu; beli paket tambahan atau naik paket di dashboard. |
| 402 | plan_expired | Paket berakhir. Pengiriman mati sampai Anda membayar di dashboard. (Paket Free tidak berakhir.) |
| 429 | free_capacity | Kuota email harian bersama akun Free sudah penuh. Dibuka lagi pukul 00:00 UTC; paket berbayar dan email prabayar tidak terpengaruh. |
| 500, 502 | internal, send_failed | Masalah 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.