1 Overview

Introduction to the Webhook event system.

CRYPTOMENTS sends real-time notifications to your configured Webhook URL when a transaction reaches a state you need to act on — a deposit is confirmed, a withdrawal is accepted, a withdrawal is closed (completed or failed), and a P2P deposit order is closed. Intermediate administrative steps such as approval do not produce their own event. This lets partners keep their systems in sync with CRYPTOMENTS.

Key Features

  • Near real-time notifications: The event is queued when it happens and picked up by a dispatch job that polls every 10 seconds.
  • HMAC-SHA256 signing: Every Webhook request is signed with the partner's API Secret for verification.
  • Automatic retry: If your endpoint does not answer with an HTTP 2xx status, delivery is retried automatically with growing intervals — 5 attempts in total.
  • Idempotency: Deduplicate on eventType + transactionId. Withdrawal events carry an empty transactionHash ("") until the transaction is broadcast, and P2P_ORDER_COMPLETE always carries "", so the hash must never be used alone as a key.
⚠️
Read this before you build a receiver: transactionHash is not a safe idempotency key for withdrawal events — every withdrawal event sent before broadcast contains an empty string (""), not null. A UNIQUE index on the hash alone will make you silently drop every withdrawal notification after the first one. Use eventType + transactionId. See Idempotency.

Request Model

CRYPTOMENTS Webhook requests follow this structure:

  • Method: HTTP POST
  • Content-Type: application/json
  • Delivery: Queued at event time, then sent by a dispatch job that polls every 10 seconds
  • Success response: Any HTTP 2xx status. The response body is not inspected
ℹ️
Tip: Respond to the Webhook with HTTP 200 immediately and process the event in a background job. A slow endpoint is the most common cause of avoidable retries.

2 Configuration

How to configure and manage your Webhook URL.

Webhook URL Setup

You can configure your Webhook from the Partner Console.

1
Open the Partner Console

Sign in to the Partner Console.

2
Go to Settings

Navigate to Settings Integrations.

3
Select the Webhook tab

Enter your callback URL on the Webhook tab.

4
Send a test event

Click the "Send test" button to verify that your endpoint receives the event correctly.

⚠️
Important: Webhook URLs must use HTTPS. HTTP is not supported for security reasons.

3 Authentication

How to verify the signature of a Webhook request.

Signature Verification (HMAC-SHA256)

CRYPTOMENTS signs every Webhook request with HMAC-SHA256. Your receiver must verify the signature to confirm the request's origin and integrity.

Signature Data Composition

The signature is generated by joining the core fields with a pipe (|) delimiter:

partnerId|transactionHash|amount|timestamp

Signature Formula

signature = Hex(HMAC-SHA256(api_secret, "partnerId|txHash|amount|timestamp"))
ℹ️
The rule is the same for all five event types. Concatenate the four field values exactly as you received them and the signature verifies — including the events whose transactionHash is the empty string (""), where the empty string itself is what was signed. For P2P_ORDER_COMPLETE, amount holds the same value as settledAmount, so no special handling is needed. settlementMethod, reason, reasonDetail and metadata are not part of the signature data.
Benefits:
  • Simple, clear structure
  • Easy for partners to implement
  • Easy to debug
  • timestamp prevents replay attacks

Webhook Request Format

Item Description Example
Content-Type header Content type application/json
signature (body field) HMAC-SHA256 signature (hex-encoded) a1b2c3d4e5f6...
timestamp (body field) Unix timestamp (seconds) 1710508200
transactionHash (body field) Transaction hash. Empty string ("") when there is no on-chain transaction yet — not an idempotency key 0xabc123def456...
ℹ️
Note: The signing key is your partner-specific API Secret. You can find it in the Partner Console under Integrations.

Verification Implementation (JavaScript)

JavaScript
const crypto = require('crypto');

function verifySignature(body, apiSecret) {
  const { partnerId, transactionHash, amount, timestamp, signature } = body;

  // Build signature data (delimiter |)
  const signatureData = `${partnerId}|${transactionHash}|${amount}|${timestamp}`;

  const computed = crypto
    .createHmac('sha256', apiSecret)
    .update(signatureData)
    .digest('hex');

  return computed === signature;
}

app.post('/webhook', (req, res) => {
  const apiSecret = process.env.API_SECRET;

  if (!verifySignature(req.body, apiSecret)) {
    return res.status(401).json({ error: 'Signature mismatch' });
  }

  res.status(200).json({ success: true });
});

Verification Implementation (Python)

Python
import hmac
import hashlib
from flask import Flask, request

app = Flask(__name__)
API_SECRET = 'your_api_secret'

@app.route('/webhook', methods=['POST'])
def webhook():
    body = request.get_json()

    # Build signature data (delimiter |)
    signature_data = f"{body['partnerId']}|{body['transactionHash']}|{body['amount']}|{body['timestamp']}"

    computed = hmac.new(
        API_SECRET.encode(),
        signature_data.encode(),
        hashlib.sha256
    ).hexdigest()

    # Verify signature
    if computed != body.get('signature'):
        return {'error': 'Signature mismatch'}, 401

    return {'success': True}, 200

Verification Implementation (Java)

Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Map;

@PostMapping("/webhook")
public ResponseEntity<?> receiveWebhook(@RequestBody Map<String, Object> body) {
  try {
    String apiSecret = System.getenv("API_SECRET");

    // Build signature data (delimiter |)
    String signatureData = body.get("partnerId") + "|"
      + body.get("transactionHash") + "|"
      + body.get("amount") + "|"
      + body.get("timestamp");

    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(
      apiSecret.getBytes(StandardCharsets.UTF_8),
      "HmacSHA256"
    ));
    byte[] hash = mac.doFinal(signatureData.getBytes(StandardCharsets.UTF_8));

    // hex encode
    StringBuilder hex = new StringBuilder();
    for (byte b : hash) {
      hex.append(String.format("%02x", b));
    }

    if (!hex.toString().equals(body.get("signature"))) {
      return ResponseEntity.status(401).build();
    }

    return ResponseEntity.ok().build();
  } catch (Exception e) {
    return ResponseEntity.status(500).build();
  }
}
🔒
Security: Store your Webhook Secret in environment variables and never hardcode it in source code. Rotate the Secret immediately if it's exposed.

4 Event Types

Event types sent via Webhook.

