Dokumentasi API

API SHOMERCH memakai REST, menerima dan membalas JSON. Base URL: https://shomerch.web.id/api/v1

Mulai cepat

Empat hal yang perlu disiapkan sebelum request pertama:

  1. Akun dengan paket aktif
  2. Kredensial ShopeePay tersambung (Dashboard → Payment Gateway)
  3. API key (Dashboard → API Keys)
  4. Endpoint webhook, kalau ingin diberi tahu otomatis

Cek dulu apakah semuanya siap:

curl 'https://shomerch.web.id/api/v1/ping' \
  -H 'X-API-Key: str-live-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'

Autentikasi

Kirim API key di header X-API-Key pada setiap request. Bisa juga sebagai Authorization: Bearer str-live-….

Jangan pakai API key di browser
Key ini memberi akses penuh ke transaksi akun Anda. Simpan di sisi server — environment variable, bukan di kode JavaScript yang dikirim ke pengunjung.

Kode unik — kenapa Rp10.000 jadi Rp10.181

QRIS statis tidak mengunci nominal, dan Shopee tidak menyediakan API "cek status invoice". Yang tersedia hanya daftar transaksi masuk. Jadi pencocokan dilakukan lewat nominal.

Supaya dua pesanan tidak tertukar, SHOMERCH menambahkan kode unik 100–199 pada setiap transaksi. Pesanan Rp10.000 menjadi Rp10.181, dan nominal itu dijamin tidak sama dengan pesanan lain yang sedang berjalan di merchant Anda.

Tampilkan total_amount, bukan amount
amount adalah harga produk Anda. total_amount adalah yang harus dibayar pelanggan dan yang terkunci di dalam QR. Kalau Anda menampilkan amount, pelanggan akan bingung melihat nominal berbeda di aplikasi bank.

Selisih kode unik (Rp100–199) tetap masuk ke akun ShopeePay Anda.

Alur lengkap

1. Pelanggan checkout                → POST /transactions
2. Tampilkan QR + total_amount       → qr_string / qr_image_url
3. Pelanggan scan & bayar            → (di aplikasi bank/e-wallet mereka)
4. SHOMERCH mendeteksi (~10 detik)     → status jadi PAID
5. Webhook payment.paid dikirim      → sistem Anda memproses pesanan

   Kalau tidak dibayar sampai expires_at + 3 menit:
   → status jadi EXPIRED, webhook payment.expired dikirim

Buat transaksi

POST/api/v1/transactions

FieldTipeKeterangan
ref_idstringWajib. Nomor pesanan Anda. Unik per akun — mengirim ulang ref_id yang sama mengembalikan transaksi lama, tidak membuat yang baru.
amountintegerWajib. Harga produk dalam rupiah. Minimal 1000.
expires_inintegerUmur QR dalam detik. 60–1800, default 300.
customerobject{ name, email } — opsional, hanya untuk catatan Anda.
callback_urlstringWebhook khusus untuk transaksi ini, menimpa endpoint global.
metadataobjectData bebas maks 2 KB. Dikembalikan apa adanya di webhook.
curl -X POST 'https://shomerch.web.id/api/v1/transactions' \
  -H 'X-API-Key: str-live-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
  -H 'Content-Type: application/json' \
  -d '{
    "ref_id": "INV-2026-0001",
    "amount": 10000,
    "metadata": { "product_id": 42 }
  }'
const res = await fetch('https://shomerch.web.id/api/v1/transactions', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.SHOMERCH_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    ref_id: 'INV-2026-0001',
    amount: 10000,
    metadata: { product_id: 42 },
  }),
});

const { success, data, error } = await res.json();
if (!success) throw new Error(error.message);

// Tampilkan data.total_amount (10181) dan data.qr_string ke pelanggan.
console.log(data.total_amount, data.qr_string);
$ch = curl_init('https://shomerch.web.id/api/v1/transactions');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SHOMERCH_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'ref_id' => 'INV-2026-0001',
        'amount' => 10000,
    ]),
]);

$res = json_decode(curl_exec($ch), true);
curl_close($ch);

if (empty($res['success'])) {
    throw new Exception($res['error']['message']);
}

$total = $res['data']['total_amount'];  // 10181
$qr    = $res['data']['qr_string'];
import os, requests

r = requests.post(
    'https://shomerch.web.id/api/v1/transactions',
    headers={'X-API-Key': os.environ['SHOMERCH_KEY']},
    json={'ref_id': 'INV-2026-0001', 'amount': 10000},
    timeout=15,
)
body = r.json()

if not body['success']:
    raise RuntimeError(body['error']['message'])

data = body['data']
print(data['total_amount'], data['qr_string'])

Respons 201

