PFree Passive OTP

Tài liệu API Free Passive OTP

Passive OTP đảo chiều SMS OTP: người dùng của bạn tự nhắn một tin chứa mã tới số chung, hệ thống xác nhận SĐT gửi đúng là SĐT cần xác thực. Không cần gửi tin đi, không lo tin bị chặn hay đến chậm.

Bắt đầu nhanh

  1. Đăng ký bằng Google, xác thực SĐT và nhận 600.000đ (2.000 lượt) dùng trong 6 tháng.
  2. Lấy API key, syntax và số chung ở dashboard → Tích hợp. Khai báo webhook URL nếu muốn nhận kết quả đẩy về.
  3. Server của bạn gọi POST /otp/requests với SĐT người dùng; app hiển thị “Soạn SYNTAX MÃ gửi số chung”.
  4. Nhận otp.verified qua webhook, hoặc hỏi GET /otp/requests/{id}.

Base URL: http://localhost:3000/api/v1. Mọi response là JSON, thời gian theo ISO 8601 UTC, tiền là số nguyên VNĐ.

Xác thực

Gửi API key trong header Authorization: Bearer pk_live_…. Key chỉ dùng ở server: không đặt trong app di động, trình duyệt hay repo. Mỗi tài khoản có tối đa 3 key đang hoạt động; thu hồi key ở dashboard → Cài đặt.

Hai cách tích hợp

CáchKhi nào dùngTính phí
Có requestBạn biết trước SĐT cần xác thực. Gọi API tạo request, người dùng nhắn SYNTAX MÃ, nhận otp.verified.300đ khi tạo request; tin nhắn khớp request không tính thêm
Chỉ syntaxBạn chỉ cần biết “SĐT này vừa nhắn đúng syntax”. App tự hiển thị nội dung; mỗi tin đến tạo sự kiện message.received.300đ mỗi tin đến hợp lệ

Mã khớp khi đứng đầu phần sau syntax; chữ phía sau mã được bỏ qua (SHOP 482913 cam on vẫn khớp, SHOP X482913 thì không). Hạn được tính theo thời điểm nhà mạng nhận tin.

Tạo request OTP

POST /otp/requests — trừ 300đ khi tạo thành công. Request cũ còn chờ của cùng SĐT chuyển superseded (không hoàn tiền).

TrườngBắt buộcMô tả
phoneCóSĐT di động Việt Nam: 0…, 84… hoặc +84…
codeKhông4–10 chữ/số; bỏ trống thì hệ thống sinh 6 số. Không phân biệt hoa thường
expires_inKhôngGiây, 60–600, mặc định 180
max_attemptsKhôngSố lần nhắn sai tối đa, 1–5, mặc định 3
referenceKhôngMã tham chiếu của bạn (≤ 100 ký tự), trả lại trong webhook
curl -X POST http://localhost:3000/api/v1/otp/requests \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1001" \
  -d '{"phone":"0912345678","reference":"order-1001"}'
201 Created
{
  "id": "otp_01JABCXYZ",
  "status": "pending",
  "phone": "84912345678",
  "sms_to": "<số chung>",
  "sms_content": "SHOPABC 482913",
  "sms_uri": "sms:<số chung>?&body=SHOPABC%20482913",
  "expires_at": "2026-10-07T03:03:00Z",
  "max_attempts": 3,
  "charged": 300,
  "balance": 599700,
  "reference": "order-1001"
}

Trên điện thoại, mở sms_uri để ứng dụng tin nhắn điền sẵn nội dung và số nhận.

Xem request

GET /otp/requests/{id} trả các trường như trên cộng attempts, verified_at, created_at và message (tin đã khớp: {id, content, received_at}).

statusNghĩa
pendingĐang chờ người dùng nhắn
verifiedĐã nhận đúng mã, đúng SĐT, còn hạn
failedNhắn sai quá max_attempts lần
expiredHết hạn mà chưa xác thực
supersededBị thay bởi request mới cùng SĐT
curl http://localhost:3000/api/v1/otp/requests/otp_01JABCXYZ \
  -H "Authorization: Bearer $API_KEY"

Danh sách, tin đến, tài khoản

EndpointMô tả
GET /otp/requests?phone=&status=&from=&to=&cursor=&limit=Danh sách request, mới nhất trước
GET /messages?phone=&from=&to=&cursor=&limit=Tin đến bắt đầu bằng syntax của bạn
GET /messages/{id}Một tin đến: phone, content, syntax, payload, status, received_at, request_id, charged
GET /accountbalance, unit_price, remaining_requests, syntax, shared_number, bonus_expires_at

