মূল কনটেন্টে যান
Crypto Merchant

ডেভেলপার

ওয়েবহুক

কিছু ঘটলে, যেমন কোনো ইনভয়েস পরিশোধিত হলে, ওয়েবহুক আপনার সার্ভারকে জানায়, তাই আপনাকে বারবার API-কে জিজ্ঞেস করতে হয় না।

একটি এন্ডপয়েন্ট যোগ করুন

পোর্টালে ডেভেলপার খুলুন এবং আপনার সার্ভারের যে HTTPS ঠিকানায় ইভেন্ট আসবে তা যোগ করুন। প্রতিটি এন্ডপয়েন্ট নিজস্ব সাইনিং সিক্রেট পায়, যা whsec_ দিয়ে শুরু হয়। একটি এন্ডপয়েন্ট পরে যোগ হওয়া ইভেন্টসহ সব ইভেন্ট পেতে পারে, অথবা শুধু আপনার বেছে নেওয়া ইভেন্টগুলো। আপনার এন্ডপয়েন্ট কাজ করছে কি না দেখতে টেস্ট ইভেন্ট পাঠান ব্যবহার করুন।

ইভেন্ট

invoice.created
একটি ইনভয়েস তৈরি হয়েছে।
invoice.confirming
অন-চেইনে একটি পেমেন্ট দেখা গেছে এবং সেটি কনফার্মেশনের অপেক্ষায় আছে।
invoice.paid
ইনভয়েসটি পরিশোধিত ও কনফার্ম হয়েছে। এই ইভেন্টে অর্ডার পূরণ করুন।
invoice.underpaid
কনফার্ম হওয়া পেমেন্টে ঘাটতি আপনার ছাড়ের সীমার চেয়ে বেশি।
invoice.expired
কোনো পেমেন্ট দেখা যাওয়ার আগেই সময় শেষ হয়েছে।
invoice.cancelled
ইনভয়েসটি বাতিল করা হয়েছে।
invoice.refunded
কয়েন গ্রাহকের কাছে ফেরত গেছে: কম পেমেন্টের ইনভয়েসের রিফান্ড, অথবা আপনার তৈরি করা একটি রিফান্ড লিংক ক্লেইম করা হয়েছে। প্রতিটি ক্লেইমে পাঠানো হয়, তাই আংশিক রিফান্ডেও একটি পাঠানো হয়।
deposit.pending
আপনার কোনো গ্রাহকের স্থায়ী ওয়ালেটে অন-চেইনে একটি ডিপোজিট দেখা গেছে এবং সেটি কনফার্মেশনের অপেক্ষায় আছে।
deposit.confirmed
একটি স্থায়ী ওয়ালেট ডিপোজিট কনফার্ম হয়ে ফি বাদে আপনার ব্যালেন্সে যোগ হয়েছে: এখন সেই গ্রাহকের অ্যাকাউন্টে অর্থ যোগ করুন।
deposit.dropped
দেখা যাওয়া একটি স্থায়ী ওয়ালেট ডিপোজিট কখনো কনফার্ম হয়নি (ব্যর্থ হয়েছে বা চেইন থেকে সরিয়ে দেওয়া হয়েছে)।
payout.sent
আপনার ওয়ালেটে একটি পেআউট পাঠানো হয়েছে, অথবা কোনো পার্টনারের অংশ পার্টনারের ওয়ালেটে পাঠানো হয়েছে।
payout.failed
একটি পেআউট পাঠানো যায়নি।
swap.completed
আপনার হোল্ডিং কয়েনে একটি সোয়াপ সম্পন্ন হয়েছে।

পেলোড

প্রতিটি ইভেন্ট JSON বডিসহ একটি HTTP POST হিসেবে পাঠানো হয়:

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

id প্রতিটি ইভেন্টের জন্য অনন্য, type ওপরের ইভেন্টগুলোর একটি, এবং created হলো এটি কখন ঘটেছে। ইনভয়েস ইভেন্টে data হলো ইনভয়েস অবজেক্ট। পেআউট ইভেন্টে এটি হলো পেআউট অবজেক্ট। সোয়াপ ইভেন্টে data সোয়াপের বিবরণ দেয়।