Click an event card below to inspect its JSON payload.

Event catalog: exactly five event types are delivered.
  • Deposit (1): DEPOSIT_CONFIRMED
  • Withdrawal (3): WITHDRAWAL_REQUESTED, WITHDRAWAL_COMPLETED, WITHDRAWAL_FAILED
  • P2P deposit order (1): P2P_ORDER_COMPLETE
The three withdrawal events share the same payload shape — branch on eventType, and ignore any event type you do not handle (still answer HTTP 200). See the eventType values table for the full list.
⚠️
There is no approval, rejection, cancellation or P2P-switch event. A withdrawal is notified twice at most: once when it is accepted (WITHDRAWAL_REQUESTED) and once when it is closed (WITHDRAWAL_COMPLETED or WITHDRAWAL_FAILED). Rejection, cancellation and retry exhaustion all arrive as WITHDRAWAL_FAILED, distinguished by the reason field. If your integration waits for an approval event, it will wait forever — no such event is ever sent.
⚠️
Null fields are omitted: a field whose value is null is not included in the JSON at all. Treat "key absent" as "value is null" — for example orderId and metadata simply do not appear when the partner did not supply them.
Deposit DEPOSIT_CONFIRMED
Deposit confirmed. Sent once per deposit.
JSON payload:
{
  "eventType": "DEPOSIT_CONFIRMED",
  "settlementMethod": "ONCHAIN",
  "transactionId": 245,
  "partnerId": "7",
  "userId": "user_001",
  "orderId": "ORDER-20260721-001",
  "orderCode": "dep_9f21c8a04b17",
  "transactionHash": "0xabc123def456...",
  "fromAddress": "0x123456789...",
  "toAddress": "0x987654321...",
  "amount": "997.000000",
  "feeAmount": "3.000000",
  "grossAmount": "1000.000000",
  "reservedAmount": "1000.000000",
  "reservedAmountKrw": "1350000",
  "currencyType": "USDT",
  "chainType": "BSC",
  "depositMethod": "HD_WALLET",
  "status": "CONFIRMED",
  "confirmedAt": "2026-03-15T14:30:00",
  "tokenKrwPrice": "1350.0",
  "tokenUsdPrice": "1.0",
  "krwAmount": "1345950",
  "usdAmount": "997.00",
  "timestamp": "1710508200",
  "signature": "a1b2c3d4e5f6789..."
}
⚠️
amount is the net credit amount, not the on-chain transfer amount. amount = grossAmountfeeAmount, and it is the value you should credit to the user. The amount that moved on-chain — the one you would see when looking the transaction up on an explorer — is grossAmount. krwAmount and usdAmount are converted from the net amount as well, and the signature uses the same net value.
Field descriptions:
  • eventType — Always DEPOSIT_CONFIRMED for deposits
  • settlementMethod — How the money arrived: ONCHAIN (on-chain transfer, transactionHash can be checked on an explorer), INTERNAL (internal settlement, no chain movement, so transactionHash is ""), FIAT (KRW bank transfer — no chain at all). Use this field rather than guessing from the hash
  • transactionId — Internal transaction ID (Long). Used in deposit/withdrawal lookup APIs
  • partnerId — Partner ID
  • userId — Partner-side user ID
  • orderId — Partner-side order ID passed via the widget/API (deposit reservation or Axim payment). The key is absent if none was provided
  • orderCode — CRYPTOMENTS-internal order code for the deposit, for support enquiries. Absent when the deposit did not come from an order
  • amount / feeAmount / grossAmount — Net credit amount / deposit fee / on-chain transfer amount. amount = grossAmountfeeAmount
  • reservedAmount / reservedAmountKrw — Reserved (expected) amount from the deposit reservation, in token / KRW. Compare with amount to detect partial/over deposits. null if there was no reservation
  • depositMethod — Deposit method (see table below)
  • krwAmount / usdAmount — Net amount converted to KRW/USD
  • signature — HMAC-SHA256 signature (for verification)
Deposit-only note: deposit payloads do not contain event, withdrawalId, metadata, reason or reasonDetail — those keys exist on withdrawal events only.
Withdrawal WITHDRAWAL_REQUESTED
Withdrawal request accepted. Sent once, at request time — the two closing events (WITHDRAWAL_COMPLETED / WITHDRAWAL_FAILED) use the same payload shape.
JSON payload:
{
  "eventType": "WITHDRAWAL_REQUESTED",
  "event": "WITHDRAWAL_REQUESTED",
  "withdrawalId": 129,
  "transactionId": 129,
  "partnerId": "7",
  "userId": "user_004",
  "orderId": "WD-20260810-001",
  "transactionHash": "",
  "fromAddress": "0x1111aaaa2222bbbb...",
  "toAddress": "0x789012345...",
  "amount": "300.000000",
  "currencyType": "USDT",
  "chainType": "BSC",
  "status": "PENDING_APPROVAL",
  "tokenKrwPrice": "1350.0",
  "tokenUsdPrice": "1.0",
  "krwAmount": "405000",
  "usdAmount": "300.00",
  "feeAmount": "0",
  "timestamp": "1710508200",
  "signature": "a1b2c3d4e5f6789..."
}
⚠️
Note the empty transactionHash. No transaction exists yet, so the value is "" — the same value for every withdrawal, of every partner. If you key your dedupe table on the hash, the second withdrawal you ever receive will be discarded as a duplicate. Key on eventType + transactionId.
Field descriptions:
  • event — Backward-compatibility duplicate of eventType, with the same value. Kept for partners that already parse it; prefer eventType
  • withdrawalId — Backward-compatibility duplicate of transactionId, with the same value
  • orderId — The order ID you sent on the withdrawal request. The key is absent if you did not send one
  • metadata — Free-form value you sent on the withdrawal request, returned as-is (pass-through, JSON string). The key is absent if you did not send one. Not covered by the signature
  • status — Withdrawal status at the moment the event was sent (REQUESTED or PENDING_APPROVAL for this event, depending on whether approval is required)
  • confirmedAt — Absent until the transaction is confirmed on-chain
  • settlementMethod / reason / reasonDetail — Not applicable to this event; treat them as absent. settlementMethod is filled in on WITHDRAWAL_COMPLETED, reason / reasonDetail on WITHDRAWAL_FAILED
