Lewati ke konten
Crypto Merchant

Developer

Webhook

Webhook memberi tahu server Anda saat sesuatu terjadi, misalnya saat invoice dibayar, sehingga Anda tidak perlu terus-menerus bertanya ke API.

Menambahkan endpoint

Di portal, buka Developer dan tambahkan alamat HTTPS di server Anda yang akan menerima event. Setiap endpoint mendapat secret penandatanganan sendiri, yang diawali dengan whsec_. Endpoint dapat menerima semua event, termasuk yang ditambahkan nanti, atau hanya event yang Anda pilih. Gunakan Kirim event uji untuk memastikan endpoint Anda berfungsi.

Event

invoice.created
Invoice dibuat.
invoice.confirming
Pembayaran terdeteksi on-chain dan sedang menunggu konfirmasi.
invoice.paid
Invoice lunas dan dikonfirmasi. Proses pesanan saat menerima event ini.
invoice.underpaid
Pembayaran yang dikonfirmasi kurang lebih dari toleransi Anda.
invoice.expired
Waktu habis sebelum ada pembayaran yang terdeteksi.
invoice.cancelled
Invoice dibatalkan.
invoice.refunded
Koin dikembalikan kepada pelanggan: pengembalian dana untuk invoice kurang bayar, atau link pengembalian dana yang Anda buat telah diklaim. Dikirim untuk setiap klaim, jadi pengembalian dana sebagian juga mengirimkannya.
deposit.pending
Deposit ke dompet tetap salah satu pelanggan Anda terdeteksi on-chain dan sedang menunggu konfirmasi.
deposit.confirmed
Deposit dompet tetap dikonfirmasi dan masuk ke saldo Anda setelah dipotong biaya: tambahkan saldo pelanggan tersebut sekarang.
deposit.dropped
Deposit dompet tetap yang terdeteksi tidak pernah dikonfirmasi (gagal atau dihapus dari chain).
payout.sent
Pencairan dikirim ke dompet Anda, atau bagian mitra dikirim ke dompet mitra.
payout.failed
Pencairan tidak dapat dikirim.
swap.completed
Konversi ke koin simpanan Anda selesai.

Payload

Setiap event dikirim sebagai HTTP POST dengan body JSON:

JSON
{
  "id": "evt_…",
  "type": "invoice.paid",
  "created": "2026-10-28T09:41:07.000Z",
  "data": {
    "id": "inv_…",
    "object": "invoice",
    "status": "paid",
    …
  }
}

id unik untuk setiap event, type adalah salah satu event di atas, dan created adalah waktu event terjadi. Untuk event invoice, data adalah objek invoice. Untuk event pencairan, isinya adalah objek pencairan. Untuk event konversi, data menjelaskan konversi tersebut.

Header

Content-Type
Selalu application/json.
CM-Signature
Timestamp dan tanda tangan: t= waktu Unix dalam detik, lalu v1= tanda tangan HMAC-SHA256 dalam format hex.
CM-Event-Id
ID event, sama dengan id di body.
User-Agent
Selalu CryptoMerchant-Webhooks/1.
HTTP
POST /webhooks/crypto-merchant HTTP/1.1
Content-Type: application/json
User-Agent: CryptoMerchant-Webhooks/1
CM-Signature: t=1792834867,v1=5f2c…
CM-Event-Id: evt_…

Memverifikasi tanda tangan

Selalu periksa tanda tangan sebelum memercayai event. Siapa pun dapat mengirim permintaan ke endpoint Anda, tetapi hanya Crypto Merchant yang mengetahui secret Anda.

  1. 1

    Baca body permintaan mentah persis seperti saat diterima, sebelum mem-parsing JSON.

  2. 2

    Pisahkan header CM-Signature berdasarkan koma dan baca nilai t dan v1.

  3. 3

    Hitung HMAC-SHA256 dari timestamp, sebuah titik, dan body mentah, menggunakan secret endpoint Anda sebagai kunci, dalam format hex huruf kecil.

  4. 4

    Bandingkan hasilnya dengan v1 menggunakan perbandingan waktu-konstan.

  5. 5

    Tolak event yang timestamp-nya lebih dari lima menit, agar permintaan lama tidak dapat diputar ulang.

verify-webhook.js · Memverifikasi webhook di Node.js
import crypto from "node:crypto";

/**
 * Checks a Crypto Merchant webhook and returns the event.
 * rawBody: the request body exactly as it arrived (string or Buffer).
 */