হেডার

Content-Type
সবসময় application/json।
CM-Signature
টাইমস্ট্যাম্প ও সিগনেচার: t= সেকেন্ডে Unix সময়, তারপর v1= হেক্স HMAC-SHA256 সিগনেচার।
CM-Event-Id
ইভেন্ট আইডি, বডির id-এর মতোই।
User-Agent
সবসময় 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_…

সিগনেচার যাচাই করুন

কোনো ইভেন্টে ভরসা করার আগে সবসময় সিগনেচার যাচাই করুন। যে কেউ আপনার এন্ডপয়েন্টে রিকোয়েস্ট পাঠাতে পারে, কিন্তু আপনার সিক্রেট শুধু Crypto Merchant জানে।

  1. 1

    JSON পার্স করার আগে, কাঁচা রিকোয়েস্ট বডি ঠিক যেভাবে এসেছে সেভাবেই পড়ুন।

  2. 2

    CM-Signature হেডারকে কমা দিয়ে ভাগ করুন এবং t ও v1-এর মান পড়ুন।

  3. 3

    আপনার এন্ডপয়েন্ট সিক্রেটকে কি হিসেবে ব্যবহার করে টাইমস্ট্যাম্প, একটি ফুল স্টপ (.) ও কাঁচা বডির HMAC-SHA256 হিসাব করুন, ছোট হাতের হেক্স হিসেবে।

  4. 4

    কনস্ট্যান্ট-টাইম তুলনা ব্যবহার করে এটি v1-এর সাথে মিলিয়ে দেখুন।

  5. 5

    যেসব ইভেন্টের টাইমস্ট্যাম্প পাঁচ মিনিটের বেশি পুরোনো সেগুলো প্রত্যাখ্যান করুন, যাতে পুরোনো রিকোয়েস্ট আবার চালানো না যায়।

verify-webhook.js · 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);
}

উত্তর দেওয়া ও পুনরায় চেষ্টা

ইভেন্ট পেয়েছেন তা নিশ্চিত করতে 10 সেকেন্ডের মধ্যে যেকোনো 2xx স্ট্যাটাস দিয়ে উত্তর দিন। অন্য যেকোনো উত্তর, বা কোনো উত্তর না দেওয়া, ব্যর্থতা হিসেবে গণ্য হয়, এবং আমরা এতক্ষণ পরে আবার চেষ্টা করি:

  1. ১ মিনিট
  2. ৫ মিনিট
  3. ১৫ মিনিট
  4. ১ ঘণ্টা
  5. ৩ ঘণ্টা
  6. ৬ ঘণ্টা
  7. ১২ ঘণ্টা
  8. ২৪ ঘণ্টা

অর্থাৎ প্রায় দুই দিনে সর্বোচ্চ আটবার পুনরায় চেষ্টা। প্রতিটি চেষ্টা নতুন টাইমস্ট্যাম্পসহ আবার সাইন করা হয়।

ভালো অভ্যাস

  • উত্তর দেওয়ার আগে প্রতিটি ইভেন্ট সেভ করুন, তারপর যা সেভ করেছেন তা থেকে কাজটি করুন, যেমন একটি ব্যাকগ্রাউন্ড জবে। সেভ করা ব্যর্থ হলে একটি এরর স্ট্যাটাস দিয়ে উত্তর দিন, আমরা ইভেন্টটি আবার পাঠাব।
  • ইভেন্ট একাধিকবার আসতে পারে, এবং সবসময় ক্রমানুসারে নাও আসতে পারে। আগে সামলানো ইভেন্ট বাদ দিতে ইভেন্টের id ব্যবহার করুন।
  • কোনো মূল্যবান অর্ডার পাঠানোর আগে, স্ট্যাটাস আবার যাচাই করতে API থেকে ইনভয়েসটি আনতে পারেন।
  • API কি-এর মতোই আপনার whsec_ সিক্রেট আপনার সার্ভারে রাখুন।