What comes next: the request event tells you the withdrawal was accepted, nothing more. Approval by an administrator does not send anything. The next — and last — event for this withdrawal is either WITHDRAWAL_COMPLETED or WITHDRAWAL_FAILED.
Withdrawal WITHDRAWAL_COMPLETED
Withdrawal closed successfully. The one success event — on-chain and P2P withdrawals both end here, told apart by settlementMethod.
JSON payload:
{
  "eventType": "WITHDRAWAL_COMPLETED",
  "event": "WITHDRAWAL_COMPLETED",
  "withdrawalId": 127,
  "settlementMethod": "ONCHAIN",
  "transactionId": 127,
  "partnerId": "7",
  "userId": "user_002",
  "orderId": "WD-20260721-002",
  "metadata": "{\"memo\":\"payout #55\"}",
  "transactionHash": "0xdef789abc123...",
  "fromAddress": "0x1111aaaa2222bbbb...",
  "toAddress": "0x789012345...",
  "amount": "500.000000",
  "currencyType": "USDC",
  "chainType": "POLYGON",
  "status": "CONFIRMED",
  "confirmedAt": "2026-03-15T15:00:00",
  "tokenKrwPrice": "1350.0",
  "tokenUsdPrice": "1.0",
  "krwAmount": "675000",
  "usdAmount": "500.00",
  "feeAmount": "0",
  "timestamp": "1710508200",
  "signature": "a1b2c3d4e5f6789..."
}
⚠️
Read settlementMethod, not the hash. ONCHAIN means the withdrawal went out on-chain and transactionHash can be verified on an explorer. P2P means it was settled through the P2P (KRW) path; the member's payout did not move on that partner's chain, so transactionHash is "" — an empty hash here is normal, not a failure.
Field descriptions:
  • settlementMethod — How the withdrawal was settled: ONCHAIN or P2P. Present on this event only
  • event / withdrawalId — Backward-compatibility duplicates of eventType / transactionId, carrying the same values
  • orderId — Partner reference / order ID passed on the withdrawal request. The key is absent if none was provided
  • metadata — Free-form value passed on the withdrawal request, returned as-is (pass-through). The key is absent if none was provided
  • feeAmountCurrently always "0" on withdrawal events. Actual withdrawal fees are not reflected in this field yet — do not use it for reconciliation
  • status — The internal withdrawal status at closing time — CONFIRMED for an on-chain withdrawal, COMPLETED for a P2P one. Branch on eventType and settlementMethod, not on this field
  • fromAddress — The partner's MASTER wallet address (the source of the transfer). null — i.e. the key is absent — for partners that have no MASTER wallet on that network
This is the only withdrawal success event. There is no separate on-chain confirmation event: an on-chain withdrawal is reported here with settlementMethod: "ONCHAIN".
Failed WITHDRAWAL_FAILED
Withdrawal closed without paying out. Rejection, cancellation, retry exhaustion and on-chain failure all arrive here — tell them apart with reason.
JSON payload:
{
  "eventType": "WITHDRAWAL_FAILED",
  "event": "WITHDRAWAL_FAILED",
  "withdrawalId": 128,
  "reason": "FAILED",
  "reasonDetail": "on-chain transaction reverted",
  "transactionId": 128,
  "partnerId": "7",
  "userId": "user_003",
  "transactionHash": "",
  "fromAddress": "TQmaster1111aaaa...",
  "toAddress": "T9yD14Nj9j7xAB4dbGeiX9h8...",
  "amount": "200.000000",
  "currencyType": "USDT",
  "chainType": "TRON",
  "status": "FAILED",
  "tokenKrwPrice": "1350.0",
  "tokenUsdPrice": "1.0",
  "krwAmount": "270000",
  "usdAmount": "200.00",
  "feeAmount": "0",
  "timestamp": "1710508200",
  "signature": "a1b2c3d4e5f6789..."
}
Field descriptions:
  • reason — Why the withdrawal was closed without paying out. One of five values (see the table below). Present on this event only
  • reasonDetail — Free-form detail text, when one was recorded. May be absent. Not covered by the signature
  • status — The internal withdrawal status at closing time (FAILED, REJECTED, CANCELLED or EXHAUSTED). Prefer reason for branching
  • transactionHash — Empty string ("") when the withdrawal was closed before a transaction was broadcast. Never use it as an idempotency key
  • confirmedAt — Absent on failure (null values are omitted)
  • Payload structure is the same as the other withdrawal events; branch on eventType, then on reason