{
  "success": true,
  "data": {
    "id": "trx_9f1c8a2e-...",
    "user_id": "usr_3a7e1b4c-...",
    "ref_id": "INV-2026-0001",
    "amount": 10000,
    "unique_code": 181,
    "total_amount": 10181,        // ← yang dibayar pelanggan
    "qr_string": "00020101021226610016ID.CO.SHOPEE.WWW...",
    "qr_image_url": "https://shomerch.web.id/api/v1/transactions/INV-2026-0001/qr.png",
    "status": "PENDING",
    "created_at": "2026-09-08T14:03:11+07:00",
    "expires_at": "2026-09-08T14:08:11+07:00"
  }
}

Cek status transaksi

GET/api/v1/transactions/:ref_id

curl 'https://shomerch.web.id/api/v1/transactions/INV-2026-0001' \
  -H 'X-API-Key: str-live-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'

Status yang mungkin: PENDING, PAID, EXPIRED, CANCELED, FAILED.

Kalau Anda memakai webhook, tidak perlu polling endpoint ini terus-menerus. Setiap panggilan tetap dihitung sebagai request dan terkena rate limit.

Daftar transaksi

GET/api/v1/transactions

QueryKeterangan
statusFilter status
from, toRentang tanggal, format YYYY-MM-DD
pageHalaman, mulai 1
limitBaris per halaman, maks 100 (default 20)

Batalkan transaksi

POST/api/v1/transactions/:ref_id/cancel

Hanya berlaku untuk transaksi yang masih PENDING. Membatalkan juga melepas nominalnya supaya bisa dipakai pesanan lain.

Gambar QR

GET/api/v1/transactions/:ref_id/qr.png?size=512

Mengembalikan PNG. Praktis untuk ditempel langsung di HTML atau dikirim lewat bot:

<img src="https://shomerch.web.id/api/v1/transactions/INV-1/qr.png?size=400">

Endpoint ini tetap butuh header X-API-Key, jadi untuk ditampilkan di browser sebaiknya QR di-render sendiri dari qr_string atau di-proxy lewat server Anda.

Mutasi uang masuk

GET/api/v1/mutations

Riwayat transaksi mentah yang terbaca dari akun ShopeePay Anda — termasuk pembayaran yang tidak berasal dari SHOMERCH. Berguna untuk rekonsiliasi pembukuan.

Info akun

GET/api/v1/me

{
  "success": true,
  "data": {
    "user_id": "usr_3a7e...",
    "name": "Toko Saya",
    "plan": { "code": "basic", "name": "Basic", "expires_at": "2026-10-08T..." },
    "quota": { "limit": 50, "used": 38, "remaining": 12, "resets_at": "2026-09-09T00:00:00+07:00" },
    "merchant": { "status": "active", "name": "Toko Saya" }
  }
}

Webhook

Setiap kali status transaksi berubah, kami mengirim POST ke endpoint Anda dengan header berikut:

X-Shomerch-Event      : payment.paid
X-Shomerch-Event-Id   : 6f1b2c8a-...        # idempotency key
X-Shomerch-Timestamp  : 1788677476          # epoch detik
X-Shomerch-Signature  : sha256=<hex>

Payload

{
  "event": "payment.paid",
  "created_at": "2026-09-08T14:05:52+07:00",
  "data": {
    "id": "trx_9f1c...",
    "user_id": "usr_3a7e...",
    "ref_id": "INV-2026-0001",
    "amount": 10000,
    "unique_code": 181,
    "total_amount": 10181,
    "status": "PAID",
    "paid_at": "2026-09-08T14:05:47+07:00",
    "payment_method": "QRIS_SHOPEEPAY",
    "metadata": { "product_id": 42 }
  }
}

Event

EventKapan dikirim
payment.paidPembayaran terdeteksi dan cocok
payment.expiredQR kedaluwarsa tanpa pembayaran
payment.canceledDibatalkan lewat API atau dashboard
payment.failedPembayaran gagal di sisi Shopee

Retry

Endpoint Anda harus membalas HTTP 2xx dalam 10 detik. Kalau tidak, kami ulang sampai 6 kali: langsung, 30 detik, 2 menit, 10 menit, 1 jam, 6 jam. Setelah itu ditandai gagal dan bisa dikirim ulang manual dari dashboard.

Proses pesanan Anda setelah membalas 200 — jangan tahan respons sampai pekerjaan berat selesai, atau kami akan menganggapnya timeout dan mengirim ulang.

Verifikasi tanda tangan

Wajib diverifikasi
Tanpa verifikasi, siapa pun yang tahu URL webhook Anda bisa mengirim payment.paid palsu dan mendapat barang tanpa membayar.

