# Nomerin Reseller API

Versi 1.0.0. Base URL: `https://api.nomerin.id`. Semua harga dalam IDR (integer, tanpa desimal). Semua waktu ISO 8601 dengan zona waktu.

Format lain: OpenAPI JSON `https://api.nomerin.id/openapi.json`, OpenAPI YAML `https://api.nomerin.id/openapi.yaml`, ringkasan untuk AI `https://api.nomerin.id/llms.txt`, halaman HTML `https://api.nomerin.id/docs`.

## Alur singkat

1. Buat API key di dashboard Nomerin, menu **API Reseller**. Key hanya ditampilkan sekali.
2. `GET /v1/services` lalu `GET /v1/countries?service_id=` lalu `GET /v1/offers?service_id=&country_id=` untuk memilih harga.
3. `POST /v1/orders` dengan `service_id`, `country_id`, `price`, dan header `Idempotency-Key`.
4. Tunggu webhook `order.number` (nomor keluar) dan `order.otp` (OTP masuk), atau polling `GET /v1/orders/{id}` tiap 3-5 detik.
5. Tidak ada OTP? Setelah `cancel_available_at`, `POST /v1/orders/{id}/cancel` untuk refund. Order yang kedaluwarsa tanpa OTP direfund otomatis.

## Autentikasi

Kirim API key di header `Authorization: Bearer nmr_...` pada setiap request. Simpan key di server Anda, jangan di aplikasi klien atau browser. Key bisa dinonaktifkan kapan saja dari dashboard.

## Envelope respons

Sukses: `{"success": true, "data": ..., "meta": {...}}` (`meta` hanya ada bila relevan).

Gagal: `{"success": false, "error": {"code": "...", "message": "...", "details": ...}}`. Cabangkan logika pada `error.code` (stabil), bukan pada `message` (bisa berubah). `details` hanya ada bila relevan.

## Rate limit