reason values:
ValueMeaning
REJECTEDAn administrator rejected the withdrawal
CANCELLED_BY_ADMINAn administrator cancelled the withdrawal
CANCELLED_BY_PARTNERThe partner cancelled its own withdrawal
EXHAUSTEDClosed after automatic retries exceeded the limit
FAILEDThe on-chain transaction failed
P2P deposit P2P_ORDER_COMPLETE
A P2P deposit order was closed. Sent once per order — this is the settlement result for the whole order, not for a single leg.
What this event is. A P2P deposit order is filled by one or more matching legs (P2P, TORQ or PARTNERTORQ is a fixed contract value meaning “LP leg”, not the name of the LP used). Individual legs do not produce a DEPOSIT_CONFIRMED event. When the order closes, this single event reports the confirmed result of the whole order — credit the user with settledAmount on receiving it.
Not sent when nothing was settled: an order that expires or is cancelled with zero settled legs produces no event at all.
⚠️
transactionId here is a P2P deposit order ID — a different ID space from ordinary deposits. A deposit with ID 245 and a P2P order with ID 245 are unrelated records. Keying idempotency on eventType + transactionId keeps them apart automatically, but joining on transactionId alone will collide. The simplest key for this event is eventId, which is unique on its own.
JSON payload (result: FULL — the whole order was settled):
{
  "eventType": "P2P_ORDER_COMPLETE",
  "eventId": "evt_p2po_pdo_22f37cbe04a0_FULL",
  "settlementMethod": "P2P",
  "result": "FULL",
  "transactionId": 3182,
  "partnerId": "7",
  "userId": "user_001",
  "orderId": "ORDER-20260818-001",
  "orderCode": "pdo_22f37cbe04a0",
  "transactionHash": "",
  "settledAmount": "731.481481",
  "settledCurrency": "USDT",
  "settledFeeAmount": "7.407407",
  "settledGrossAmount": "738.888888",
  "settledAmountKrw": "1000000",
  "orderAmount": "1000000",
  "orderCurrency": "KRW",
  "unsettledAmountKrw": "0",
  "amount": "731.481481",
  "currencyType": "USDT",
  "krwAmount": "1000000",
  "usdAmount": "731.481481",
  "legCount": 2,
  "settledLegCount": 2,
  "status": "COMPLETED",
  "closeReason": "ALL_LEGS_SETTLED",
  "closedAt": "2026-08-18T14:31:07",
  "timestamp": "1755495067",
  "signature": "a1b2c3d4e5f6789..."
}
JSON payload (result: PARTIAL — the order closed with only part of it settled):
{
  "eventType": "P2P_ORDER_COMPLETE",
  "eventId": "evt_p2po_pdo_8b40e1d7c93a_PARTIAL",
  "settlementMethod": "P2P",
  "result": "PARTIAL",
  "transactionId": 3190,
  "partnerId": "7",
  "userId": "user_042",
  "orderCode": "pdo_8b40e1d7c93a",
  "transactionHash": "",
  "settledAmount": "219.444444",
  "settledCurrency": "USDT",
  "settledFeeAmount": "2.222222",
  "settledGrossAmount": "221.666666",
  "settledAmountKrw": "300000",
  "orderAmount": "1000000",
  "orderCurrency": "KRW",
  "unsettledAmountKrw": "700000",
  "amount": "219.444444",
  "currencyType": "USDT",
  "krwAmount": "300000",
  "usdAmount": "219.444444",
  "legCount": 3,
  "settledLegCount": 1,
  "status": "PARTIALLY_SETTLED",
  "closeReason": "ORDER_EXPIRED",
  "closedAt": "2026-08-18T15:02:44",
  "timestamp": "1755496964",
  "signature": "b7c8d9e0f1a2345..."
}
Field descriptions:
  • eventType — Always P2P_ORDER_COMPLETE
  • eventId — Deterministic idempotency key, evt_p2po_{orderCode}_{result}. Retries re-send the identical value
  • settlementMethod — Always P2P
  • resultFULL (the whole order was settled) or PARTIAL (the order closed with only part of it settled)
  • transactionIdP2P deposit order ID (Long) — not a deposit ID
  • partnerId / userId — Partner ID / partner-side user ID
  • orderId — The order ID you passed in. The key is absent if you did not send one
  • orderCode — CRYPTOMENTS-internal order code
  • transactionHashAlways "". An order is a bundle of legs, so there is no single chain hash for it
  • settledAmountThe confirmed USDT amount, net of fees. This is the value to credit to the user
  • settledCurrencyUSDT
  • settledFeeAmount — Total buyer-side fee (USDT)
  • settledGrossAmount — Total before fees (USDT)
  • settledAmountKrw — Total settled KRW
  • orderAmount / orderCurrency — Face value of the order, in KRW
  • unsettledAmountKrw — Unsettled KRW = orderAmountsettledAmountKrw
  • amount / currencyType / krwAmount / usdAmount — Duplicates of settledAmount / USDT / settledAmountKrw / settledAmount, carried under the common field names for convenience
  • legCount / settledLegCount — Total matching legs / legs that settled (int)
  • status — Order status: COMPLETED, PARTIALLY_SETTLED, CANCELLED or EXPIRED
  • closeReason — Why the order was closed
  • closedAt — Closing time (yyyy-MM-dd'T'HH:mm:ss)
  • timestamp / signature — Same as every other event
Fields deliberately absent: tokenKrwPrice, tokenUsdPrice and chainType. Each leg can settle at its own rate and on its own network, so there is no single order-level value. If you need an effective rate, compute settledAmountKrw ÷ settledGrossAmount.
Signature: the usual partnerId|transactionHash|amount|timestamp — with transactionHash as the empty string and amount as settledAmount. Concatenating the received field values as-is reproduces it.

📋 Payload Field Reference

Fields common to every Webhook event.

