Lewati ke isi

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:

  1. 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.
  2. Tangani 429 dengan menampilkan sisa waktu tunggu, bukan pesan error mentah. Batasnya 1 kode/60 detik dan 3 kode/15 menit per nomor.
  3. 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:

DELETE FROM otp_codes WHERE created_at < now() - interval '7 days';

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:

  1. 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.
  2. Pilih event yang perlu. Untuk alur OTP biasanya cukup message.sent + message.failed; tambahkan message.incoming kalau Anda ingin balasan pelanggan masuk ke sistem Anda.
  3. 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_PROXIES yang diatur administrator. Tanpa itu, yang dinilai adalah alamat proxy — dan sering kali itu yang jadi sebab 403 + ip_not_allowed saat 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.
  • state kembali bersama token di langkah 6. Pakai untuk mencocokkan permintaan dengan sesi yang memulainya — sama seperti state pada 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-login milik 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_uri tautan 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.