Phân trang: limit mặc định 20, tối đa 100; dùng next_cursor của trang trước làm cursor. Response: {"data": [...], "next_cursor": "..." | null}.

Webhook

Hệ thống gửi POST JSON tới webhook URL (bắt buộc https://) với các header X-Event-Id, X-Event-Type, X-Timestamp, X-Signature. Trả HTTP 2xx trong 5 giây là thành công; nếu không, hệ thống thử lại sau 10 giây, 1 phút, 5 phút (tổng 4 lần). Webhook lỗi không hoàn tiền; kết quả vẫn lấy được qua API, và bạn có thể gửi lại từ dashboard.

typeKhi nào
otp.verifiedRequest được xác thực
otp.failedNhắn sai quá số lần
otp.expiredRequest hết hạn
message.receivedTin chỉ syntax (cách 2)
pingNút “Gửi thử” trên dashboard
{
  "id": "evt_01JABD...",
  "type": "otp.verified",
  "created_at": "2026-10-07T03:01:12Z",
  "data": {
    "request_id": "otp_01JABCXYZ",
    "reference": "order-1001",
    "phone": "84912345678",
    "status": "verified",
    "attempts": 1,
    "message_id": "msg_01JABD...",
    "content": "SHOPABC 482913",
    "received_at": "2026-10-07T03:01:10Z"
  }
}

message.received có data gồm message_id, phone, syntax, payload, content, received_at; otp.expired có request_id, reference, phone, status, attempts, expires_at; ping có data rỗng. Dùng id (= X-Event-Id) để bỏ qua sự kiện đã xử lý.

Kiểm tra chữ ký

X-Signature: sha256=<hex HMAC-SHA256(webhook_secret, X-Timestamp + "." + body)>. Tính trên body nguyên bản (trước khi parse JSON), so sánh bằng phép so thời gian hằng, và từ chối nếu X-Timestamp lệch quá 5 phút. Webhook secret xem ở dashboard → Tích hợp.

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

// rawBody: chuỗi body nguyên bản (chưa parse JSON)
export function verifyWebhook(rawBody, headers, secret) {
  const ts = headers["x-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // lệch quá 5 phút
  const expected = "sha256=" + createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  const given = String(headers["x-signature"] ?? "");
  return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}

Mã lỗi

Dạng chung: {"error": {"code": "insufficient_balance", "message": "...", "request_id": "req_..."}}. Gửi kèm request_id khi cần hỗ trợ.

HTTPcodeKhi nào
400invalid_phoneSĐT không phải di động Việt Nam
400validation_errorTham số sai kiểu hoặc ngoài giới hạn
401invalid_api_keyThiếu, sai hoặc đã thu hồi
402insufficient_balanceSố dư dưới đơn giá
403tenant_suspendedTài khoản bị khoá
403tenant_migratedTài khoản đã chuyển sang gói trả phí trên hệ thống chính
404not_foundKhông có hoặc không thuộc tài khoản của bạn
409idempotency_conflictCùng Idempotency-Key nhưng khác nội dung (hoặc đang chạy)
429rate_limitedVượt giới hạn; xem header Retry-After
500internal_errorLỗi hệ thống

Giới hạn và idempotency

  • 10 request/giây cho mỗi API key.
  • Mỗi SĐT: 1 request mỗi 30 giây và 20 request mỗi 24 giờ.
  • Header Idempotency-Key (≤ 100 ký tự) cho POST: gửi lại cùng key và cùng body trong 24 giờ sẽ nhận đúng response cũ (header Idempotent-Replayed: true) và không bị trừ tiền lần hai.

Câu hỏi thường gặp

Người dùng có mất phí nhắn tin không?
Có, cước một tin nhắn thường theo gói của họ. Bù lại họ không phải chờ tin OTP đến.
Người dùng nhắn sai mã thì sao?
Request tăng attempts; đạt max_attempts thì failed và bạn nhận otp.failed. Tạo request mới để thử lại.
Tin nhắn đến sau khi request hết hạn?
Không còn request để khớp nên được tính như tin chỉ syntax (300đ) và gửi message.received.
Hết tiền thì sao?
API trả 402, tin chỉ syntax bị chặn (vẫn xem được trong log). Liên hệ để dùng gói trả phí.
Đổi syntax được không?
1 lần mỗi 30 ngày ở dashboard → Cài đặt. Syntax cũ vẫn về tài khoản bạn trong 30 ngày.