Field Type Required Description
eventType String Event type. 5 values — 1 deposit + 3 withdrawal + 1 P2P deposit order. See the eventType values table below
settlementMethod String How the money moved. Deposit: ONCHAIN / INTERNAL / FIAT. WITHDRAWAL_COMPLETED: ONCHAIN / P2P. P2P_ORDER_COMPLETE: always P2P. Not applicable to WITHDRAWAL_REQUESTED / WITHDRAWAL_FAILED — treat as absent. Use this instead of inferring the path from transactionHash. Not part of the signature data
transactionId Long Internal transaction ID. Deposit / withdrawal events: deposit ID / withdrawal ID. P2P_ORDER_COMPLETE: P2P deposit order ID — a different ID space. Used by the lookup APIs, and as part of the idempotency key together with eventType
eventId String P2P_ORDER_COMPLETE only. Deterministic idempotency key, evt_p2po_{orderCode}_{result}
reason String WITHDRAWAL_FAILED only. Why the withdrawal was closed: REJECTED, CANCELLED_BY_ADMIN, CANCELLED_BY_PARTNER, EXHAUSTED, FAILED. Not part of the signature data
reasonDetail String WITHDRAWAL_FAILED only. Free-form detail text; may be absent. Not part of the signature data
event String Withdrawal events only. Backward-compatibility duplicate of eventType, always carrying the same value
withdrawalId Long Withdrawal events only. Backward-compatibility duplicate of transactionId, always carrying the same value
partnerId String Partner unique ID
userId String Partner-registered user ID
orderId String Partner-side order ID. Deposit: from the widget/API payment session. Withdrawal: the orderId you sent on the request. The key is absent when none was provided
orderCode String CRYPTOMENTS-internal order code, for support enquiries. DEPOSIT_CONFIRMED (absent when the deposit did not come from an order) and P2P_ORDER_COMPLETE
metadata String Withdrawal events only. Free-form value you sent on the withdrawal request, returned as-is (pass-through). The key is absent when none was provided. Not part of the signature data
transactionHash String On-chain transaction hash. Empty string "" (not null) when no on-chain transaction exists yet — e.g. any withdrawal event sent before broadcast. Not usable on its own as an idempotency key — see Idempotency
fromAddress String Sender address. Deposit: the depositor's wallet. Withdrawal: the partner's MASTER wallet address (the source of the transfer); absent for partners with no MASTER wallet on that network
toAddress String Recipient address. Partner wallet for deposits, withdrawal target for withdrawals
amount String Amount in token units (decimal string), and the value used in the signature data. Deposit: the net credit amount (grossAmountfeeAmount) — not the on-chain transfer amount. Withdrawal: the requested withdrawal amount. P2P_ORDER_COMPLETE: same value as settledAmount
grossAmount String Deposit events only. On-chain transfer amount before the deposit fee — use this when reconciling against the chain
reservedAmount String Reserved (expected) token amount from the deposit reservation. Deposit events only; null if there was no reservation
reservedAmountKrw String KRW equivalent of the reserved amount. null if there was no reservation
currencyType String Token symbol. USDT, USDC, etc.
chainType String Blockchain network. BSC, ETHEREUM, POLYGON, TRON, etc.
depositMethod String Deposit method. Included only in deposit events (see table below)
status String Transaction status (see table below)
confirmedAt String Block confirmation time (yyyy-MM-dd'T'HH:mm:ss). null if unconfirmed
tokenKrwPrice String KRW price per token
tokenUsdPrice String USD price per token
krwAmount String KRW value of amount (amount × tokenKrwPrice, integer). For deposits this is the net amount, matching amount
usdAmount String USD value of the amount (amount × tokenUsdPrice, 2 decimal places)
feeAmount String Fee amount. Deposit events: the deposit fee that was deducted (amount = grossAmountfeeAmount). Withdrawal events: limitation — currently always "0"; actual withdrawal fees are not reflected in this field, so do not use it for reconciliation
timestamp String Event timestamp (Unix seconds). Used for signature verification
signature String HMAC-SHA256 signature (hex-encoded). See Authentication for verification
ℹ️
Note: If price lookup fails, tokenKrwPrice, tokenUsdPrice, krwAmount, and usdAmount are all sent as "0". P2P_ORDER_COMPLETE carries neither tokenKrwPrice nor tokenUsdPrice nor chainType — an order is settled leg by leg, each with its own rate and network.
P2P_ORDER_COMPLETE-only fields: eventId, result, settledAmount, settledCurrency, settledFeeAmount, settledGrossAmount, settledAmountKrw, orderAmount, orderCurrency, unsettledAmountKrw, legCount, settledLegCount, closeReason, closedAt. They are described in the P2P_ORDER_COMPLETE event card above.

📡 eventType Values

ValueDescriptionWhen triggered
DEPOSIT_CONFIRMED Deposit confirmed When a deposit is confirmed — an on-chain deposit transaction, or a completed PhonePay (KRW) deposit. Legs of a P2P deposit order are excluded (see P2P_ORDER_COMPLETE)
WITHDRAWAL_REQUESTED Withdrawal request accepted Immediately after a withdrawal request is accepted (sent once, whether the initial status is REQUESTED or PENDING_APPROVAL)
WITHDRAWAL_COMPLETED Closed — paid out When the withdrawal is confirmed on-chain (settlementMethod: ONCHAIN), or when a P2P (KRW) withdrawal is closed by settlement, conversion, cancellation or a forced settlement (settlementMethod: P2P)
WITHDRAWAL_FAILED Closed — not paid out When the withdrawal is rejected, cancelled, closed after retry exhaustion, or fails on-chain. The reason field says which
P2P_ORDER_COMPLETE P2P deposit order closed Once, when a P2P deposit order is closed with at least one settled leg. Not sent when nothing was settled

🔄 status Values

Deposits and withdrawals follow separate status flows. The status sent in the Webhook is the current status of that transaction.

Deposit Status

ValueDescriptionWebhook sent
DETECTED Deposit transaction detected on-chain (before block confirmation)
CONFIRMING Block confirmation in progress (waiting for required confirmations)
CONFIRMED Confirmed on-chain. Deposit is valid DEPOSIT_CONFIRMED
NOTIFIED Webhook successfully delivered to the partner ❌ (internal)
COLLECTING Sweeping funds from HOT/POOL wallet to MASTER wallet ❌ (internal)
SETTLED Final state. Sweep finalized and included in settlement ❌ (internal)
FAILED Deposit processing failed (chain error, confirmation failure, etc.)
ℹ️
Note: A deposit Webhook is sent only once, when the status is CONFIRMED. Subsequent state changes (NOTIFIED → COLLECTING → SETTLED) do not trigger additional Webhooks.

Withdrawal Status

ValueDescriptionWebhook sent
REQUESTED Withdrawal request received WITHDRAWAL_REQUESTED
PENDING_APPROVAL Waiting for admin approval WITHDRAWAL_REQUESTED (the request-time event carries whichever of these two statuses applies)
REJECTED Rejected by an administrator WITHDRAWAL_FAILED (reason: REJECTED)
APPROVED Approved by an administrator approval sends nothing
P2P_PENDING Switched to the P2P (KRW) path — awaiting matching ❌ the switch itself is not notified
PROCESSING Withdrawal processing (transaction creation & signing)
BROADCASTING Broadcasting transaction to the blockchain
CONFIRMED Withdrawal transaction confirmed on-chain WITHDRAWAL_COMPLETED (settlementMethod: ONCHAIN)
COMPLETED Closed — P2P (KRW) withdrawal settlement / residual handling finished WITHDRAWAL_COMPLETED (settlementMethod: P2P)
FAILED Error during withdrawal (insufficient gas, balance, chain error, etc.) WITHDRAWAL_FAILED (reason: FAILED)
CANCELLED Withdrawal cancelled (by the partner or an administrator) WITHDRAWAL_FAILED (reason: CANCELLED_BY_ADMIN / CANCELLED_BY_PARTNER)
STALE Unconfirmed for more than 1 hour. Flagged by the safety-net scheduler for manual review
EXHAUSTED Closed after automatic retries exceeded the limit WITHDRAWAL_FAILED (reason: EXHAUSTED)
⚠️
Important: a withdrawal produces at most two Webhooks — one when it is accepted, one when it is closed. Approval, the switch to the P2P path, PROCESSING, BROADCASTING and STALE send nothing at all. Every unsuccessful ending (rejection, cancellation, retry exhaustion, on-chain failure) arrives as a single WITHDRAWAL_FAILED that you distinguish by reason. Branch on eventType and ignore the events you do not handle — but always answer HTTP 200 so they are not retried.

