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 emptytransactionHash("") until the transaction is broadcast, andP2P_ORDER_COMPLETEalways carries"", so the hash must never be used alone as a key.
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
2 Configuration
How to configure and manage your Webhook URL.
Webhook URL Setup
You can configure your Webhook from the Partner Console.
Sign in to the Partner Console.
Navigate to .
Enter your callback URL on the Webhook tab.
Click the "Send test" button to verify that your endpoint receives the event correctly.
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"))
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.
- Simple, clear structure
- Easy for partners to implement
- Easy to debug
timestampprevents 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... |
API Secret. You can find it in the Partner Console under Integrations.
Verification Implementation (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)
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)
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(); } }
4 Event Types
Event types sent via Webhook.
Click an event card below to inspect its JSON payload.
- Deposit (1):
DEPOSIT_CONFIRMED - Withdrawal (3):
WITHDRAWAL_REQUESTED,WITHDRAWAL_COMPLETED,WITHDRAWAL_FAILED - P2P deposit order (1):
P2P_ORDER_COMPLETE
eventType, and ignore any event type you do not handle (still answer HTTP 200). See the eventType values table for the full list.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 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.
{
"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 = grossAmount − feeAmount, 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.
eventType— AlwaysDEPOSIT_CONFIRMEDfor depositssettlementMethod— How the money arrived:ONCHAIN(on-chain transfer,transactionHashcan be checked on an explorer),INTERNAL(internal settlement, no chain movement, sotransactionHashis""),FIAT(KRW bank transfer — no chain at all). Use this field rather than guessing from the hashtransactionId— Internal transaction ID (Long). Used in deposit/withdrawal lookup APIspartnerId— Partner IDuserId— Partner-side user IDorderId— Partner-side order ID passed via the widget/API (deposit reservation or Axim payment). The key is absent if none was providedorderCode— CRYPTOMENTS-internal order code for the deposit, for support enquiries. Absent when the deposit did not come from an orderamount/feeAmount/grossAmount— Net credit amount / deposit fee / on-chain transfer amount.amount=grossAmount−feeAmountreservedAmount/reservedAmountKrw— Reserved (expected) amount from the deposit reservation, in token / KRW. Compare withamountto detect partial/over deposits. null if there was no reservationdepositMethod— Deposit method (see table below)krwAmount/usdAmount— Netamountconverted to KRW/USDsignature— HMAC-SHA256 signature (for verification)
event, withdrawalId, metadata, reason or reasonDetail — those keys exist on withdrawal events only.
{
"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..."
}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.
event— Backward-compatibility duplicate ofeventType, with the same value. Kept for partners that already parse it; prefereventTypewithdrawalId— Backward-compatibility duplicate oftransactionId, with the same valueorderId— The order ID you sent on the withdrawal request. The key is absent if you did not send onemetadata— 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 signaturestatus— Withdrawal status at the moment the event was sent (REQUESTEDorPENDING_APPROVALfor this event, depending on whether approval is required)confirmedAt— Absent until the transaction is confirmed on-chainsettlementMethod/reason/reasonDetail— Not applicable to this event; treat them as absent.settlementMethodis filled in onWITHDRAWAL_COMPLETED,reason/reasonDetailonWITHDRAWAL_FAILED
WITHDRAWAL_COMPLETED or WITHDRAWAL_FAILED.
{
"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..."
}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.
settlementMethod— How the withdrawal was settled:ONCHAINorP2P. Present on this event onlyevent/withdrawalId— Backward-compatibility duplicates ofeventType/transactionId, carrying the same valuesorderId— Partner reference / order ID passed on the withdrawal request. The key is absent if none was providedmetadata— Free-form value passed on the withdrawal request, returned as-is (pass-through). The key is absent if none was providedfeeAmount— Currently always"0"on withdrawal events. Actual withdrawal fees are not reflected in this field yet — do not use it for reconciliationstatus— The internal withdrawal status at closing time —CONFIRMEDfor an on-chain withdrawal,COMPLETEDfor a P2P one. Branch oneventTypeandsettlementMethod, not on this fieldfromAddress— 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
settlementMethod: "ONCHAIN".
{
"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..."
}reason— Why the withdrawal was closed without paying out. One of five values (see the table below). Present on this event onlyreasonDetail— Free-form detail text, when one was recorded. May be absent. Not covered by the signaturestatus— The internal withdrawal status at closing time (FAILED,REJECTED,CANCELLEDorEXHAUSTED). Preferreasonfor branchingtransactionHash— Empty string ("") when the withdrawal was closed before a transaction was broadcast. Never use it as an idempotency keyconfirmedAt— Absent on failure (null values are omitted)- Payload structure is the same as the other withdrawal events; branch on
eventType, then onreason
| Value | Meaning |
|---|---|
REJECTED | An administrator rejected the withdrawal |
CANCELLED_BY_ADMIN | An administrator cancelled the withdrawal |
CANCELLED_BY_PARTNER | The partner cancelled its own withdrawal |
EXHAUSTED | Closed after automatic retries exceeded the limit |
FAILED | The on-chain transaction failed |
P2P, TORQ or PARTNER — TORQ 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.
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.
{
"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..."
}{
"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..."
}eventType— AlwaysP2P_ORDER_COMPLETEeventId— Deterministic idempotency key,evt_p2po_{orderCode}_{result}. Retries re-send the identical valuesettlementMethod— AlwaysP2Presult—FULL(the whole order was settled) orPARTIAL(the order closed with only part of it settled)transactionId— P2P deposit order ID (Long) — not a deposit IDpartnerId/userId— Partner ID / partner-side user IDorderId— The order ID you passed in. The key is absent if you did not send oneorderCode— CRYPTOMENTS-internal order codetransactionHash— Always"". An order is a bundle of legs, so there is no single chain hash for itsettledAmount— The confirmed USDT amount, net of fees. This is the value to credit to the usersettledCurrency—USDTsettledFeeAmount— Total buyer-side fee (USDT)settledGrossAmount— Total before fees (USDT)settledAmountKrw— Total settled KRWorderAmount/orderCurrency— Face value of the order, inKRWunsettledAmountKrw— Unsettled KRW =orderAmount−settledAmountKrwamount/currencyType/krwAmount/usdAmount— Duplicates ofsettledAmount/USDT/settledAmountKrw/settledAmount, carried under the common field names for conveniencelegCount/settledLegCount— Total matching legs / legs that settled (int)status— Order status:COMPLETED,PARTIALLY_SETTLED,CANCELLEDorEXPIREDcloseReason— Why the order was closedclosedAt— Closing time (yyyy-MM-dd'T'HH:mm:ss)timestamp/signature— Same as every other event
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.
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 (grossAmount − feeAmount) — 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 = grossAmount − feeAmount). 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 |
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
| Value | Description | When 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
| Value | Description | Webhook 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.) | ❌ |
CONFIRMED. Subsequent state changes (NOTIFIED → COLLECTING → SETTLED) do not trigger additional Webhooks.
Withdrawal Status
| Value | Description | Webhook 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) |
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.
| Case | What 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.
| Value | Description |
|---|---|
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. |
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)
| Value | Network | Notes |
|---|---|---|
BSC | BNB Smart Chain (BEP-20) | BNB gas, low fees |
ETHEREUM | Ethereum (ERC-20) | ETH gas |
POLYGON | Polygon PoS (ERC-20) | MATIC gas, low fees |
TRON | TRON (TRC-20) | Energy/bandwidth based, can be gasless |
currencyType (token)
| Value | Token | Supported chains |
|---|---|---|
USDT | Tether USD | BSC, ETHEREUM, POLYGON, TRON |
USDC | USD Coin | BSC, 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
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.
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
| Notification | Description | Example fields |
|---|---|---|
| Deposit detected | Deposit TX detected on-chain (awaiting confirmation) | Deposit code, amount, network, TX hash |
| Deposit confirmed | Deposit confirmed on-chain | Deposit code, amount, network, TX hash |
| Large deposit | Deposit exceeding the alert threshold | Deposit code, amount, threshold, network |
| Withdrawal requested | New withdrawal request received | Withdrawal code, amount, recipient, status |
| Withdrawal approved | Withdrawal request approved | Withdrawal code, amount, approver |
| Withdrawal rejected | Withdrawal rejected by an administrator | Withdrawal code, amount, reason |
| Withdrawal completed | Withdrawal confirmed on-chain | Withdrawal code, amount, recipient, TX hash |
| Withdrawal failed | Error during withdrawal processing | Withdrawal code, amount, network, reason |
| Withdrawal cancelled | Withdrawal request cancelled | Withdrawal code, amount |
@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.
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.
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:
Save the eventType + transactionId pair of every processed event in your database.
On receiving a Webhook, first check whether that eventType + transactionId pair has already been processed.
If it's a duplicate, return HTTP 200 immediately (not an error).
Idempotency Example (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' }); } });
-- 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.
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. |
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+transactionIdto prevent duplicate processing — nevertransactionHashon its own. - Tolerate unknown events: Five event types are sent today and more may be added. Answer HTTP 200 to any
eventTypeyou 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
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
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_FAILED3. 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 closes2. Credit settledAmount3. 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 requests2. Retry with the same orderId — the existing withdrawal is returned3. 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
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