# Dokumentasi Skirk Gateway & otp

Base URL: https://skirkdonate.my.id/api/v1

## Mulai cepat

Skirk punya satu API untuk **QRIS** (terima pembayaran) dan **Nomor OTP** (nomor virtual untuk SMS verifikasi), ditambah **webhook** yang memberi tahu server kamu saat ada pembayaran atau kode OTP masuk.

1. Buka menu **API**, buat key, dan isi IP server kamu (wajib). Key asli hanya ditampilkan sekali, jadi simpan.
2. Kirim request ke `https://skirkdonate.my.id/api/v1/...` dengan header `Authorization: Bearer <key>`.
3. (Opsional) isi **Callback URL** pada key untuk menerima [webhook](#webhooks).

**Contoh pertama: buat QRIS Rp25.000 (cURL)**

```bash
curl -X POST https://skirkdonate.my.id/api/v1/qris \
  -H "Authorization: Bearer $SKIRK_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount": 25000, "order_id": "INV-1001", "name": "Budi", "message": "Terima kasih"}'
```

**Contoh pertama: buat QRIS Rp25.000 (PHP)**

```php
<?php
$ch = curl_init('https://skirkdonate.my.id/api/v1/qris');
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SKIRK_KEY'), 'Content-Type: application/json'],
  CURLOPT_POSTFIELDS => json_encode(['amount' => 25000, 'order_id' => 'INV-1001']),
]);
$res = json_decode(curl_exec($ch), true);
echo $res['data']['pay_url'];   // kirim link ini ke pembeli
```

**Contoh pertama: buat QRIS Rp25.000 (Node.js)**

```js
const r = await fetch('https://skirkdonate.my.id/api/v1/qris', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + process.env.SKIRK_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ amount: 25000, order_id: 'INV-1001' }),
});
const j = await r.json();
console.log(j.data.pay_url);   // kirim link ini ke pembeli
```

**Contoh pertama: buat QRIS Rp25.000 (Python)**

```python
import os, requests
r = requests.post('https://skirkdonate.my.id/api/v1/qris',
    headers={'Authorization': 'Bearer ' + os.environ['SKIRK_KEY']},
    json={'amount': 25000, 'order_id': 'INV-1001'}, timeout=15)
print(r.json()['data']['pay_url'])   # kirim link ini ke pembeli
```

## Autentikasi & batasan

| Aturan | Keterangan |
| --- | --- |
| Autentikasi | Header `Authorization: Bearer sk_live_...` (atau `X-API-Key`). Key di URL tidak didukung. Simpan key hanya di server. |
| HTTPS | Wajib. Request non-HTTPS ditolak (400). |
| Allowlist IP | Hanya IP yang didaftarkan pada key yang diterima (IPv4, IPv6, atau CIDR). Selain itu dijawab 403. |
| Rate limit | Default 30 request/menit per key. Header `X-RateLimit-Limit` dan `X-RateLimit-Remaining` ada di tiap respons. Melewati batas dijawab 429 + `Retry-After`. Butuh lebih? Minta admin menaikkannya. |
| Percobaan gagal | IP yang gagal (401/403) 20 kali dalam 10 menit diblokir 10 menit. |
| Batas invoice | Maksimal 50 invoice pending dan 1000 invoice per 24 jam per key. Invoice berlaku 30 menit. |

Semua respons berbentuk JSON: `{"status":"success","data":...}` atau `{"status":"error","message":"..."}`. Daftar kode error ada di [Kode error](#error).

## API QRIS: terima pembayaran

### Buat QRIS

`POST /api/v1/qris` membuat invoice QRIS dinamis. Nominal bayar bisa bertambah kode unik (1-99) supaya pembayaran bisa dicocokkan. Contoh request ada di [Mulai cepat](#mulai).

| Field | Tipe | Keterangan |
| --- | --- | --- |
| amount | integer | Wajib. Nominal dalam Rupiah (default Rp1.000 - Rp10.000.000). |
| order_id | string | Opsional, 1-64 karakter (huruf, angka, `_ . : -`). **Idempoten**: order_id sama + nominal sama mengembalikan invoice yang sama; nominal beda dijawab 409. |
| name | string | Opsional, nama pembayar (maks. 40 karakter). |
| message | string | Opsional, pesan (maks. 200 karakter). |

**Respons 201**

```json
{
  "status": "success",
  "data": {
    "code": "9f8e7d6c5b4a3921",
    "order_id": "INV-1001",
    "amount": 25107,
    "base_amount": 25000,
    "fee": 275,
    "fee_payer": "creator",
    "net": 24832,
    "status": "pending",
    "created_at": "2026-10-09T10:00:00+07:00",
    "expires_at": "2026-10-09T10:30:00+07:00",
    "paid_at": null,
    "pay_url": "https://skirkdonate.my.id/pay/9f8e7d6c5b4a3921",
    "qris": "00020101021226..."
  }
}
```

| Field respons | Arti |
| --- | --- |
| amount | Total yang harus dibayar pembeli (termasuk kode unik, dan biaya admin bila ditanggung pembeli). |
| base_amount | Nominal yang kamu minta. |
| fee / fee_payer | Biaya admin dan penanggungnya: `creator` atau `donor`. |
| net | Yang masuk ke saldo kamu. |
| status | `pending`, `paid`, atau `expired`. |
| pay_url / qris | Halaman bayar siap pakai, dan teks QRIS dinamis (hanya saat pending) bila kamu ingin membuat gambar QR sendiri. |

### Cek status

`GET /api/v1/qris/{code}` mengembalikan bentuk data yang sama. Hanya invoice milik key itu yang bisa dibaca. Untuk invoice penting, gunakan ini sebagai cadangan [webhook](#webhooks).

### Cek saldo

`GET /api/v1/balance` mengembalikan saldo akunmu.

**Respons**

```json
{
  "status": "success",
  "data": {
    "available": 150000,
    "pending_withdrawal": 0,
    "withdrawn": 500000,
    "total_received": 650000
  }
}
```

## API Nomor OTP: pesan & ambil daftar

Pesan nomor sekali pakai untuk menerima SMS verifikasi (WhatsApp, Telegram, dll.) dan ambil daftar negara, layanan, operator, serta **semua pilihan harga (termurah dulu)**. Memakai API key dan saldo yang sama dengan QRIS.

### Alur singkat

1. Ambil `country_id` dan `service_id` dari `/otp/countries` dan `/otp/services`.
2. `GET /otp/products` untuk melihat semua harga, lalu pilih satu baris (yang termurah ada di atas).
3. `POST /otp/orders` dengan data baris itu, `price`-nya, dan `ref` milikmu.
4. Tunggu kode: terima webhook `otp.received`, atau ulangi `GET /otp/orders/{id}` tiap 3-5 detik.
5. Panggil `finish` setelah kode dipakai, atau `cancel` bila tidak ada SMS (saldo kembali).

### Endpoint

| Endpoint | Fungsi |
| --- | --- |
| `GET /otp/countries` | Daftar negara (`id`, `name`, `emoji`, `dial_code`). |
| `GET /otp/services?country_id=` | Daftar layanan/aplikasi untuk negara itu. |
| `GET /otp/operators?country_id=&service_id=` | Operator yang punya stok (`operator_id` null = acak/any). |
| `GET /otp/products?country_id=&service_id=` | **Semua opsi harga**, termurah ke termahal, beserta stok. Opsional: `operator_id=`. |
| `POST /otp/orders` | Pesan satu nomor di harga yang dipilih. |
| `GET /otp/orders` | Daftar pesanan akunmu, terbaru dulu. Opsional: `status=`, `limit=` (maks. 100), `offset=`. |
| `GET /otp/orders/{id}` | Status satu pesanan; kode OTP muncul di sini. |
| `POST /otp/orders/{id}/cancel` | Batalkan sebelum ada SMS; saldo kembali. |
| `POST /otp/orders/{id}/finish` | Tandai selesai setelah kode dipakai. |

**Respons GET /otp/products: semua harga, termurah dulu**

```json
{
  "status": "success",
  "data": [
    {"catalog_product_id": 88, "operator_id": 42,   "operator": "Telkomsel",  "available": 142, "price": 2945},
    {"catalog_product_id": 88, "operator_id": null, "operator": "Acak (Any)", "available": 30,  "price": 3300},
    {"catalog_product_id": 88, "operator_id": 43,   "operator": "Indosat",    "available": 12,  "price": 3600}
  ]
}
```

### Memesan nomor

| Field | Tipe | Keterangan |
| --- | --- | --- |
| country_id | integer | Wajib. Dari `/otp/countries`. |
| service_id | integer | Wajib. Dari `/otp/services`. |
| catalog_product_id | integer | Wajib. Dari baris yang dipilih di `/otp/products`. |
| operator_id | integer / null | Dari baris yang sama (null untuk acak/any). |
| price | integer | Wajib. Harga baris itu (Rupiah). Harus **sama persis** dengan harga di daftar. |
| ref | string | Opsional tetapi **disarankan**, 1-64 karakter (huruf, angka, `_ . : -`). Ref sama = pesanan sama, jadi request yang timeout aman diulang tanpa memesan dua kali. |

**Request POST /otp/orders**

```json
{
  "country_id": 7,
  "service_id": 1,
  "catalog_product_id": 88,
  "operator_id": 42,
  "price": 2945,
  "ref": "ORD-1001"
}
```

> **Catatan:** Harga dicek ulang di server saat memesan. Bila harga atau stok berubah, pesanan ditolak dengan `price_changed`: ambil `/otp/products` lagi lalu pilih ulang. Saldo ditahan sebesar `price`, dan kembali otomatis bila pesanan batal, kedaluwarsa, atau gagal.

### Objek pesanan

**Respons 201**

```json
{
  "status": "success",
  "data": {
    "id": 90210,
    "ref": "ORD-1001",
    "status": "waiting_sms",
    "phone": "+6281234567890",
    "service": "WhatsApp",
    "country": "Indonesia",
    "operator": "Telkomsel",
    "price": 2945,
    "otp_code": null,
    "otp_message": null,
    "sms_revision": 0,
    "created_at": "2026-10-09T10:00:00+07:00",
    "expires_at": "2026-10-09T10:20:00+07:00",
    "expires_in": 1200,
    "can_cancel": true,
    "can_finish": false
  }
}
```

| Field | Arti |
| --- | --- |
| id / ref | ID pesanan (dipakai di endpoint lain) dan referensi yang kamu kirim. |
| status | Lihat tabel status di bawah. |
| phone | Nomor yang dipesan; masukkan di aplikasi tujuan. |
| otp_code / otp_message | Kode terdeteksi dan teks SMS lengkap. Kode bisa `null` sementara `otp_message` terisi (mis. SMS berisi tautan). |
| sms_revision | Naik setiap ada SMS baru. Abaikan data yang `sms_revision`-nya lebih kecil dari yang sudah kamu simpan. |
| price | Harga yang ditagihkan (Rupiah). |
| expires_at / expires_in | Batas waktu sewa (ISO 8601) dan sisa detiknya. |
| can_cancel / can_finish | Aksi yang diizinkan saat ini. |

### Status pesanan

| status | Arti | Langkah berikutnya |
| --- | --- | --- |
| creating | Pesanan sedang dibuat. | Cek lagi sebentar. |
| waiting_sms | Nomor siap, menunggu SMS. | Kirim kode ke nomor itu. Bisa dibatalkan setelah jeda singkat (biasanya sekitar 2 menit). |
| otp_received | SMS masuk. | Ambil `otp_code` / `otp_message`, lalu panggil `finish`. Tidak bisa dibatalkan lagi. |
| completed | Selesai. | Saldo terpotong. |
| canceled, expired, failed | Dibatalkan, kedaluwarsa tanpa SMS, atau nomor tidak terbentuk. | Saldo kembali (atau tidak pernah terpotong). |
| pending_admin | Hasil pemesanan belum pasti (mis. koneksi terputus). Dibalas **202**. | Saldo ditahan. Cek `GET /otp/orders/{id}`; bila lama, hubungi admin dengan menyebut `id`. |

**Contoh alur lengkap: cari harga termurah, pesan, tunggu kode, selesai (cURL)**

```bash
# 1) Lihat semua harga (termurah di atas). country_id & service_id dari /otp/countries dan /otp/services
curl "https://skirkdonate.my.id/api/v1/otp/products?country_id=7&service_id=1" -H "Authorization: Bearer $SKIRK_KEY"

# 2) Pesan nomor di harga yang dipilih (price harus sama persis dengan hasil langkah 1)
curl -X POST https://skirkdonate.my.id/api/v1/otp/orders -H "Authorization: Bearer $SKIRK_KEY" -H "Content-Type: application/json" \
  -d '{"country_id":7,"service_id":1,"catalog_product_id":88,"operator_id":42,"price":2945,"ref":"ORD-1001"}'

# 3) Cek pesanan sampai status otp_received (ulangi tiap 3-5 detik, atau pakai webhook)
curl https://skirkdonate.my.id/api/v1/otp/orders/90210 -H "Authorization: Bearer $SKIRK_KEY"

# 4) Setelah kode dipakai: selesai. Bila tidak ada SMS: batalkan (saldo kembali)
curl -X POST https://skirkdonate.my.id/api/v1/otp/orders/90210/finish -H "Authorization: Bearer $SKIRK_KEY"
curl -X POST https://skirkdonate.my.id/api/v1/otp/orders/90210/cancel -H "Authorization: Bearer $SKIRK_KEY"
```

**Contoh alur lengkap: cari harga termurah, pesan, tunggu kode, selesai (PHP)**

```php
<?php
const BASE = 'https://skirkdonate.my.id/api/v1';
function skirk(string $method, string $path, ?array $body = null): array {
  $ch = curl_init(BASE . $path);
  curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_TIMEOUT => 90,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SKIRK_KEY'), 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body)]);
  $res = json_decode(curl_exec($ch), true) ?: [];
  $res['_http'] = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  return $res;
}

// 1) harga termurah untuk layanan 1 di negara 7
$prices = skirk('GET', '/otp/products?country_id=7&service_id=1')['data'] ?? [];
$p = $prices[0] ?? exit("stok kosong\n");

// 2) pesan (ref = id order di sistemmu; aman diulang bila timeout)
$o = skirk('POST', '/otp/orders', ['country_id' => 7, 'service_id' => 1, 'catalog_product_id' => $p['catalog_product_id'],
        'operator_id' => $p['operator_id'], 'price' => $p['price'], 'ref' => 'ORD-1001']);
if (!in_array($o['_http'], [200, 201, 202], true)) exit('gagal: ' . ($o['message'] ?? $o['_http']) . "\n");
$id = $o['data']['id'];
echo "Nomor: {$o['data']['phone']}\n";

// 3) tunggu kode (maks. ~5 menit)
for ($i = 0; $i < 100; $i++) {
  sleep(3);
  $d = skirk('GET', "/otp/orders/$id")['data'] ?? [];
  if (($d['status'] ?? '') === 'otp_received') { echo 'Kode: ' . ($d['otp_code'] ?? $d['otp_message']) . "\n"; skirk('POST', "/otp/orders/$id/finish"); exit; }
  if (!in_array($d['status'] ?? '', ['waiting_sms', 'creating'], true)) exit("berakhir: {$d['status']}\n");
}
skirk('POST', "/otp/orders/$id/cancel");   // tidak ada SMS: batalkan, saldo kembali
```

**Contoh alur lengkap: cari harga termurah, pesan, tunggu kode, selesai (Node.js 18+)**

```js
const BASE = 'https://skirkdonate.my.id/api/v1';
const H = { Authorization: 'Bearer ' + process.env.SKIRK_KEY, 'Content-Type': 'application/json' };
const call = async (method, path, body) => {
  const r = await fetch(BASE + path, { method, headers: H, body: body ? JSON.stringify(body) : undefined });
  return { http: r.status, ...(await r.json()) };
};
const sleep = ms => new Promise(r => setTimeout(r, ms));

const p = ((await call('GET', '/otp/products?country_id=7&service_id=1')).data || [])[0];   // termurah
if (!p) throw new Error('stok kosong');

const o = await call('POST', '/otp/orders', { country_id: 7, service_id: 1, catalog_product_id: p.catalog_product_id,
  operator_id: p.operator_id, price: p.price, ref: 'ORD-1001' });
if (![200, 201, 202].includes(o.http)) throw new Error(o.message);
console.log('Nomor:', o.data.phone);

for (let i = 0; i < 100; i++) {
  await sleep(3000);
  const d = (await call('GET', '/otp/orders/' + o.data.id)).data;
  if (d.status === 'otp_received') { console.log('Kode:', d.otp_code ?? d.otp_message); await call('POST', '/otp/orders/' + d.id + '/finish'); break; }
  if (!['waiting_sms', 'creating'].includes(d.status)) { console.log('berakhir:', d.status); break; }
}
```

**Contoh alur lengkap: cari harga termurah, pesan, tunggu kode, selesai (Python)**

```python
import os, time, requests
BASE = 'https://skirkdonate.my.id/api/v1'
S = requests.Session(); S.headers['Authorization'] = 'Bearer ' + os.environ['SKIRK_KEY']

prices = S.get(BASE + '/otp/products', params={'country_id': 7, 'service_id': 1}, timeout=30).json().get('data', [])
if not prices: raise SystemExit('stok kosong')
p = prices[0]                                          # termurah

r = S.post(BASE + '/otp/orders', timeout=90, json={'country_id': 7, 'service_id': 1, 'catalog_product_id': p['catalog_product_id'],
        'operator_id': p['operator_id'], 'price': p['price'], 'ref': 'ORD-1001'})
if r.status_code not in (200, 201, 202): raise SystemExit(r.json().get('message'))
o = r.json()['data']; print('Nomor:', o['phone'])

for _ in range(100):
    time.sleep(3)
    d = S.get(f"{BASE}/otp/orders/{o['id']}", timeout=30).json()['data']
    if d['status'] == 'otp_received':
        print('Kode:', d['otp_code'] or d['otp_message']); S.post(f"{BASE}/otp/orders/{o['id']}/finish", timeout=30); break
    if d['status'] not in ('waiting_sms', 'creating'):
        print('berakhir:', d['status']); break
```

> **Catatan:** Cara paling hemat menunggu kode adalah [webhook](#webhooks) `otp.received`. Polling cukup sebagai cadangan; tetap patuhi rate limit. Pesanan lewat API juga muncul di halaman Nomor OTP dengan saldo yang sama.

## Webhooks: pembayaran & OTP

Isi **Callback URL** pada API key (menu **API**). Skirk lalu mengirim `POST` JSON ke URL itu saat pembayaran lunas atau saat pesanan Nomor OTP-mu berubah. Secret penandatangan (`whsec_...`) ada di kartu key yang sama dan bisa diganti kapan saja.

> **Catatan:** Satu URL menerima **semua** event. Cabangkan berdasarkan field `event` (atau header `X-Skirk-Event`). Callback OTP hanya dikirim untuk pesanan yang dibuat lewat API key itu.

### Event

| event | Kapan dikirim |
| --- | --- |
| payment.paid | Invoice yang dibuat lewat key itu lunas. |
| otp.received | SMS masuk. Dikirim lagi untuk setiap SMS baru (`sms_revision` naik); `otp_code` bisa `null` sementara `otp_message` terisi. |
| otp.expired | Masa sewa habis tanpa SMS. Saldo sudah dikembalikan. |
| otp.canceled | Pesanan berakhir dibatalkan dari sisi sumber nomor. Saldo sudah dikembalikan. |

### Header & payload

| Header | Isi |
| --- | --- |
| X-Skirk-Event | Nama event, mis. `payment.paid`. |
| X-Skirk-Timestamp | Waktu kirim (detik, Unix). |
| X-Skirk-Signature | `sha256=<hex>`: HMAC-SHA256 dari `"<timestamp>.<body mentah>"` dengan secret key-mu. |

**Contoh payload (payment.paid)**

```json
{
  "event": "payment.paid",
  "code": "9f8e7d6c5b4a3921",
  "order_id": "INV-1001",
  "amount": 25107,
  "base_amount": 25000,
  "fee": 275,
  "fee_payer": "creator",
  "net": 24832,
  "status": "paid",
  "donor_name": "Budi",
  "message": "Terima kasih",
  "paid_at": "2026-10-09T10:05:00+07:00"
}
```

**Contoh payload (otp.received)**

```json
{
  "event": "otp.received",
  "id": 90210,
  "ref": "ORD-1001",
  "status": "otp_received",
  "phone": "+6281234567890",
  "service": "WhatsApp",
  "country": "Indonesia",
  "operator": "Telkomsel",
  "price": 2945,
  "otp_code": "123456",
  "otp_message": "Your code is 123456",
  "sms_revision": 1,
  "created_at": "2026-10-09T10:00:00+07:00",
  "expires_at": "2026-10-09T10:20:00+07:00",
  "expires_in": 1090,
  "can_cancel": false,
  "can_finish": true
}
```

Isi `otp.*` sama dengan [objek pesanan](#otp-api) ditambah `event`; `expires_in` adalah snapshot saat event terjadi. Untuk status paling baru, panggil `GET /otp/orders/{id}`.

### Verifikasi tanda tangan

1. Ambil **body mentah** (jangan di-decode/di-format ulang dulu).
2. Hitung `HMAC-SHA256(timestamp + "." + body, secret)` dan bandingkan dengan header `X-Skirk-Signature` secara constant-time.
3. Tolak bila timestamp lebih dari 5 menit dari waktu sekarang.
4. Balas status `2xx` secepatnya; proses berat jalankan di belakang.

**Contoh penerima webhook (PHP)**

```php
<?php
$secret = 'CALLBACK_SECRET_KAMU';                      // whsec_... dari menu API
$body   = file_get_contents('php://input');            // body MENTAH, jangan di-decode dulu
$ts     = $_SERVER['HTTP_X_SKIRK_TIMESTAMP'] ?? '';
$sig    = $_SERVER['HTTP_X_SKIRK_SIGNATURE'] ?? '';
$calc   = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret);

if (!ctype_digit($ts) || abs(time() - (int)$ts) > 300 || !hash_equals($calc, $sig)) {
    http_response_code(401); exit;
}
$data = json_decode($body, true);
switch ($data['event']) {
    case 'payment.paid':  /* tandai order $data['order_id'] lunas */ break;
    case 'otp.received':  /* simpan $data['otp_code'] / $data['otp_message'] untuk pesanan $data['id'] */ break;
    case 'otp.expired':
    case 'otp.canceled':  /* pesanan berakhir tanpa kode, saldo sudah kembali */ break;
}
http_response_code(200); echo 'ok';                     // 2xx = diterima
```

**Contoh penerima webhook (Node.js / Express)**

```js
const crypto = require('crypto');
app.post('/skirk-callback', express.raw({ type: 'application/json' }), (req, res) => {
  const ts = req.get('X-Skirk-Timestamp') || '';
  const sig = req.get('X-Skirk-Signature') || '';
  const raw = req.body.toString('utf8');                // body MENTAH
  const calc = 'sha256=' + crypto.createHmac('sha256', process.env.SKIRK_CALLBACK_SECRET).update(ts + '.' + raw).digest('hex');
  const ok = sig.length === calc.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(calc));
  if (!ok || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);

  const data = JSON.parse(raw);
  if (data.event === 'payment.paid') { /* tandai data.order_id lunas */ }
  else if (data.event === 'otp.received') { /* simpan data.otp_code / data.otp_message untuk data.id */ }
  else { /* otp.expired / otp.canceled: pesanan berakhir, saldo sudah kembali */ }
  res.sendStatus(200);                                   // 2xx = diterima
});
```

**Contoh penerima webhook (Python / Flask)**

```python
import hmac, hashlib, time, json, os
from flask import Flask, request, abort
app = Flask(__name__)

@app.post('/skirk-callback')
def skirk_callback():
    body = request.get_data()                          # body MENTAH
    ts = request.headers.get('X-Skirk-Timestamp', '')
    sig = request.headers.get('X-Skirk-Signature', '')
    calc = 'sha256=' + hmac.new(os.environ['SKIRK_CALLBACK_SECRET'].encode(), ts.encode() + b'.' + body, hashlib.sha256).hexdigest()
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300 or not hmac.compare_digest(calc, sig):
        abort(401)
    data = json.loads(body)
    if data['event'] == 'payment.paid':
        pass   # tandai data['order_id'] lunas
    elif data['event'] == 'otp.received':
        pass   # simpan data['otp_code'] / data['otp_message'] untuk data['id']
    else:
        pass   # otp.expired / otp.canceled: pesanan berakhir, saldo sudah kembali
    return 'ok'                                        # 2xx = diterima
```

### Pengiriman ulang

Bila balasanmu bukan `2xx` (atau koneksi gagal), Skirk mengirim ulang sampai **6 kali percobaan**: segera, lalu setelah 1 menit, 5 menit, 30 menit, 2 jam, dan 6 jam. Setelah itu dianggap gagal. Karena itu, event yang sama bisa tiba lebih dari sekali atau tidak berurutan: jadikan pemrosesan **idempoten** (pakai `order_id` atau `code` untuk pembayaran, `id` + `sms_revision` untuk OTP).

URL harus HTTPS, memakai port 80, 443, 8080, atau 8443, dan tidak boleh mengarah ke alamat IP privat/internal.

**Uji penerimamu dari terminal (hitung tanda tangan dengan openssl)**

```bash
SECRET="whsec_xxxxxxxx"; TS=$(date +%s)
BODY='{"event":"payment.paid","code":"test","order_id":"INV-TEST","amount":25107,"status":"paid"}'
SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')"
curl -X POST https://server-kamu.com/skirk-callback \
  -H "Content-Type: application/json" \
  -H "X-Skirk-Event: payment.paid" -H "X-Skirk-Timestamp: $TS" -H "X-Skirk-Signature: $SIG" \
  -d "$BODY"
```

## Kode error

Error berbentuk `{"status":"error","message":"..."}`. Pada API Nomor OTP ada tambahan `code` yang stabil untuk percabangan logika; pesan `message` bisa berubah.

| HTTP | code | Penyebab |
| --- | --- | --- |
| 400 | - | Bukan HTTPS, atau body bukan JSON. |
| 401 | - | API key tidak valid. |
| 403 | - | Key dicabut / pemilik diblokir, atau IP tidak ada di allowlist (respons memuat `your_ip`). |
| 404 | - | Endpoint, invoice, atau pesanan tidak ditemukan. |
| 409 | - | QRIS: `order_id` sudah dipakai dengan nominal berbeda. OTP: cancel/finish ditolak (belum bisa dibatalkan, SMS sudah masuk, dst.); respons memuat `order` terbaru. |
| 409 | price_changed | OTP: harga atau stok berubah. Ambil `/otp/products` lagi lalu pilih ulang. |
| 409 | no_number | OTP: nomor tidak tersedia saat ini; saldo tidak terpotong. |
| 409 | ref_conflict | OTP: `ref` sudah dipakai untuk produk lain. Pakai ref baru. |
| 402 | insufficient_balance | OTP: saldo tersedia kurang dari `price`. |
| 422 | - | Parameter tidak valid (nominal di luar batas, `order_id`/`ref` salah format, dst.), atau pesanan OTP gagal diproses (saldo tidak terpotong). |
| 424 | source_unavailable | OTP: sumber nomor sedang tidak merespons; coba lagi sebentar. |
| 429 | - | Rate limit, terlalu banyak invoice pending, atau batas harian tercapai. Hormati header `Retry-After`. |
| 429 | max_active | OTP: batas pesanan berjalan per akun tercapai. |
| 503 | - | Layanan Nomor OTP belum diaktifkan admin. |

## Nomor OTP lewat halaman web

Menu **Nomor OTP** menjual nomor sekali pakai untuk menerima SMS verifikasi dengan saldo Skirk-mu. Menu hanya muncul bila admin sudah mengaktifkannya.

1. Pilih **negara**, lalu **layanan/aplikasi** (ada kolom cari), lalu **operator** (atau biarkan "Acak").
2. Semua harga tampil dari yang **termurah**, lengkap dengan operator dan stok. Klik **Beli** pada harga yang kamu mau, lalu konfirmasi.
3. Masukkan nomor yang tampil di aplikasi tujuan dan minta kode verifikasi.
4. Kode OTP muncul otomatis di kartu pesanan dan bisa disalin dengan tombol **Salin**.
5. Setelah kode dipakai, klik **Selesai**. Bila tidak ada SMS, klik **Batalkan & kembalikan saldo**.

> **Catatan:** Harga yang tampil sudah final dan dicek ulang saat kamu menekan Beli. Bila harga atau stok berubah, pembelian ditolak dan kamu memilih ulang, jadi tidak pernah ditagih lebih mahal dari yang kamu lihat. Saldo ditahan saat memesan dan kembali otomatis bila pesanan batal, kedaluwarsa, atau gagal; saldo benar-benar terpakai hanya bila statusnya **Selesai**.

### Arti status

| Status | Arti | Yang bisa kamu lakukan |
| --- | --- | --- |
| Menunggu SMS | Nomor siap, menunggu SMS masuk. | Kirim kode ke nomor itu. Tombol Batalkan aktif setelah jeda singkat (sekitar 2 menit) dan selama belum ada SMS. |
| Kode masuk | SMS sudah diterima. | Salin kodenya, pakai di aplikasi, lalu klik **Selesai**. |
| Selesai | Pesanan tuntas. | Saldo tetap terpotong. |
| Dibatalkan / Kedaluwarsa / Gagal | Tidak ada SMS masuk atau nomor tidak terbentuk. | Saldo kembali otomatis. Pesan ulang bila perlu. |
| Menunggu admin | Hasil pemesanan belum pasti (mis. koneksi terputus). | Saldo ditahan, bukan hilang. Admin akan memutuskan; hubungi admin bila lebih dari beberapa menit. |

### Masalah umum

| Masalah | Solusi |
| --- | --- |
| Kode tidak kunjung masuk | Pastikan nomor dimasukkan lengkap dengan kode negara dan kode sudah diminta di aplikasi tujuan. Bila tetap tidak ada, batalkan (saldo kembali) dan coba operator atau harga lain. |
| Tombol Batalkan tidak aktif | Ada jeda minimum setelah memesan, atau SMS sudah masuk (pesanan tidak bisa dibatalkan lagi). |
| "Saldo tidak cukup" | Saldo tersedia = saldo dikurangi pesanan yang masih berjalan. Isi saldo atau selesaikan/batalkan pesanan lain. |
| "Maksimal ... pesanan berjalan" | Selesaikan atau batalkan pesanan yang sedang berjalan dulu. |
| "Harga/stok sudah berubah" atau daftar kosong | Daftar dimuat ulang otomatis; pilih lagi. Bila kosong, stok habis: coba operator "Acak" atau layanan lain. |
| SMS masuk tetapi kode kosong | Beberapa SMS berisi tautan, bukan angka. Teks SMS lengkap tetap tampil di kartu pesanan. |

Nomor bersifat sekali pakai untuk satu verifikasi; pesan lagi untuk verifikasi berikutnya. Gunakan hanya untuk akun dan keperluan yang sah.

## Notifikasi: lonceng, popup, dan suara

Setelah masuk, ikon **lonceng** di bar atas memberi tahu kejadian penting tanpa perlu membuka halaman tertentu. Angka merah di lonceng = jumlah notifikasi yang belum dibaca.

| Kamu menerima notifikasi saat | Contoh |
| --- | --- |
| Pembayaran QRIS lunas | "Pembayaran masuk Rp24.832 · dari Budi · INV-1001" |
| Kode OTP masuk | "Kode OTP masuk: 123456 · WhatsApp +6281..." |
| Pesanan OTP kedaluwarsa / dibatalkan | Saldo sudah dikembalikan. |
| Penarikan saldo diproses admin | Sudah ditransfer, atau ditolak dan saldo kembali. |
| (Admin) permintaan tarik saldo baru, pesanan OTP butuh keputusan, backup Telegram gagal | Langsung membuka tab yang relevan. |

### Cara kerja

1. **Popup mengambang** muncul di pojok layar saat ada kejadian baru (maks. 3 sekaligus, hilang otomatis). Klik popup untuk langsung ke halamannya.
2. **Suara**: nada pendek berbeda untuk pembayaran, OTP, pengembalian saldo, dan penarikan. Getar singkat juga dicoba di HP. Atur lewat tombol **Suara: nyala/mati** di panel lonceng; pilihan ini tersimpan per perangkat.
3. **Panel lonceng** menampilkan 20 notifikasi terakhir. Menutup panel menandai semuanya dibaca, atau klik **Tandai dibaca**.
4. Judul tab ikut berubah menjadi `(2) Nomor OTP` selama ada notifikasi belum dibaca.

> **Catatan:** Browser menahan suara sampai kamu mengetuk atau menekan tombol sekali di halaman itu. Bila suara belum aktif, panel menampilkan petunjuk "Ketuk layar sekali". Notifikasi hanya tampil selama situs terbuka di tab browser (belum berupa notifikasi sistem saat browser ditutup). Untuk menerima kabar di sisi server kamu, pakai [webhook](#webhooks).