🚫 When no Webhook is sent

Cases where a transaction really happens but produces no event. Do not build a reconciliation that waits for one.

CaseWhat is sent instead
Individual legs of a P2P deposit order (P2P / TORQ / PARTNER legs; TORQ = LP leg) No DEPOSIT_CONFIRMED per leg. One P2P_ORDER_COMPLETE when the order closes
A P2P deposit order that closes with nothing settled (expired or fully cancelled) Nothing at all
A standalone LP on-ramp deposit (not part of a P2P order) A normal DEPOSIT_CONFIRMED with depositMethod: TORQ
Derived withdrawals — the internal transfer that moves the residual of a P2P withdrawal out as USDT Nothing, on success or failure. The original withdrawal was already closed with WITHDRAWAL_COMPLETED, so a second notification would double-report it
Partner settlement-share withdrawals (revenue pay-outs) Nothing. This is a revenue withdrawal, not a movement of partner customer funds
Withdrawal approval, and the switch to the P2P path Nothing. Only the closing event is sent

💰 depositMethod Values

A field included only in DEPOSIT_CONFIRMED events, indicating how the deposit was made.

ValueDescription
HD_WALLET Deposit via an HD-derived wallet address. Each user gets a unique address; the depositor is identified by the TO address.
EXTERNAL_WALLET Deposit from an externally linked wallet (e.g. Axim). The depositor is identified by the FROM address.
DECIMAL_MATCH Decimal-tail matching deposit. A unique 4-digit decimal tail is assigned per depositor on a shared address.
DIRECT Direct top-up by the partner. The partner sends tokens directly to the MASTER wallet.
MANUAL Manual entry by an administrator. Used when an inbound deposit can't be processed automatically.
PHONEPAY PhonePay (BanqPipe) KRW deposit. An indirect deposit via KRW payment.
TORQ KRW → USDT on-ramp through a liquidity provider (LP). The value is a fixed contract string — it does not name the LP actually used, which can differ per partner.
P2P P2P matching deposit — an individual withdrawer is matched with a buyer. Sent only for a standalone P2P deposit record; legs of a P2P deposit order are reported by P2P_ORDER_COMPLETE instead.
⚠️
A P2P deposit order does not send deposit events. An order is filled by several matching legs, and no leg emits DEPOSIT_CONFIRMED. Credit the user when the order closes and P2P_ORDER_COMPLETE arrives. A standalone LP on-ramp deposit that is not part of a P2P order does send a normal DEPOSIT_CONFIRMED with depositMethod: TORQ (a fixed contract value meaning "LP on-ramp").

⛓️ chainType / currencyType Values

Supported blockchain networks and tokens.

chainType (blockchain network)

ValueNetworkNotes
BSCBNB Smart Chain (BEP-20)BNB gas, low fees
ETHEREUMEthereum (ERC-20)ETH gas
POLYGONPolygon PoS (ERC-20)MATIC gas, low fees
TRONTRON (TRC-20)Energy/bandwidth based, can be gasless

currencyType (token)

ValueTokenSupported chains
USDTTether USDBSC, ETHEREUM, POLYGON, TRON
USDCUSD CoinBSC, ETHEREUM, POLYGON

5 Retry Policy

How automatic retries work when Webhook delivery fails.

Delivery counts as successful when your server answers with an HTTP 2xx status; the response body is not inspected. Anything else — an error status, a connection failure, a response that never arrives — makes the event eligible for retry. There are 5 attempts in total: the first delivery plus 4 retries.

Retry Schedule

1
First attempt
2
+30 sec
3
+1 min
4
+5 min
5
+15 min
⚠️
These intervals are nominal, and delivery is never instant. Events are dispatched by a job that polls every 10 seconds, so the first attempt can be up to about 10 seconds after the event. Retries are re-armed by a second job that runs every 60 seconds, so an actual retry can land up to about 70 seconds later than the nominal interval. Do not build timing assumptions on the exact figures, and note that there is no published per-request timeout — a slow endpoint may be treated as a failure.

After All Retries Fail

After the fifth attempt the event is closed as FAILED — there is no further automatic attempt. Partners can view it in the "Failed Webhooks" section of the console and trigger a manual re-send.

💡
Recommended: Plan Webhook processing time around your server maintenance and deploy windows. If you notify the CRYPTOMENTS support team of scheduled infrastructure maintenance, retries can be paused during that period.

6 Telegram Alerts

Receive real-time notifications via a Telegram bot alongside Webhooks.

In addition to Webhook events, CRYPTOMENTS supports two-channel notifications via a Telegram bot. Webhooks are for system integration; Telegram is for operators to monitor status in real time. The two channels are independent: the Telegram alert types below are operator messages and do not map one-to-one onto Webhook events — an alert here does not imply that a Webhook of the same name exists.

Supported Notification Types

NotificationDescriptionExample fields
Deposit detectedDeposit TX detected on-chain (awaiting confirmation)Deposit code, amount, network, TX hash
Deposit confirmedDeposit confirmed on-chainDeposit code, amount, network, TX hash
Large depositDeposit exceeding the alert thresholdDeposit code, amount, threshold, network
Withdrawal requestedNew withdrawal request receivedWithdrawal code, amount, recipient, status
Withdrawal approvedWithdrawal request approvedWithdrawal code, amount, approver
Withdrawal rejectedWithdrawal rejected by an administratorWithdrawal code, amount, reason
Withdrawal completedWithdrawal confirmed on-chainWithdrawal code, amount, recipient, TX hash
Withdrawal failedError during withdrawal processingWithdrawal code, amount, network, reason
Withdrawal cancelledWithdrawal request cancelledWithdrawal code, amount
💬
Setup: Connect the bot from Partner Console > Settings > Integrations > Telegram tab. Search for the bot ID @CryptomentsBot in Telegram, then authenticate with the /start or /verify command. See Partner Guide > Integrations for details.

7 Idempotency

How to prevent duplicate processing.

Network delays and retries can cause the same event to be delivered multiple times. Deduplicate with the pair eventType + transactionId, which is unique per event across both deposits and withdrawals.