export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    String(signatureHeader ?? "")
      .split(",")
      .map((part) => part.trim().split("=")),
  );
  const t = parts.t;
  const v1 = parts.v1 ?? "";
  if (!t || !v1) throw new Error("Missing signature");

  const body = Buffer.isBuffer(rawBody) ? rawBody.toString("utf8") : rawBody;
  const expected = crypto
    .createHmac("sha256", secret) // your endpoint secret, whsec_…
    .update(`${t}.${body}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(v1);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error("Bad signature");
  }
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) {
    throw new Error("Timestamp too old");
  }
  return JSON.parse(body);
}
server.js · Express
import express from "express";
import { verifyWebhook } from "./verify-webhook.js";
import { db } from "./db.js"; // your database, e.g. a pg Pool

// CREATE TABLE webhook_events (
//   id text PRIMARY KEY, type text NOT NULL, payload jsonb NOT NULL,
//   received_at timestamptz NOT NULL DEFAULT now(), processed_at timestamptz
// );

const app = express();

// Keep the raw body: re-serialised JSON would not match the signature.
app.post(
  "/webhooks/crypto-merchant",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;
    try {
      event = verifyWebhook(req.body, req.get("CM-Signature"), process.env.CM_WEBHOOK_SECRET);
    } catch {
      return res.sendStatus(400);
    }

    try {
      // 1. Save the event before replying. A repeat delivery has the same id and is ignored.
      await db.query(
        "INSERT INTO webhook_events (id, type, payload) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING",
        [event.id, event.type, event],
      );
    } catch {
      return res.sendStatus(500); // not saved: we send it again later
    }

    // 2. It's stored, so confirm it.
    res.sendStatus(200);

    // 3. Do the work from what you saved, here or in a background job.
    //    If your server stops now, the event is still waiting in the table.
    processEvents().catch(console.error);
  },
);

async function processEvents() {
  const { rows } = await db.query(
    "SELECT payload FROM webhook_events WHERE processed_at IS NULL ORDER BY received_at",
  );
  for (const { payload: event } of rows) {
    if (event.type === "invoice.paid") {
      // Mark the order in event.data.order_id as paid. Doing it twice must change nothing.
    }
    await db.query("UPDATE webhook_events SET processed_at = now() WHERE id = $1", [event.id]);
  }
}
verify_webhook.py · Python
import hashlib, hmac, json, time

def verify_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance_sec: int = 300) -> dict:
    """Checks a Crypto Merchant webhook and returns the event. raw_body: the request body exactly as it arrived."""
    parts = dict(part.strip().split("=", 1) for part in (signature_header or "").split(",") if "=" in part)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        raise ValueError("Missing signature")
    signed = t.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()  # your endpoint secret, whsec_…
    if not hmac.compare_digest(expected, v1):
        raise ValueError("Bad signature")
    if abs(time.time() - int(t)) > tolerance_sec:
        raise ValueError("Timestamp too old")
    return json.loads(raw_body)
verify_webhook.php · PHP
<?php
// Checks a Crypto Merchant webhook and returns the event.
// Read the raw body with file_get_contents('php://input') and the header with $_SERVER['HTTP_CM_SIGNATURE'].
function verify_webhook(string $rawBody, string $signatureHeader, string $secret, int $toleranceSec = 300): array {
    $parts = [];
    foreach (explode(',', $signatureHeader) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        $parts[$key] = $value;
    }
    $t = $parts['t'] ?? '';
    $v1 = $parts['v1'] ?? '';
    if ($t === '' || $v1 === '') {
        throw new RuntimeException('Missing signature');
    }
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); // your endpoint secret, whsec_…
    if (!hash_equals($expected, $v1)) {
        throw new RuntimeException('Bad signature');
    }
    if (abs(time() - (int) $t) > $toleranceSec) {
        throw new RuntimeException('Timestamp too old');
    }
    return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
}

Respons dan percobaan ulang

Balas dengan status 2xx apa pun dalam 10 detik untuk mengonfirmasi bahwa Anda menerima event. Balasan lain, atau tidak ada balasan, dianggap gagal, dan kami mencoba lagi setelah:

  1. 1 menit
  2. 5 menit
  3. 15 menit
  4. 1 jam
  5. 3 jam
  6. 6 jam
  7. 12 jam
  8. 24 jam

Artinya hingga delapan percobaan ulang selama sekitar dua hari. Setiap percobaan ditandatangani ulang dengan timestamp baru.

Praktik yang baik

  • Simpan setiap event sebelum Anda membalas, lalu kerjakan prosesnya dari data yang Anda simpan, misalnya di background job. Jika penyimpanan gagal, balas dengan error dan kami akan mengirim ulang event tersebut.
  • Event dapat tiba lebih dari sekali, dan tidak selalu berurutan. Gunakan id event untuk melewati event yang sudah Anda tangani.
  • Sebelum mengirim pesanan bernilai tinggi, Anda dapat mengambil invoice dari API untuk memeriksa ulang statusnya.
  • Simpan secret whsec_ Anda di server, seperti kunci API.