Panduan Integrasi¶
Halaman ini untuk Anda yang ingin memakai OTP Yolked Send dari aplikasi sendiri — misalnya Yolked POS — tanpa perlu membangun infrastruktur WhatsApp sendiri: tidak perlu server WAHA, tidak perlu nomor terdaftar, tidak perlu menjaga koneksi perangkat tetap hidup.
Pembagian tugasnya sederhana dan sengaja dibuat begitu:
| Siapa | Tugasnya |
|---|---|
| Yolked Send | Membuat kode OTP, mengirimkannya lewat WhatsApp, dan mencatat pemakaian kuota |
| Aplikasi Anda | Menyimpan kode itu, membandingkannya saat pengguna memasukkannya, dan memberlakukan masa berlakunya |
Yolked tidak memverifikasi kode Anda
Tidak ada endpoint verifikasi di sisi Yolked. Server membuat kode, dan
POST /send/api/send-otp mengembalikan kode itu kepada Anda. Verifikasi
sepenuhnya ada di aplikasi Anda. Ini disengaja: hanya aplikasi Anda yang tahu
pengguna mana yang sedang login.
Alur yang disarankan¶
1 Pengguna -> Aplikasi Anda minta kode OTP untuk nomor HP-nya
2 Aplikasi Anda -> Database Anda hapus kode lama untuk nomor ini
3 Aplikasi Anda -> Yolked Send POST /send/api/send-otp (+ Idempotency-Key)
4 Yolked Send -> Pengguna WhatsApp berisi kode
5 Yolked Send -> Aplikasi Anda {"status":"sent","otp":"481920"}
6 Aplikasi Anda -> Database Anda simpan hash kode + masa berlaku
7 Aplikasi Anda -> Pengguna "kode sudah dikirim"
8 Pengguna -> Aplikasi Anda masukkan kode yang diterima
9 Aplikasi Anda -> Database Anda bandingkan hash & cek masa berlaku
10 Aplikasi Anda -> Pengguna login berhasil / gagal
Perhatikan langkah 4 dan 5: kode yang sama keluar ke dua arah — ke pengguna
lewat WhatsApp, dan ke backend Anda lewat badan respons JSON. Itu memang begitu
caranya, dan justru karena itu otp tidak boleh pernah diteruskan dari backend
Anda ke aplikasi klien.
Sebelum mulai¶
- [x] Paket akun mencantumkan akses API (Starter atau Bisnis). Paket tanpa
akses API akan ditolak
403+api_access_required— lihat Batas Paket. - [x] API key dan subdomain sudah disalin dari Akun → tab API.
- [x] Perangkat WhatsApp sudah tersambung — statusnya terlihat di dashboard Yolked Send. Kode OTP hanya bisa dikirim kalau perangkatnya tersambung.
- [x] Waktu server Anda akurat (NTP aktif). Selisih jam membuat hitungan masa berlaku kode jadi salah.
- [ ] Opsional: URL webhook sudah disiapkan dan secret-nya tersimpan di lingkungan aplikasi Anda — lihat Langkah 4.
Langkah 1 — Siapkan tabel penyimpanan kode¶
Kode OTP disimpan di database Anda, bukan di Yolked. Tabelnya minimal seperti ini:
CREATE TABLE otp_codes (
id BIGSERIAL PRIMARY KEY,
phone VARCHAR(20) NOT NULL,
code_hash TEXT NOT NULL, -- HMAC kode, bukan kode aslinya
expires_at TIMESTAMP NOT NULL,
attempts INT NOT NULL DEFAULT 0,
consumed_at TIMESTAMP NULL,
message_id BIGINT NULL, -- id pesan Yolked, untuk penelusuran
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX otp_codes_phone_idx ON otp_codes (phone, created_at DESC);
Kenapa code_hash, bukan kode aslinya? Kalau database Anda bocor, kode yang
masih berlaku tidak boleh ikut terbaca. Simpan HMAC dengan rahasia milik server
Anda sendiri, supaya isi tabel pun tidak cukup untuk memalsukan kode.
Langkah 2 — Kirim kode dari backend¶
Tiga hal yang membuat panggilan ini benar dan tidak menyiksa pengguna:
- Kunci idempotency diambil dari id permintaan yang dibuat aplikasi Anda saat pengguna menekan tombol — bukan dibuat baru di dalam fungsi kirim. Kalau panggilan ke Yolked timeout lalu dicoba ulang dengan kunci yang sama, penerima tetap hanya mendapat satu kode dan kuota Anda hanya terpotong sekali.
- Tangani
429dengan menampilkan sisa waktu tunggu, bukan pesan error mentah. Batasnya 1 kode/60 detik dan 3 kode/15 menit per nomor. - Periksa
status, bukan hanya kode HTTP-nya. Balasan bisa berisi"status": "failed"saat WhatsApp belum siap.
Python / Flask¶
import hashlib
import hmac
import os
import requests
from datetime import datetime, timedelta
YOLKED_SEND_BASE = os.environ.get("YOLKED_SEND_BASE", "https://send.yolked.id")
YOLKED_SEND_API_KEY = os.environ["YOLKED_SEND_API_KEY"]
OTP_SECRET = os.environ["OTP_SECRET"].encode() # rahasia server Anda sendiri
OTP_TTL = timedelta(minutes=5)
OTP_MAX_ATTEMPTS = 5
class OtpError(Exception):
"""Kegagalan yang aman ditampilkan ke pengguna."""
def __init__(self, message, code="otp_failed", retry_after=None):
super().__init__(message)
self.message = message
self.code = code
self.retry_after = retry_after
def normalize_phone(raw):
"""62812…, +62812… dan 0812… adalah nomor yang sama."""
digits = "".join(c for c in str(raw or "") if c.isdigit())
if digits.startswith("0"):
digits = "62" + digits[1:]
return digits
def hash_code(phone, code):
return hmac.new(OTP_SECRET, f"{phone}:{code}".encode(), hashlib.sha256).hexdigest()
def send_login_otp(phone, request_id):
"""Kirim OTP dan simpan hashnya. `request_id` dibuat SEKALI oleh pemanggil."""
phone = normalize_phone(phone)
resp = requests.post(
f"{YOLKED_SEND_BASE}/send/api/send-otp",
headers={
"Authorization": f"Bearer {YOLKED_SEND_API_KEY}",
"Content-Type": "application/json",
# Kunci yang stabil untuk satu aksi pengguna, sehingga percobaan
# ulang akibat timeout tidak mengirim kode kedua.
"Idempotency-Key": f"otp-login-{request_id}",
},
json={"phone": phone, "otp_length": 6},
timeout=20,
)
if resp.status_code == 429:
body = resp.json() or {}
raise OtpError(
f"Tunggu {body.get('retry_after', 60)} detik sebelum minta kode lagi.",
code="otp_rate_limited",
retry_after=body.get("retry_after", 60),
)
if resp.status_code >= 400:
raise OtpError("Tidak bisa mengirim kode saat ini.", code="otp_send_failed")
data = resp.json()
if data.get("status") != "sent":
# Nomor perangkat WhatsApp kemungkinan sedang tidak tersambung.
raise OtpError("Kode gagal dikirim. Hubungi admin.", code="otp_undelivered")
code = data["otp"] # hanya hidup di dalam proses ini
otp_code = OtpCode(
phone=phone,
code_hash=hash_code(phone, code),
expires_at=datetime.utcnow() + OTP_TTL,
message_id=data.get("message_id"),
)
db.session.add(otp_code)
db.session.commit()
return data.get("message_id")
Verifikasinya, di route berikutnya:
def verify_login_otp(phone, submitted):
phone = normalize_phone(phone)
row = (OtpCode.query
.filter_by(phone=phone, consumed_at=None)
.order_by(OtpCode.created_at.desc())
.first())
# Satu pesan yang sama untuk "tidak ada kode", "salah" dan "kedaluwarsa",
# supaya penyerang tidak bisa memakai beda pesan untuk menebak keadaan.
if row is None or row.expires_at < datetime.utcnow():
raise OtpError("Kode salah atau sudah kedaluwarsa.", code="otp_invalid")
if row.attempts >= OTP_MAX_ATTEMPTS:
row.consumed_at = datetime.utcnow()
db.session.commit()
raise OtpError("Terlalu banyak percobaan. Minta kode baru.", code="otp_locked")
row.attempts += 1
db.session.commit()
if not hmac.compare_digest(row.code_hash, hash_code(phone, submitted)):
raise OtpError("Kode salah atau sudah kedaluwarsa.", code="otp_invalid")
row.consumed_at = datetime.utcnow() # sekali pakai
db.session.commit()
return True
Node.js / Express¶
import crypto from 'node:crypto';
const YOLKED_SEND_BASE = process.env.YOLKED_SEND_BASE ?? 'https://send.yolked.id';
const YOLKED_SEND_API_KEY = process.env.YOLKED_SEND_API_KEY;
const OTP_SECRET = process.env.OTP_SECRET;
const OTP_TTL_MS = 5 * 60 * 1000;
const OTP_MAX_ATTEMPTS = 5;
export function normalizePhone(raw) {
let digits = String(raw ?? '').replace(/\D/g, '');
if (digits.startsWith('0')) digits = '62' + digits.slice(1);
return digits;
}
function hashCode(phone, code) {
return crypto.createHmac('sha256', OTP_SECRET).update(`${phone}:${code}`).digest('hex');
}
/** `requestId` dibuat sekali oleh pemanggil, bukan di sini. */
export async function sendLoginOtp(phone, requestId) {
const normalized = normalizePhone(phone);
const resp = await fetch(`${YOLKED_SEND_BASE}/send/api/send-otp`, {
method: 'POST',
headers: {
Authorization: `Bearer ${YOLKED_SEND_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `otp-login-${requestId}`,
},
// Batas waktu 20 detik: di atas batas 15 detik milik Yolked, dan lebih pendek
// daripada waktu tunggu pengguna yang wajar.
signal: AbortSignal.timeout(20000),
body: JSON.stringify({ phone: normalized, otp_length: 6 }),
});
if (resp.status === 429) {
const body = await resp.json().catch(() => ({}));
const err = new Error(`Tunggu ${body.retry_after ?? 60} detik lagi.`);
err.code = 'otp_rate_limited';
err.retryAfter = body.retry_after ?? 60;
throw err;
}
if (!resp.ok) {
const err = new Error('Tidak bisa mengirim kode saat ini.');
err.code = 'otp_send_failed';
throw err;
}
const data = await resp.json();
if (data.status !== 'sent') {
const err = new Error('Kode gagal dikirim. Hubungi admin.');
err.code = 'otp_undelivered';
throw err;
}
await pool.query(
`INSERT INTO otp_codes (phone, code_hash, expires_at, message_id)
VALUES ($1, $2, now() + ($3 || ' milliseconds')::interval, $4)`,
[normalized, hashCode(normalized, data.otp), OTP_TTL_MS, data.message_id ?? null],
);
return data.message_id;
}
export async function verifyLoginOtp(phone, submitted) {
const normalized = normalizePhone(phone);
const { rows } = await pool.query(
`SELECT id, code_hash, attempts, expires_at
FROM otp_codes
WHERE phone = $1 AND consumed_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[normalized],
);
const row = rows[0];
const invalid = () => {
const err = new Error('Kode salah atau sudah kedaluwarsa.');
err.code = 'otp_invalid';
return err;
};
if (!row || new Date(row.expires_at) < new Date()) throw invalid();
if (row.attempts >= OTP_MAX_ATTEMPTS) {
await pool.query('UPDATE otp_codes SET consumed_at = now() WHERE id = $1', [row.id]);
const err = new Error('Terlalu banyak percobaan. Minta kode baru.');
err.code = 'otp_locked';
throw err;
}
await pool.query('UPDATE otp_codes SET attempts = attempts + 1 WHERE id = $1', [row.id]);
const expected = Buffer.from(row.code_hash, 'hex');
const actual = Buffer.from(hashCode(normalized, String(submitted)), 'hex');
if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) {
throw invalid();
}
await pool.query('UPDATE otp_codes SET consumed_at = now() WHERE id = $1', [row.id]);
return true;
}
PHP / Laravel¶
app/Services/YolkedOtp.php:
<?php
namespace App\Services;
use App\Models\OtpCode;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class OtpException extends RuntimeException
{
public function __construct(
string $message,
public string $code = 'otp_failed',
public ?int $retryAfter = null,
) {
parent::__construct($message);
}
}
class YolkedOtp
{
private const TTL_MINUTES = 5;
private const MAX_ATTEMPTS = 5;
/** 62812…, +62812… dan 0812… adalah nomor yang sama. */
public static function normalizePhone(?string $raw): string
{
$digits = preg_replace('/\D/', '', (string) $raw);
if (str_starts_with($digits, '0')) {
$digits = '62' . substr($digits, 1);
}
return $digits;
}
private static function hash(string $phone, string $code): string
{
// Rahasia server sendiri, bukan API key Yolked.
return hash_hmac('sha256', "{$phone}:{$code}", config('services.yolked.otp_secret'));
}
/** $requestId dibuat sekali oleh pemanggil, bukan di dalam method ini. */
public static function sendLoginOtp(string $phone, string $requestId): ?int
{
$phone = self::normalizePhone($phone);
$response = Http::withToken(config('services.yolked.api_key'))
->withHeaders(['Idempotency-Key' => "otp-login-{$requestId}"])
->timeout(20)
->post(config('services.yolked.base_url') . '/send/api/send-otp', [
'phone' => $phone,
'otp_length' => 6,
]);
if ($response->status() === 429) {
$retryAfter = (int) $response->json('retry_after', 60);
throw new OtpException(
"Tunggu {$retryAfter} detik sebelum minta kode lagi.",
'otp_rate_limited',
$retryAfter,
);
}
if ($response->failed()) {
throw new OtpException('Tidak bisa mengirim kode saat ini.', 'otp_send_failed');
}
$data = $response->json();
if (($data['status'] ?? null) !== 'sent') {
throw new OtpException('Kode gagal dikirim. Hubungi admin.', 'otp_undelivered');
}
OtpCode::create([
'phone' => $phone,
'code_hash' => self::hash($phone, $data['otp']),
'expires_at' => now()->addMinutes(self::TTL_MINUTES),
'message_id' => $data['message_id'] ?? null,
]);
return $data['message_id'] ?? null;
}
public static function verifyLoginOtp(string $phone, string $submitted): bool
{
$phone = self::normalizePhone($phone);
$row = OtpCode::where('phone', $phone)
->whereNull('consumed_at')
->latest('created_at')
->first();
// Satu pesan yang sama untuk semua kegagalan, supaya penyerang tidak
// bisa memakai beda pesan untuk menebak keadaan kode.
$invalid = fn () => throw new OtpException('Kode salah atau sudah kedaluwarsa.', 'otp_invalid');
if (! $row || $row->expires_at->isPast()) {
$invalid();
}
if ($row->attempts >= self::MAX_ATTEMPTS) {
$row->update(['consumed_at' => now()]);
throw new OtpException('Terlalu banyak percobaan. Minta kode baru.', 'otp_locked');
}
$row->increment('attempts');
if (! hash_equals($row->code_hash, self::hash($phone, $submitted))) {
$invalid();
}
$row->update(['consumed_at' => now()]);
return true;
}
}
Tambahkan kredensialnya di config/services.php dan .env:
'yolked' => [
'base_url' => env('YOLKED_SEND_BASE', 'https://send.yolked.id'),
'api_key' => env('YOLKED_SEND_API_KEY'),
'otp_secret' => env('YOLKED_OTP_SECRET'),
],
Langkah 3 — Rawat tabelnya¶
Kode kedaluwarsa tidak boleh menumpuk. Jadwalkan pembersihan sederhana, misalnya sekali sehari:
Simpan riwayatnya beberapa hari supaya Anda masih bisa menelusuri keluhan pengguna, lalu buang sisanya.
Langkah 4 — Ketahui statusnya lewat webhook (opsional)¶
Kirim OTP itu satu arah: setelah aplikasi Anda memanggil /send/api/send-otp,
pertanyaan berikutnya adalah "pesannya sungguh sampai?". Menanyakan itu dengan
memanggil GET /send/api/history berulang kali bekerja, tapi lambat dan boros.
Cara yang lebih baik adalah meminta Yolked Send memanggil aplikasi Anda saat
statusnya berubah.
Sekali pengaturan, tiga langkah:
- Akun → tab API → Webhook Keluar. Isi URL endpoint di aplikasi Anda —
harus
http(s)ke alamat publik, karena yang menghubungi adalah server Yolked, bukan browser Anda. - Pilih event yang perlu. Untuk alur OTP biasanya cukup
message.sent+message.failed; tambahkanmessage.incomingkalau Anda ingin balasan pelanggan masuk ke sistem Anda. - Salin secret yang ditampilkan. Ia hanya muncul sekali — simpan di variabel lingkungan, bukan di kode.
Endpoint Anda cukup memverifikasi tanda tangannya, menyimpan
X-Send-Delivery sebagai kunci idempotensi, lalu membalas 200:
import hashlib, hmac
@app.post("/hooks/yolked-send")
def yolked_webhook():
raw = request.get_data() # byte mentah, sebelum di-parse
timestamp = request.headers["X-Send-Timestamp"]
expected = "sha256=" + hmac.new(
os.environ["YOLKED_WEBHOOK_SECRET"].encode(),
f"{timestamp}.".encode() + raw, hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, request.headers["X-Send-Signature"]):
abort(401)
payload = json.loads(raw)
if already_processed(payload["delivery_id"]): # percobaan ulang
return "", 200
if payload["event"] == "message.failed":
# Perangkat WhatsApp kemungkinan mati — beri tahu admin, jangan biarkan
# pengguna menunggu kode yang tidak akan pernah datang.
alert_admin(payload["data"]["to"], payload["data"]["error"])
return "", 200
Balas cepat, kerjakan di belakang
Kami menunggu sampai 10 detik sebelum menganggap satu percobaan gagal, dan
mengulang paling banyak tiga kali dengan jeda 2 lalu 8 detik. Kalau endpoint
Anda melakukan pekerjaan berat di dalam handler, percobaan ulang akan
menumpuk. Simpan payload, balas 200, lalu proses.
Detail lengkapnya — daftar event, rumus tanda tangan, arti setiap header, dan kebijakan percobaan ulang — ada di Referensi API → Webhook Keluar.
Langkah 5 — Kunci API key ke alamat server (opsional, disarankan)¶
API key hanya dipakai server Anda, jadi mengikatnya ke IP server itu membuat kunci yang bocor tidak berguna di tempat lain. Aktifkan di Akun → tab API → Pembatasan IP, dan tekan Pakai IP saya supaya alamat yang dicatat adalah yang benar-benar dilihat server Yolked — angka dari situs "what is my IP" bisa berbeda kalau ada proxy di depan Anda.
Dua hal yang perlu diingat:
- Sesi browser tidak pernah dibatasi. Aplikasi Anda kehilangan akses, bukan Anda: buka dashboard, perbaiki daftarnya, integrasi langsung jalan lagi.
- Kalau ada proxy di depan server Anda, alamat asli tetap dikenali selama
proxy itu ada di daftar
SEND_TRUSTED_PROXIESyang diatur administrator. Tanpa itu, yang dinilai adalah alamat proxy — dan sering kali itu yang jadi sebab403+ip_not_allowedsaat pertama kali mencoba.
Masuk lewat tautan email untuk pengguna aplikasi Anda¶
Kalau aplikasi Anda punya pengguna sendiri — kasir, staf gudang, pelanggan — dan
Anda tidak mau membangun alur password atau OTP sendiri, Yolked Send bisa
mengirim tautan masuk sekali pakai ke email mereka lewat
POST /send/api/auth/magic/link. Yang Anda kirim cuma alamat email; yang
dikerjakan Yolked adalah membuat akunnya bila perlu, menerbitkan tautannya, dan
mengirimkannya.
Bedanya dengan OTP yang Anda kirim sendiri: di sini tidak ada kode yang perlu Anda simpan dan bandingkan. Tautannya sudah berupa kredensial, dan halaman Yolked yang memakainya. Yang sampai ke aplikasi Anda adalah token sesi Send — bentuknya sama persis dengan token hasil login, dan bisa dipakai untuk memanggil seluruh API Send atas nama pengguna itu.
Mendaftarkan alamat pengembalian¶
Supaya penerima bisa dikembalikan ke aplikasi Anda (bukan berhenti di dashboard
Send), redirect_uri Anda harus terdaftar lebih dulu oleh administrator di
variabel lingkungan SEND_MAGIC_REDIRECT_URIS pada server Yolked Send, dipisah
koma:
SEND_MAGIC_REDIRECT_URIS=https://pos.toko-anda.com/auth/send/callback,https://app.toko-anda.com/sso/send
Pencocokannya persis sama — termasuk skema dan host, tanpa wildcard. Alamat
yang belum terdaftar dijawab 400 + code: invalid_redirect_uri. Inilah yang
mencegah tautan resmi Yolked dipakai mengalihkan token ke domain orang lain.
Alamat localhost dan 127.0.0.1 selalu diizinkan supaya pengembangan lokal
tidak perlu konfigurasi tambahan.
Alurnya¶
1 Pengguna -> Aplikasi Anda "masuk dengan email saya"
2 Aplikasi Anda -> Yolked Send POST /send/api/auth/magic/link (+ state)
3 Yolked Send -> Aplikasi Anda {"status":"ok","expires_in_minutes":15}
4 Yolked Send -> Pengguna email berisi tautan sekali pakai
5 Pengguna -> Yolked Send buka tautannya, tekan tombol konfirmasi
6 Yolked Send -> Aplikasi Anda redirect_uri?token=...&state=...
7 Aplikasi Anda -> Yolked Send GET /send/api/status (token sebagai sesi)
Dua hal yang perlu diperhatikan dari urutan itu:
- Langkah 3 tidak mengembalikan tautannya. Tautan hanya ada di email. Kalau tautan ikut dikembalikan, API key yang bocor berarti siapa pun bisa masuk ke akun mana pun yang emailnya ditebak.
statekembali bersama token di langkah 6. Pakai untuk mencocokkan permintaan dengan sesi yang memulainya — sama sepertistatepada OAuth.
Menangani tautannya di sisi Anda¶
import requests
YOLKED_SEND_BASE = "https://send.yolked.id"
API_KEY = "<API_KEY_ANDA>"
def kirim_tautan_masuk(email, aplikasi="POS Toko Anda"):
response = requests.post(
f"{YOLKED_SEND_BASE}/send/api/auth/magic/link",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"email": email,
"app": aplikasi,
"redirect_uri": "https://pos.toko-anda.com/auth/send/callback",
"state": "sesi-8f21",
"create_account": True,
},
timeout=15,
)
payload = response.json()
if response.status_code == 429:
# magic_rate_limited / magic_cooldown: tunjukkan sisa waktu tunggu,
# bukan pesan mentah. 3 permintaan / 10 menit, jeda 60 detik.
return {"ok": False, "pesan": "Tunggu sebentar sebelum minta tautan lagi."}
if response.status_code != 200:
return {"ok": False, "pesan": payload.get("error", "Gagal mengirim tautan")}
# status "ok" juga muncul saat alamatnya belum terdaftar dan Anda meminta
# create_account=False — memang begitu, supaya tidak bisa dipakai menebak
# email mana yang sudah terdaftar.
return {"ok": True, "expires_in_minutes": payload["expires_in_minutes"]}
Di sisi penerima, redirect_uri Anda menerima token di query string:
from flask import request
@app.route("/auth/send/callback")
def send_callback():
token = request.args.get("token", "")
if not token:
# Tautan sudah dipakai, sudah kedaluwarsa, atau dibatalkan.
return "Tautan masuk tidak berlaku lagi. Silakan minta yang baru.", 400
sesi = login_pengguna_dengan_token_send(request.args.get("state", ""), token)
return redirect("/dashboard")
Yang tidak perlu Anda lakukan¶
- Jangan simpan tautannya. Anda tidak pernah menerimanya; yang perlu Anda simpan adalah token sesi di langkah 6, dan itu pun cukup di sisi klien.
- Jangan bangun halaman konfirmasi sendiri. Halaman
send/magic-loginmilik Yolked yang memakai tautannya, justru karena pemindai email korporat membuka setiap URL lebih dulu — pemisahan "buka" dari "pakai" ada di sana. - Jangan mencatat token ke log atau sistem pemantauan error. Token itu setara password: siapa pun yang membacanya bisa memakai akun pengguna tersebut.
Kalau akun itu perlu bisa masuk juga ke dashboard Send, biarkan saja — tautannya
berlaku sama untuk keduanya. Kalau tidak, kirim create_account: false supaya
alamat yang belum terdaftar tidak diam-diam dibuatkan akun.
Kalau Anda menyambung lewat tombol "Hubungkan ke Yolked Send"¶
Aplikasi POS/billing bisa menyambung tanpa salin-tempel API key: tombol itu
membawa pemilik warung ke halaman persetujuan Yolked Send, dan sebagai gantinya
aplikasi Anda menerima API key lewat penukaran kode sekali pakai
(grant_type=authorization_code dengan PKCE).
Mulai sekarang Yolked Send mencatat koneksi itu, supaya pemilik akun bisa melihat di dashboard siapa yang memegang kuncinya:
- Akun → tab Akun → kartu Integrasi POS, plus ringkasan di dashboard dan di tab API. Di halaman admin, akun yang tersambung ditandai di daftar klien.
- Yang disimpan hanya 6 karakter pertama API key. Nilai kuncinya tidak pernah disimpan di sisi Send — kalau hilang, harus di-rotate, bukan dibaca.
- Setiap permintaan API yang memakai kunci akun itu mencatat waktu panggil terakhir. Jadi indikatornya bisa membedakan "pernah tersambung dulu" dari "benar-benar sedang jalan".
Dari sisi Anda, dua field ini yang perlu diperhatikan:
| Tulisan di dashboard | Arti untuk Anda |
|---|---|
| aktif | Kunci yang Anda pegang masih berlaku dan ada panggilan dalam 24 jam terakhir. Tidak ada yang perlu dilakukan |
kunci berganti (stale) |
Kunci yang Anda pegang sudah tidak berlaku — biasanya karena pemilik akun menekan rotate key. Setiap panggilan Anda akan dijawab 401. Jalankan ulang alur sambung (tombol yang sama), lalu ganti kunci di penyimpanan Anda |
| tanpa aktivitas | Tersambung, tapi belum ada panggilan dalam 24 jam. Biasanya cuma berarti aplikasi Anda belum sempat mengirim, bukan masalah |
| belum tersambung | Belum pernah ada koneksi yang selesai untuk akun ini |
Endpoint GET /send/api/status ikut melaporkan keadaan ini sebagai objek pos,
jadi integrasi Anda bisa memeriksanya sendiri sebelum mengirim — dan tahu harus
meminta sambung ulang, bukan menebak-nebak kenapa balasannya 401.
Halaman kredensial ada di tab Akun dan tab API
Koneksi WhatsApp dan Integrasi POS ada di Akun → tab Akun; Webhook Keluar dan Pembatasan IP ada di Akun → tab API. Tab "WhatsApp" yang lama sudah tidak ada — tautan lama yang masih memakainya tetap membuka tab Akun.
Praktik keamanan¶
Jangan pernah meneruskan otp ke klien
Field otp pada balasan adalah kode yang sedang dikirim ke pengguna. Kalau
Anda mengembalikannya ke browser atau aplikasi mobile, OTP itu kehilangan
seluruh gunanya: siapa pun yang bisa memanggil endpoint Anda akan menerima
kodenya tanpa pernah memegang telepon korban. Jangan pula menuliskannya ke
log, ke sistem pemantauan error, atau ke respons API publik Anda.
Aturan lain yang layak dipatuhi:
- Sekali pakai. Tandai kode terpakai setelah verifikasi berhasil. Tanpa ini satu kode bisa dipakai berulang selama masa berlakunya.
- Batasi percobaan. Lima kali sudah cukup; lebih dari itu, buang kodenya. Tanpa batas ini, kode 6 digit bisa ditebak dalam hitungan menit.
- Beri masa berlaku pendek. Lima menit adalah kebiasaan yang baik.
- Kosongkan kode lama saat mengirim yang baru, supaya hanya kode terakhir yang berlaku dan pengguna tidak tertukar.
- Bandingkan dengan waktu konstan (
hmac.compare_digest,timingSafeEqual,hash_equals) agar waktu balasan tidak membocorkan kode. - Pakai kunci idempotency yang stabil untuk satu aksi pengguna. Ini yang mencegah pengiriman ganda saat jaringan tersendat.
- Perlakukan token sesi Send seperti password. Baik yang datang dari
redirect_uritautan masuk maupun dari hasil verifikasi OTP: jangan dituliskan ke log, ke sistem pemantauan error, atau ke URL yang bisa dibagikan orang lain. Simpan di sisi klien, bukan di database Anda.
Menangani kode error¶
| Kode | Sebaiknya dilakukan aplikasi Anda |
|---|---|
401 |
API key salah, akun tidak aktif, atau langganan kedaluwarsa — periksa Akun → tab API |
403 + api_access_required |
Paket tidak termasuk akses API — lihat Batas Paket |
403 + ip_not_allowed |
Pembatasan IP aktif dan alamat server Anda tidak ada di daftarnya. Kuncinya sah — jangan ganti kunci, perbaiki daftarnya di dashboard (field ip menunjukkan alamat yang terlihat) |
403 + quota_exhausted |
Kuota habis (hanya untuk pengiriman terjadwal) |
429 + otp_rate_limited |
Tampilkan hitung mundur dari retry_after, jangan menawarkan tombol kirim ulang |
400 + invalid_redirect_uri |
redirect_uri yang Anda kirim belum terdaftar di SEND_MAGIC_REDIRECT_URIS pada server Yolked Send — minta administrator menambahkannya, jangan mengakalinya dengan domain lain |
403 + magic_disabled |
Masuk lewat tautan sedang dimatikan admin. Sediakan jalur cadangan (kode OTP) alih-alih hanya menampilkan error |
429 + magic_rate_limited / magic_cooldown |
Sama seperti OTP: tampilkan hitung mundur, jangan menawarkan tombol minta tautan lagi |
502 + smtp_failed |
Pengiriman emailnya sendiri yang gagal. Tautannya sudah dibatalkan, jadi tidak ada yang menggantung — cukup tawarkan coba lagi |
status: "failed" di body |
Perangkat WhatsApp kemungkinan belum tersambung — arahkan ke admin, dan tawarkan coba lagi karena kode ini tidak memotong kuota |
Webhook yang tidak pernah datang bukan kode error — tidak ada yang dibalas ke aplikasi Anda. Mulai diagnosis dari Akun → tab API: alamat yang tersimpan, delapan pengiriman terakhir beserta kode HTTP-nya, dan tombol Uji Webhook yang melaporkan apa yang dijawab endpoint Anda saat itu juga.
Percobaan ulang yang aman
Aman untuk diulang: 429, 5xx, dan status: "failed". Untuk 5xx
lakukan percobaan ulang dengan kunci idempotency yang sama, supaya
permintaan yang sebenarnya sudah diproses tidak terkirim dua kali.