⚠️
Do not use transactionHash alone as the idempotency key. WITHDRAWAL_REQUESTED, any withdrawal closed before broadcast, a P2P-settled WITHDRAWAL_COMPLETED, an internally settled DEPOSIT_CONFIRMED and every P2P_ORDER_COMPLETE carry "transactionHash": "" — an empty string, not null, and identical across every such event. With a UNIQUE index on the hash alone, the first "" event is stored and every later notification is silently discarded as a duplicate.
Every event: use eventType + transactionId. It is unique for all five event types, so the simplest option is to use it everywhere.
P2P_ORDER_COMPLETE: eventId is unique on its own and can be used directly.
⚠️
P2P_ORDER_COMPLETE uses a different transactionId space. Its transactionId is a P2P deposit order ID, unrelated to deposit and withdrawal IDs — the same number can legitimately appear on a deposit and on a P2P order. The eventType + transactionId key separates them automatically, but joining your records on transactionId alone will mix unrelated transactions together. If you would rather not think about it, store eventId for this event: it is deterministic (evt_p2po_{orderCode}_{result}), identical across retries, and unique by itself.

Idempotency Implementation

Your receiver should guarantee idempotency as follows:

1
Store the event key

Save the eventType + transactionId pair of every processed event in your database.

2
Check for duplicates

On receiving a Webhook, first check whether that eventType + transactionId pair has already been processed.

3
Respond on duplicate

If it's a duplicate, return HTTP 200 immediately (not an error).

Idempotency Example (JavaScript)

JavaScript
app.post('/webhook', async (req, res) => {
  // Idempotency key = eventType + transactionId (transactionHash is "" before broadcast)
  const { eventType, transactionId } = req.body;

  try {
    // 1. Check for a duplicate event key
    const existing = await db.webhookEvents.findOne({
      event_type: eventType,
      transaction_id: transactionId
    });
    if (existing) {
      return res.status(200).json({
        success: true,
        message: 'Event already processed'
      });
    }

    // 2. Verify signature (omitted)

    // 3. Handle event
    await processEvent(req.body);

    // 4. Store the event key
    await db.webhookEvents.insertOne({
      event_type: eventType,
      transaction_id: transactionId,
      tx_hash: req.body.transactionHash,  // reference only — may be ""
      processed_at: new Date()
    });

    res.status(200).json({ success: true });
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: 'Internal error' });
  }
});
Recommended Database Index:
SQL
-- Composite key. Never put a UNIQUE index on tx_hash alone:
-- withdrawals send tx_hash = "" until the transaction is broadcast.
CREATE UNIQUE INDEX uk_event ON webhook_events(event_type, transaction_id);

Withdrawal Request Idempotency (orderId)

The rules above are about the events you receive. The withdrawal request API you call has its own duplicate protection, driven by the orderId you send.

💡
Always send an orderId on withdrawal requests. It is the only way to be sure a retried or double-submitted request does not create a second withdrawal.
Case Behaviour
Same orderId, same request content Idempotent — the existing withdrawal is returned as-is. No new withdrawal is created.
Same orderId, different request content
(amount, destination address, currency or network differs)
409 Conflict. Use a different orderId for a genuinely different withdrawal.
Reusing the orderId of a closed-failed withdrawal
(FAILED / CANCELLED / REJECTED / EXHAUSTED)
Allowed — the order key of a closed-failed withdrawal is released and can be reused for a fresh attempt.
No orderId, same conditions repeated within 10 seconds
(same partner + user + currency + network + amount + destination address)
409 Conflict (double-click protection). The error message contains the withdrawalCode of the existing withdrawal so you can match it up.
⚠️
Double-click protection applies only to requests without an orderId. When you send an orderId, the idempotency rules above take over instead — two different orderIds are treated as two different withdrawals, even if everything else matches.

8 Receiver Implementation

Guide to implementing a Webhook receiver server.

Best Practices

  • Respond fast: Reply with HTTP 200 immediately on receiving the Webhook, and handle actual processing in a background job (queue, message broker).
  • Verify signature: Verify the signature on every request to confirm the origin.
  • Idempotency: Use eventType + transactionId to prevent duplicate processing — never transactionHash on its own.
  • Tolerate unknown events: Five event types are sent today and more may be added. Answer HTTP 200 to any eventType you do not handle instead of erroring, so it is not retried.
  • Logging: Log every Webhook received and its processing outcome.
  • Error handling: Design failures to be retryable.

Node.js (Express) Example

JavaScript
const express = require('express');
const crypto = require('crypto');
const Bull = require('bull');

const app = express();
const webhookQueue = new Bull('webhooks');
const API_SECRET = process.env.API_SECRET;

app.use(express.json());

// Signature verification helper
function verifySignature(body) {
  const { partnerId, transactionHash, amount, timestamp, signature } = body;
  const signatureData = `${partnerId}|${transactionHash}|${amount}|${timestamp}`;
  const computed = crypto
    .createHmac('sha256', API_SECRET)
    .update(signatureData)
    .digest('hex');
  return computed === signature;
}

// Webhook handler
app.post('/webhook', async (req, res) => {
  // 1. Verify signature
  if (!verifySignature(req.body)) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  // 2. Check for duplicates (eventType + transactionId)
  const exists = await db.webhookEvents.findOne({
    event_type: req.body.eventType,
    transaction_id: req.body.transactionId
  });
  if (exists) {
    return res.status(200).json({ success: true });
  }

  // 3. Enqueue and respond immediately
  await webhookQueue.add(req.body);
  res.status(200).json({ success: true });
});

// Background job
webhookQueue.process(async (job) => {
  const data = job.data;
  try {
    switch (data.eventType) {
      case 'DEPOSIT_CONFIRMED':
        // data.amount is net (grossAmount - feeAmount) — credit this value
        await handleDepositConfirmed(data);
        break;
      case 'P2P_ORDER_COMPLETE':
        // P2P deposit order closed. Credit data.settledAmount.
        // data.transactionId is an ORDER id — a different id space from deposits
        await handleP2pOrderComplete(data);
        break;
      case 'WITHDRAWAL_REQUESTED':
        // Accepted. Approval is not notified — the next event closes the withdrawal
        await handleWithdrawalRequested(data);
        break;
      case 'WITHDRAWAL_COMPLETED':
        // Paid out. data.settlementMethod is 'ONCHAIN' or 'P2P'
        await handleWithdrawalCompleted(data);
        break;
      case 'WITHDRAWAL_FAILED':
        // Closed without paying out. data.reason: REJECTED | CANCELLED_BY_ADMIN |
        //   CANCELLED_BY_PARTNER | EXHAUSTED | FAILED
        await handleWithdrawalFailed(data);
        break;
      default:
        // unknown event type — ignore, but still acknowledge
        break;
    }
    await db.webhookEvents.insertOne({
      event_type: data.eventType,
      transaction_id: data.transactionId,
      tx_hash: data.transactionHash,
      processed_at: new Date()
    });
  } catch (error) {
    console.error('Webhook processing error:', error);
    throw error;
  }
});