Tanda tangan dihitung dari timestamp + "." + body mentah memakai HMAC SHA-256 dengan signing secret Anda. Tiga hal yang harus benar:

  1. Pakai body mentah, bukan hasil parse lalu di-stringify ulang — urutan key bisa berubah dan tanda tangan langsung tidak cocok.
  2. Bandingkan dengan fungsi timing-safe, bukan ===.
  3. Tolak kalau timestamp lebih tua dari 5 menit — ini yang mencegah serangan replay.
const crypto = require('crypto');

// PENTING: ambil body mentah, jangan express.json() untuk route ini.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const raw       = req.body.toString('utf8');
  const timestamp = req.get('x-shomerch-timestamp');
  const signature = (req.get('x-shomerch-signature') || '').replace('sha256=', '');

  // 1. Tolak yang kedaluwarsa (anti replay)
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.status(400).send('timestamp kedaluwarsa');
  }

  // 2. Hitung ulang tanda tangan
  const expected = crypto
    .createHmac('sha256', process.env.SHOMERCH_WEBHOOK_SECRET)
    .update(`${timestamp}.${raw}`)
    .digest('hex');

  // 3. Bandingkan timing-safe
  const a = Buffer.from(signature), b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send('tanda tangan tidak valid');
  }

  const event = JSON.parse(raw);

  // Balas dulu, proses belakangan — batas waktu kami 10 detik.
  res.sendStatus(200);

  if (event.event === 'payment.paid') {
    // Idempoten: event yang sama bisa datang dua kali.
    prosesPesanan(event.data.ref_id, event.data.total_amount);
  }
});
<?php
$raw       = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_SHOMERCH_TIMESTAMP'] ?? '';
$signature = str_replace('sha256=', '', $_SERVER['HTTP_X_SHOMERCH_SIGNATURE'] ?? '');

// 1. Anti replay
if (abs(time() - (int) $timestamp) > 300) {
    http_response_code(400);
    exit('timestamp kedaluwarsa');
}

// 2. Hitung ulang
$expected = hash_hmac('sha256', $timestamp . '.' . $raw, getenv('SHOMERCH_WEBHOOK_SECRET'));

// 3. Bandingkan timing-safe
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('tanda tangan tidak valid');
}

$event = json_decode($raw, true);
http_response_code(200);   // balas dulu

if ($event['event'] === 'payment.paid') {
    prosesPesanan($event['data']['ref_id']);
}
import hmac, hashlib, time, os
from flask import Flask, request

app = Flask(__name__)

@app.route('/webhook', methods=['POST'])
def webhook():
    raw       = request.get_data()               # bytes mentah
    timestamp = request.headers.get('X-Shomerch-Timestamp', '')
    signature = request.headers.get('X-Shomerch-Signature', '').replace('sha256=', '')

    # 1. Anti replay
    if abs(time.time() - int(timestamp or 0)) > 300:
        return 'timestamp kedaluwarsa', 400

    # 2. Hitung ulang
    expected = hmac.new(
        os.environ['SHOMERCH_WEBHOOK_SECRET'].encode(),
        timestamp.encode() + b'.' + raw,
        hashlib.sha256,
    ).hexdigest()

    # 3. Bandingkan timing-safe
    if not hmac.compare_digest(expected, signature):
        return 'tanda tangan tidak valid', 401

    event = request.get_json()
    if event['event'] == 'payment.paid':
        proses_pesanan(event['data']['ref_id'])

    return '', 200

Tanpa webhook — cara polling yang benar

Webhook itu opsional. Kalau aplikasi Anda tidak punya alamat publik — bot Telegram atau WhatsApp yang jalan di laptop, di rumah, atau di belakang NAT — biarkan saja kolom webhook kosong. Tidak ada yang rusak; status transaksi tetap diperbarui, Anda tinggal menanyakannya.

Jangan poll satu per satu
Memanggil /transactions/:ref_id untuk tiap pesanan yang menunggu adalah cara paling boros. Sepuluh pesanan aktif yang dicek tiap 5 detik = 120 request/menit — jauh di atas jatah paket mana pun, dan Anda akan kena RATE_LIMITED.

Cara yang benar: satu panggilan untuk semua pesanan

Pakai updated_since pada endpoint daftar. Satu panggilan mengembalikan semua transaksi yang berubah sejak pengecekan terakhir — berapa pun jumlah pesanan yang sedang berjalan.

QueryKeterangan
updated_sinceWaktu ISO-8601. Hanya transaksi yang berubah setelah waktu ini yang dikembalikan, diurutkan dari yang paling lama.
statusSaring, mis. PAID saja.

Respons memuat next_since — pakai nilai itu apa adanya untuk panggilan berikutnya. Kalau tidak ada yang berubah, nilainya dipantulkan balik, jadi tidak ada yang terlewat.

// Satu loop untuk SELURUH pesanan. 12 request/menit, tetap segitu
// meski ada 200 pesanan berjalan.
let sejak = new Date().toISOString();