Dihitung per API key per 60 detik. Katalog, saldo, dan cek order: 120 request per menit. Buat order: 30 per menit. Cancel: 30 per menit. Setiap respons membawa header `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (detik). Saat terlampaui: HTTP 429 `RATE_LIMIT_EXCEEDED` dengan header `Retry-After`.

Maksimal 10 order aktif (processing atau waiting_otp) per API key pada saat yang sama (`TOO_MANY_ACTIVE_ORDERS`).

## Idempotency

`POST /v1/orders` menerima header `Idempotency-Key` (string unik, maksimal 64 karakter, disimpan 24 jam). Request ulang dengan key dan body yang sama mengembalikan order yang sama (HTTP 200, `meta.idempotent_replay: true`). Key sama dengan body berbeda ditolak `IDEMPOTENCY_KEY_REUSED`. Selalu pakai header ini saat melakukan retry setelah timeout atau error 5xx.

## Siklus order

| Status | Arti |
| --- | --- |
| processing | Order diterima dan sedang dicarikan nomor. Biasanya selesai dalam beberapa detik, paling lama kurang lebih 80 detik. |
| waiting_otp | Nomor sudah keluar (field number terisi). Pakai nomornya lalu tunggu OTP. Boleh dibatalkan setelah cancel_available_at. |
| success | OTP masuk (otp_code terisi). Nomor masih bisa menerima OTP tambahan sampai expires_at, setiap OTP baru memicu webhook order.otp. |
| refunded | Tidak ada OTP sampai expires_at atau order dibatalkan. Saldo sudah dikembalikan penuh. |
| failed | Nomor tidak didapat setelah semua percobaan. Saldo sudah dikembalikan penuh. |

Saldo dipotong saat order dibuat dan dikembalikan penuh otomatis bila order berakhir `refunded` atau `failed`. Satu order hanya untuk satu nomor.

## Objek Order

| Field | Tipe | Keterangan |
| --- | --- | --- |
| `id` | integer | ID order di Nomerin. Pakai ini untuk cek status dan cancel. |
| `external_id` | string\|null | ID dari sistem Anda yang dikirim saat membuat order (maksimal 64 karakter). |
| `status` | string | processing, waiting_otp, success, refunded, atau failed. Lihat bagian Siklus order. |
| `service` | object | Layanan: id, code, name. |
| `country` | object | Negara: id, code (kode telepon negara, misalnya 62), name. |
| `price` | integer | Harga yang dipotong dari saldo (IDR). Sudah final, tidak ada biaya lain. |
| `number` | string\|null | Nomor telepon format internasional (+62...). Terisi saat status waiting_otp. |
| `otp_code` | string\|null | Kode OTP terakhir yang masuk. |
| `otp_codes` | array | Semua kode OTP yang masuk ke nomor ini, urut dari yang paling lama. |
| `otp_received_at` | string\|null | Waktu OTP terakhir masuk (ISO 8601 dengan zona waktu). |
| `created_at` | string | Waktu order dibuat. |
| `expires_at` | string\|null | Batas nomor menerima OTP. Setelah ini order tanpa OTP otomatis refunded. |
| `cancel_available_at` | string\|null | Waktu paling awal order boleh dibatalkan (2 menit setelah nomor keluar). |
| `refunded_at` | string\|null | Waktu saldo dikembalikan (status refunded atau failed). |
| `failure_reason` | string\|null | NUMBER_UNAVAILABLE bila status failed: stok nomor habis di harga itu. |

```json
{
  "id": 184212,
  "external_id": "inv-2026-0001",
  "status": "success",
  "service": {
    "id": 3,
    "code": "whatsapp",
    "name": "WhatsApp"
  },
  "country": {
    "id": 7,
    "code": "62",
    "name": "Indonesia"
  },
  "price": 4500,
  "number": "+628123456789",
  "otp_code": "482913",
  "otp_codes": [
    "482913"
  ],
  "otp_received_at": "2026-10-14T20:31:05+07:00",
  "created_at": "2026-10-14T20:29:40+07:00",
  "expires_at": "2026-10-14T20:49:40+07:00",
  "cancel_available_at": null,
  "refunded_at": null,
  "failure_reason": null
}
```

## Endpoint

### GET /v1/balance

**Cek saldo.** Saldo akun Nomerin pemilik API key, dalam IDR. Saldo diisi lewat deposit di dashboard Nomerin.

Rate limit: 120 request per menit.

Contoh respons 200:

```json
{
  "success": true,
  "data": {
    "balance": 250000,
    "currency": "IDR"
  }
}
```

### GET /v1/services

**Daftar layanan.** Semua layanan (WhatsApp, Telegram, dan lainnya) yang saat ini punya stok. countries adalah jumlah negara yang tersedia. Daftar ini berubah mengikuti stok, ambil ulang secara berkala, cukup tiap beberapa menit.

Rate limit: 120 request per menit.

Contoh respons 200:

```json
{
  "success": true,
  "data": [
    {
      "id": 3,
      "code": "whatsapp",
      "name": "WhatsApp",
      "countries": 42
    },
    {
      "id": 8,
      "code": "telegram",
      "name": "Telegram",
      "countries": 57
    }
  ]
}
```

### GET /v1/countries

**Daftar negara untuk satu layanan.** Negara yang tersedia untuk service_id beserta harga termurah dan termahal (IDR) dan jumlah pilihan harga. low_stock true berarti semua pilihan harga stoknya tipis.

Rate limit: 120 request per menit.

Query parameter:

| Nama | Tipe | Wajib | Keterangan |
| --- | --- | --- | --- |
| `service_id` | integer | ya | ID layanan dari /v1/services. |

Contoh respons 200:

```json
{
  "success": true,
  "data": [
    {
      "id": 7,
      "code": "62",
      "name": "Indonesia",
      "min_price": 4500,
      "max_price": 12000,
      "offers": 6,
      "low_stock": false
    },
    {
      "id": 12,
      "code": "MY",
      "name": "Malaysia",
      "min_price": 6000,
      "max_price": 9500,
      "offers": 3,
      "low_stock": true
    }
  ]
}
```

### GET /v1/offers

**Daftar harga untuk layanan + negara.** Semua pilihan harga untuk pasangan layanan dan negara, urut dari termurah. Nilai price inilah yang dikirim saat membuat order. Harga lebih tinggi umumnya nomor dengan kualitas atau tingkat keberhasilan lebih baik. label bisa null. low_stock true berarti stok tipis, bisa habis sebelum order diproses.

Rate limit: 120 request per menit.

Query parameter:

| Nama | Tipe | Wajib | Keterangan |
| --- | --- | --- | --- |
| `service_id` | integer | ya | ID layanan. |
| `country_id` | integer | ya | ID negara dari /v1/countries. |

Contoh respons 200:

```json
{
  "success": true,
  "data": {
    "service": {
      "id": 3,
      "code": "whatsapp",
      "name": "WhatsApp"
    },
    "country": {
      "id": 7,
      "code": "62",
      "name": "Indonesia"
    },
    "offers": [
      {
        "price": 4500,
        "label": "Hemat",
        "low_stock": false
      },
      {
        "price": 6000,
        "label": null,
        "low_stock": false
      },
      {
        "price": 8500,
        "label": "Premium",
        "low_stock": true
      }
    ]
  }
}
```

### POST /v1/orders

**Buat order nomor.** Memesan satu nomor. price harus salah satu nilai dari /v1/offers; kalau harga itu sudah tidak ada, respons 409 OFFER_UNAVAILABLE dengan daftar harga terbaru di details.offers. Saldo dipotong saat order diterima dan dikembalikan otomatis bila order berakhir refunded atau failed. Order dikembalikan dengan status processing; nomor keluar beberapa detik kemudian (webhook order.number atau cek GET /v1/orders/{id}). Kirim header Idempotency-Key (string unik maksimal 64 karakter) supaya request yang diulang karena timeout tidak membuat order ganda: ulangan dengan body sama mengembalikan order yang sama dengan HTTP 200 dan meta.idempotent_replay true.

Rate limit: 30 request per menit.

Header:

| Nama | Tipe | Wajib | Keterangan |
| --- | --- | --- | --- |
| `Idempotency-Key` | string | tidak | Kunci unik per order dari sisi Anda, disimpan 24 jam. |

Body (JSON):

| Nama | Tipe | Wajib | Keterangan |
| --- | --- | --- | --- |
| `service_id` | integer | ya | ID layanan. |
| `country_id` | integer | ya | ID negara. |
| `price` | integer | ya | Harga pilihan dari /v1/offers (IDR). |
| `external_id` | string | tidak | Referensi dari sistem Anda, maksimal 64 karakter. Ikut dikirim di webhook. |

Contoh body:

```json
{
  "service_id": 3,
  "country_id": 7,
  "price": 4500,
  "external_id": "inv-2026-0001"
}
```

Contoh respons 201:

```json
{
  "success": true,
  "data": {
    "id": 184212,
    "external_id": "inv-2026-0001",
    "status": "processing",
    "service": {
      "id": 3,
      "code": "whatsapp",
      "name": "WhatsApp"
    },
    "country": {
      "id": 7,
      "code": "62",
      "name": "Indonesia"
    },
    "price": 4500,
    "number": null,
    "otp_code": null,
    "otp_codes": [],
    "otp_received_at": null,
    "created_at": "2026-10-14T20:29:40+07:00",
    "expires_at": null,
    "cancel_available_at": "2026-10-14T20:31:40+07:00",
    "refunded_at": null,
    "failure_reason": null
  }
}
```

Error khusus endpoint ini: `VALIDATION_ERROR` (422), `SERVICE_NOT_FOUND` (404), `OFFER_UNAVAILABLE` (409), `INSUFFICIENT_BALANCE` (402), `TOO_MANY_ACTIVE_ORDERS` (429), `ORDER_BLOCKED` (429), `IDEMPOTENCY_KEY_REUSED` (409), `REQUEST_IN_PROGRESS` (409), `PURCHASE_DISABLED` (503). Semua endpoint juga bisa mengembalikan `UNAUTHORIZED`, `KEY_REVOKED`, `RATE_LIMIT_EXCEEDED`, `INTERNAL_ERROR`.

### GET /v1/orders/{order_id}

**Detail dan status order.** Status terkini satu order termasuk nomor dan semua OTP yang masuk. Kalau tidak memakai webhook, polling endpoint ini tiap 3 sampai 5 detik sudah cukup.

Rate limit: 120 request per menit.

Path parameter:

| Nama | Tipe | Wajib | Keterangan |
| --- | --- | --- | --- |
| `order_id` | integer | ya | ID order. |

Contoh respons 200:

```json
{
  "success": true,
  "data": {
    "id": 184212,
    "external_id": "inv-2026-0001",
    "status": "success",
    "service": {
      "id": 3,
      "code": "whatsapp",
      "name": "WhatsApp"
    },
    "country": {
      "id": 7,
      "code": "62",
      "name": "Indonesia"
    },
    "price": 4500,
    "number": "+628123456789",
    "otp_code": "482913",
    "otp_codes": [
      "482913"
    ],
    "otp_received_at": "2026-10-14T20:31:05+07:00",
    "created_at": "2026-10-14T20:29:40+07:00",
    "expires_at": "2026-10-14T20:49:40+07:00",
    "cancel_available_at": null,
    "refunded_at": null,
    "failure_reason": null
  }
}
```

Error khusus endpoint ini: `ORDER_NOT_FOUND` (404). Semua endpoint juga bisa mengembalikan `UNAUTHORIZED`, `KEY_REVOKED`, `RATE_LIMIT_EXCEEDED`, `INTERNAL_ERROR`.

### GET /v1/orders

**Daftar order.** Order yang dibuat lewat API key ini, terbaru lebih dulu. Untuk halaman berikutnya kirim before_id = meta.next_before_id.

Rate limit: 120 request per menit.

Query parameter:

| Nama | Tipe | Wajib | Keterangan |
| --- | --- | --- | --- |
| `status` | string | tidak | Filter status: processing, waiting_otp, success, refunded, failed. |
| `external_id` | string | tidak | Cari order berdasarkan external_id Anda. |
| `before_id` | integer | tidak | Hanya order dengan id lebih kecil dari ini (paging). |
| `limit` | integer | tidak | Jumlah per halaman, 1 sampai 100, default 20. |

Contoh respons 200:

```json
{
  "success": true,
  "data": [
    {
      "id": 184212,
      "external_id": "inv-2026-0001",
      "status": "success",
      "service": {
        "id": 3,
        "code": "whatsapp",
        "name": "WhatsApp"
      },
      "country": {
        "id": 7,
        "code": "62",
        "name": "Indonesia"
      },
      "price": 4500,
      "number": "+628123456789",
      "otp_code": "482913",
      "otp_codes": [
        "482913"
      ],
      "otp_received_at": "2026-10-14T20:31:05+07:00",
      "created_at": "2026-10-14T20:29:40+07:00",
      "expires_at": "2026-10-14T20:49:40+07:00",
      "cancel_available_at": null,
      "refunded_at": null,
      "failure_reason": null
    }
  ],
  "meta": {
    "has_more": true,
    "next_before_id": 184212
  }
}
```

Error khusus endpoint ini: `VALIDATION_ERROR` (422). Semua endpoint juga bisa mengembalikan `UNAUTHORIZED`, `KEY_REVOKED`, `RATE_LIMIT_EXCEEDED`, `INTERNAL_ERROR`.

### POST /v1/orders/{order_id}/cancel

**Batalkan order.** Membatalkan order yang masih waiting_otp dan belum menerima OTP, paling cepat 2 menit setelah nomor keluar (lihat cancel_available_at). Saldo kembali penuh dan status menjadi refunded. Order yang sudah refunded atau failed mengembalikan 200 dengan data order apa adanya. Terlalu sering membatalkan atau membiarkan order kedaluwarsa tanpa OTP akan memblokir order baru sementara (ORDER_BLOCKED).

Rate limit: 30 request per menit.

Path parameter:

| Nama | Tipe | Wajib | Keterangan |
| --- | --- | --- | --- |
| `order_id` | integer | ya | ID order. |

Contoh respons 200:

```json
{
  "success": true,
  "data": {
    "id": 184212,
    "external_id": "inv-2026-0001",
    "status": "refunded",
    "service": {
      "id": 3,
      "code": "whatsapp",
      "name": "WhatsApp"
    },
    "country": {
      "id": 7,
      "code": "62",
      "name": "Indonesia"
    },
    "price": 4500,
    "number": "+628123456789",
    "otp_code": null,
    "otp_codes": [],
    "otp_received_at": null,
    "created_at": "2026-10-14T20:29:40+07:00",
    "expires_at": "2026-10-14T20:49:40+07:00",
    "cancel_available_at": null,
    "refunded_at": "2026-10-14T20:35:02+07:00",
    "failure_reason": null
  }
}
```

Error khusus endpoint ini: `ORDER_NOT_FOUND` (404), `CANCEL_NOT_ALLOWED` (409), `CANCEL_TOO_EARLY` (409), `OTP_ALREADY_RECEIVED` (409), `CANCEL_FAILED` (503). Semua endpoint juga bisa mengembalikan `UNAUTHORIZED`, `KEY_REVOKED`, `RATE_LIMIT_EXCEEDED`, `INTERNAL_ERROR`.

## Kode error

| HTTP | Kode | Arti |
| --- | --- | --- |
| 401 | `UNAUTHORIZED` | API key tidak dikirim, formatnya salah, atau tidak dikenal. |
| 401 | `KEY_REVOKED` | API key sudah dinonaktifkan dari dashboard. |
| 402 | `INSUFFICIENT_BALANCE` | Saldo tidak cukup. details berisi balance dan price. |
| 404 | `NOT_FOUND` | Endpoint tidak ada. |
| 404 | `SERVICE_NOT_FOUND` | service_id tidak ada atau layanan sedang tidak tersedia. |
| 404 | `COUNTRY_NOT_FOUND` | country_id tidak tersedia untuk layanan itu. |
| 404 | `ORDER_NOT_FOUND` | Order tidak ada atau bukan milik API key ini. |
| 409 | `OFFER_UNAVAILABLE` | Harga yang dikirim sudah tidak ada. details.offers berisi daftar harga saat ini, pilih lalu kirim ulang. |
| 409 | `IDEMPOTENCY_KEY_REUSED` | Idempotency-Key sama dipakai dengan body berbeda. |
| 409 | `REQUEST_IN_PROGRESS` | Request dengan Idempotency-Key yang sama masih diproses. |
| 409 | `CANCEL_NOT_ALLOWED` | Order bukan waiting_otp, sudah selesai, atau sudah berubah. |
| 409 | `CANCEL_TOO_EARLY` | Belum lewat cancel_available_at (2 menit setelah nomor keluar). |
| 409 | `OTP_ALREADY_RECEIVED` | OTP sudah masuk, order tidak bisa dibatalkan. Cek ulang status order. |
| 422 | `VALIDATION_ERROR` | Body atau query tidak valid. details berisi field yang salah. |
| 429 | `RATE_LIMIT_EXCEEDED` | Melebihi batas request per menit. Hormati header Retry-After. |
| 429 | `TOO_MANY_ACTIVE_ORDERS` | Sudah ada 10 order aktif (processing atau waiting_otp) di API key ini. |
| 429 | `ORDER_BLOCKED` | Terlalu banyak order dibatalkan atau kedaluwarsa tanpa OTP. details.retry_after dalam detik. |
| 503 | `PURCHASE_DISABLED` | Pembelian nomor dinonaktifkan sementara. Coba lagi sesuai Retry-After. |
| 503 | `CANCEL_FAILED` | Pembatalan gagal sementara, coba lagi beberapa detik kemudian. |
| 500 | `INTERNAL_ERROR` | Kesalahan di server Nomerin. Aman untuk diulang dengan Idempotency-Key yang sama. |

## Webhook

Atur URL webhook (https) per API key di dashboard. Nomerin mengirim `POST` berisi JSON setiap status order berubah:

| Event | Kapan |
| --- | --- |
| order.number | Nomor sudah keluar, status waiting_otp. |
| order.otp | OTP masuk, status success. Dikirim lagi setiap ada OTP baru di nomor yang sama. |
| order.refunded | Order dibatalkan atau kedaluwarsa tanpa OTP, saldo kembali. |
| order.failed | Nomor tidak didapat, saldo kembali. |
| webhook.test | Tes dari tombol di dashboard. Field order bernilai null. |

Body:

```json
{
  "event": "order.otp",
  "created_at": "2026-10-14T20:31:05+07:00",
  "order": {
    "id": 184212,
    "external_id": "inv-2026-0001",
    "status": "success",
    "service": {
      "id": 3,
      "code": "whatsapp",
      "name": "WhatsApp"
    },
    "country": {
      "id": 7,
      "code": "62",
      "name": "Indonesia"
    },
    "price": 4500,
    "number": "+628123456789",
    "otp_code": "482913",
    "otp_codes": [
      "482913"
    ],
    "otp_received_at": "2026-10-14T20:31:05+07:00",
    "created_at": "2026-10-14T20:29:40+07:00",
    "expires_at": "2026-10-14T20:49:40+07:00",
    "cancel_available_at": null,
    "refunded_at": null,
    "failure_reason": null
  }
}
```

Header yang dikirim:

| Header | Isi |
| --- | --- |
| `X-Nomerin-Event` | Nama event, misalnya order.otp. |
| `X-Nomerin-Delivery` | ID pengiriman, unik per percobaan kirim. Pakai untuk mengabaikan duplikat. |
| `X-Nomerin-Timestamp` | Unix timestamp (detik) saat dikirim. |
| `X-Nomerin-Signature` | t=<timestamp>,v1=<hex HMAC-SHA256>. Dihitung dari secret webhook atas string "<timestamp>.<body mentah>". |

Balas HTTP **200** secepatnya (proses isi secara asinkron). Selain 200, atau tidak ada respons dalam 10 detik, dianggap gagal dan dikirim ulang maksimal 5 kali total dengan jeda 5s, 15s, 40s, 90s. Pengiriman bisa datang tidak urut dan bisa duplikat, jadi jadikan `order.id` + `status` + `otp_code` (atau `X-Nomerin-Delivery`) sebagai kunci idempoten di sisi Anda. Riwayat pengiriman dan tombol tes ada di dashboard.

Verifikasi tanda tangan (Python):

```python
import hmac, hashlib, time

def verify(secret, header, raw_body, tolerance=300):
    parts = dict(item.split("=", 1) for item in header.split(","))
    timestamp, received = parts["t"], parts["v1"]
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)
```

Verifikasi tanda tangan (Node.js):

```javascript
const crypto = require("crypto");

function verify(secret, header, rawBody, tolerance = 300) {
  const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
```

## Contoh cepat (curl)

```bash
curl https://api.nomerin.id/v1/offers?service_id=3&country_id=7 \
  -H "Authorization: Bearer nmr_xxx"

curl -X POST https://api.nomerin.id/v1/orders \
  -H "Authorization: Bearer nmr_xxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: inv-2026-0001" \
  -d '{"service_id": 3, "country_id": 7, "price": 4500, "external_id": "inv-2026-0001"}'

curl https://api.nomerin.id/v1/orders/184212 -H "Authorization: Bearer nmr_xxx"
```