app.listen(3000);

Python (Flask) Example

Python
from flask import Flask, request, jsonify
from celery import Celery
import hmac
import hashlib

app = Flask(__name__)
celery = Celery(app.name, broker='redis://localhost:6379')

API_SECRET = 'your_api_secret'

def verify_signature(body):
    signature_data = f"{body['partnerId']}|{body['transactionHash']}|{body['amount']}|{body['timestamp']}"
    computed = hmac.new(
        API_SECRET.encode(),
        signature_data.encode(),
        hashlib.sha256
    ).hexdigest()
    return computed == body.get('signature')

@app.route('/webhook', methods=['POST'])
def receive_webhook():
    body = request.get_json()

    # 1. Verify signature
    if not verify_signature(body):
        return jsonify({'error': 'Unauthorized'}), 401

    # 2. Check for duplicates (eventType + transactionId)
    event_key = {'event_type': body['eventType'], 'transaction_id': body['transactionId']}
    if db.webhook_events.find_one(event_key):
        return jsonify({'success': True}), 200

    # 3. Process async and respond immediately
    process_webhook.delay(body)
    return jsonify({'success': True}), 200

@celery.task
def process_webhook(data):
    event_type = data['eventType']
    try:
        # 5 event types are sent. Anything else: ignore, but still answer 200
        if event_type == 'DEPOSIT_CONFIRMED':
            # data['amount'] is net (grossAmount - feeAmount) — credit this value
            handle_deposit_confirmed(data)
        elif event_type == 'P2P_ORDER_COMPLETE':
            # P2P deposit order closed. Credit data['settledAmount'].
            # data['transactionId'] is an ORDER id — a different id space from deposits
            handle_p2p_order_complete(data)
        elif event_type == 'WITHDRAWAL_REQUESTED':
            # Accepted. Approval is not notified — the next event closes the withdrawal
            handle_withdrawal_requested(data)
        elif event_type == 'WITHDRAWAL_COMPLETED':
            # Paid out. data['settlementMethod'] is 'ONCHAIN' or 'P2P'
            handle_withdrawal_completed(data)
        elif event_type == 'WITHDRAWAL_FAILED':
            # Closed without paying out. data['reason']: REJECTED | CANCELLED_BY_ADMIN |
            #   CANCELLED_BY_PARTNER | EXHAUSTED | FAILED
            handle_withdrawal_failed(data)

        db.webhook_events.insert_one({
            'event_type': event_type,
            'transaction_id': data['transactionId'],
            'tx_hash': data['transactionHash'],
            'processed_at': datetime.utcnow()
        })
    except Exception as e:
        print(f'Error processing webhook: {e}')
        raise

if __name__ == '__main__':
    app.run()

9 Troubleshooting

Common issues and how to fix them.

Common Issues

Issue Cause Resolution
Webhook not received Misconfigured URL, network firewall 1. Confirm the URL is HTTPS
2. Use "Send test" in the console to verify connectivity
3. Review firewall settings
Signature verification fails Wrong API Secret, mismatched signature data 1. Re-check API Secret
2. Verify the order: partnerId|txHash|amount|timestamp
3. Confirm hex encoding
Deliveries reported as failed although the endpoint answers Slow server response, heavy synchronous processing, or a non-2xx status code (only 2xx counts as success) 1. Respond with HTTP 200 immediately, then process async
2. Remove synchronous processing from the request path
3. Optimize DB queries
The withdrawal approval Webhook never arrives / no Webhook when a withdrawal switches to P2P Those events do not exist — earlier versions of this document listed them in error 1. Approval and the P2P switch are not notified
2. Only the closing event is sent — branch on WITHDRAWAL_COMPLETED / WITHDRAWAL_FAILED
3. For a rejection or cancellation, read reason on WITHDRAWAL_FAILED
A P2P deposit arrives but no DEPOSIT_CONFIRMED comes The legs of a P2P deposit order are not notified individually 1. Handle P2P_ORDER_COMPLETE, which is sent when the order closes
2. Credit settledAmount
3. If nothing was settled, no event is sent at all
SSL/TLS error Expired or self-signed certificate 1. Check certificate validity
2. Renew the certificate
3. Use a trusted CA
Duplicate processing Idempotency not implemented 1. Persist the eventType + transactionId pair
2. Check that pair before processing
3. Use a composite UNIQUE index (event_type, transaction_id)
Withdrawal notifications stop after the first one UNIQUE index on transactionHash alone — withdrawals send "" before broadcast, so every later event looks like a duplicate 1. Switch the key to eventType + transactionId
2. Drop the UNIQUE index on tx_hash
3. Re-send the missed events from the console
Duplicate withdrawals created Withdrawal request sent without an orderId and retried 1. Always send an orderId on withdrawal requests
2. Retry with the same orderId — the existing withdrawal is returned
3. A 409 means the same orderId was reused with different content
Console shows failure All 5 delivery attempts failed 1. Check server health
2. Click "Manual re-send" in the console
3. Inspect logs

Debugging Tips

🔍
Signature debugging: Log the received partnerId, transactionHash, amount, and timestamp, then regenerate the signature from the partnerId|txHash|amount|timestamp string and compare.

Monitoring

For Webhook reliability, monitor the following:

  • Response time: Keep your Webhook endpoint's response time low — investigate any upward trend, since a slow endpoint drives avoidable retries
  • Error rate: Track HTTP 5xx error increases
  • Retry rate: A high retry ratio is a sign to inspect your server
  • Event lag: Monitor the delay between event creation and processing
⚠️
Important: Notify the CRYPTOMENTS support team in advance about scheduled server maintenance or deploys so retry timing can be adjusted.

Need help? Contact support.

Last updated: August 18, 2026

CRYPTOMENTS Webhook v2.1 © 2026