setInterval(async () => {
  const url = 'https://shomerch.web.id/api/v1/transactions' +
    '?status=PAID&updated_since=' + encodeURIComponent(sejak);

  const r = await fetch(url, {
    headers: { 'X-API-Key': process.env.SHOMERCH_KEY },
  });
  const { data } = await r.json();

  for (const trx of data.transactions) {
    // Idempoten: simpan ref_id yang sudah diproses, event yang sama
    // bisa muncul lagi kalau penanda waktu mundur.
    if (await sudahDiproses(trx.ref_id)) continue;
    await kirimBarang(trx.ref_id, trx.total_amount);
  }

  sejak = data.next_since;   // penanda untuk putaran berikutnya
}, 5000);
import os, time, requests
from datetime import datetime, timezone

sejak = datetime.now(timezone.utc).isoformat()
sudah = set()

while True:
    r = requests.get(
        'https://shomerch.web.id/api/v1/transactions',
        headers={'X-API-Key': os.environ['SHOMERCH_KEY']},
        params={'status': 'PAID', 'updated_since': sejak},
        timeout=15,
    )
    data = r.json()['data']

    for trx in data['transactions']:
        if trx['ref_id'] in sudah:
            continue
        sudah.add(trx['ref_id'])
        kirim_barang(trx['ref_id'], trx['total_amount'])

    sejak = data['next_since']
    time.sleep(5)
curl 'https://shomerch.web.id/api/v1/transactions?status=PAID&updated_since=2026-09-08T14:00:00%2B07:00' \
  -H 'X-API-Key: str-live-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'

# Respons memuat next_since — pakai itu untuk panggilan berikutnya.

Berapa sering boleh poll?

PaketRate limitInterval amanSisa untuk request lain
Basic30/menit5 detik (12/menit)18/menit
Standard60/menit3 detik (20/menit)40/menit
Premium120/menit2 detik (30/menit)90/menit
Berhenti poll saat tidak ada pesanan
Kalau tidak ada transaksi PENDING, tidak ada gunanya terus memanggil. Mulai loop saat pesanan pertama dibuat, hentikan saat semuanya selesai atau kedaluwarsa. Ini menghemat kuota dan membuat paket Basic cukup untuk kebanyakan bot.

Webhook atau polling?

WebhookPolling
Butuh alamat publik + HTTPSYaTidak
Jeda deteksiSeketikaSesuai interval poll
Memakan rate limitTidakYa
Cocok untukWeb/VPSBot di laptop, rumah, di balik NAT

Boleh juga dipakai bersamaan: webhook sebagai jalur utama, polling jarang (mis. tiap 1 menit) sebagai jaring pengaman kalau ada webhook yang tidak sampai.

Kode error

Semua error memakai bentuk yang sama:

{
  "success": false,
  "error": {
    "code": "QUOTA_EXCEEDED",
    "message": "Kuota harian paket Basic (50) sudah habis.",
    "docs": "https://shomerch.web.id/docs#errors"
  }
}
HTTPCodeArtinya & apa yang harus dilakukan
401INVALID_API_KEYKey salah atau sudah dicabut. Buat key baru di dashboard.
402SUBSCRIPTION_INACTIVEPaket belum aktif atau habis. Perpanjang di dashboard.
403MERCHANT_NOT_CONNECTEDKredensial ShopeePay belum diisi atau tidak valid.
403MERCHANT_SESSION_EXPIREDSesi Shopee mati. Ambil ulang token & cookie dari DevTools.
403ACCOUNT_SUSPENDEDAkun dibekukan. Hubungi dukungan.
409UNIQUE_CODE_EXHAUSTED100 slot kode unik untuk nominal itu sedang terpakai semua. Coba lagi beberapa menit.
422VALIDATION_ERRORField tidak valid. Lihat error.details untuk per-field.
429RATE_LIMITEDMelebihi request/menit. Tunggu sesuai header Retry-After.
429QUOTA_EXCEEDEDKuota harian habis. Reset jam 00:00 WIB, atau naikkan paket.
502SHOPEE_UNREACHABLEShopee sedang tidak bisa dihubungi. Coba lagi.

Batas & kuota

BatasBasicStandardPremium
Transaksi per hari50100Tanpa batas
Request per menit3060120
API key2520
Riwayat tersimpan30 hari90 hari365 hari

Kuota harian dihitung dari transaksi yang dibuat (bukan yang lunas) dan reset jam 00:00 WIB. Sisa kuota bisa dilihat kapan saja lewat GET /api/v1/me.

Praktik terbaik

Ada yang kurang jelas atau menemukan kekeliruan di dokumen ini? Kirim ke dukungan lewat dashboard — kami perbaiki.