TONTONDocs

TON Pay webhooks

Beta version

Webhook functionality is in closed beta. Interfaces or behavior may change in the future.

TON Pay uses webhooks to notify applications in real-time about transfer events.

How webhooks work

  1. A transfer is completed on-chain and indexed by TON Pay.
  2. TON Pay generates a webhook payload and signs it using HMAC-SHA256 and the optional API key when it is configured.
  3. TON Pay sends a POST request to the webhook URL with the signed payload. The request includes the X-TonPay-Signature header for verification.
  4. Verify the signature and process the event payload. Return a 2xx status code to acknowledge receipt.

Important

The webhook URL must be publicly accessible and accept POST requests from the TON Pay backend.

Configure the webhook URL

Configure the endpoint in TON Pay Merchant Dashboard.

  1. Open the TON Pay Merchant Dashboard and sign in to the merchant account.
  2. Navigate to the Developer section, then select Webhooks.
  3. Enter the webhook URL. In production, the endpoint must use HTTPS. For example, https://thedomain.com/webhooks/tonpay.
  4. Save the configuration and use the test feature to verify delivery.

Built-in test feature

Use a built-in test feature in the dashboard to send sample webhooks before going live.

Webhook payload

Event types

import {
  type WebhookPayload,
  type WebhookEventType,
  type TransferCompletedWebhookPayload,
  type TransferRefundedWebhookPayload, // Coming soon
} from "@ton-pay/api";

// Event types
type WebhookEventType =
  | "transfer.completed"
  | "transfer.refunded"; // Coming soon

// Union type for all webhook payloads
type WebhookPayload =
  | TransferCompletedWebhookPayload
  | TransferRefundedWebhookPayload; // Coming soon

transfer.completed

interface TransferCompletedWebhookPayload {
  event: "transfer.completed";
  timestamp: string;
  data: CompletedTonPayTransferInfo; // See API reference
}

Type safety

Webhook types are exported from @ton-pay/api. Use WebhookPayload for type safety in webhook handlers.

Example payloads

Successful transfer:

{
  "event": "transfer.completed",
  "timestamp": "2024-01-15T14:30:00.000Z",
  "data": {
    "amount": "10.5",
    "rawAmount": "10500000000",
    "senderAddr": "<SENDER_ADDR>",
    "recipientAddr": "<RECIPIENT_ADDR>",
    "asset": "TON",
    "assetTicker": "TON",
    "status": "success",
    "reference": "<REFERENCE>",
    "bodyBase64Hash": "<BODY_BASE64_HASH>",
    "txHash": "<TX_HASH>",
    "traceId": "<TRACE_ID>",
    "commentToSender": "Thanks for the purchase",
    "commentToRecipient": "Payment for order #1234",
    "date": "2024-01-15T14:30:00.000Z"
  }
}

Failed transfer:

{
  "event": "transfer.completed",
  "timestamp": "2024-01-15T14:35:00.000Z",
  "data": {
    "amount": "10.5",
    "rawAmount": "10500000000",
    "senderAddr": "<SENDER_ADDR>",
    "recipientAddr": "<RECIPIENT_ADDR>",
    "asset": "TON",
    "assetTicker": "TON",
    "status": "failed",
    "reference": "<REFERENCE>",
    "bodyBase64Hash": "<BODY_BASE64_HASH>",
    "txHash": "<TX_HASH>",
    "traceId": "<TRACE_ID>",
    "commentToSender": "Transaction failed",
    "commentToRecipient": "Payment for order #5678",
    "date": "2024-01-15T14:35:00.000Z",
    "errorCode": 36,
    "errorMessage": "Not enough TON"
  }
}

Placeholders:

  • <SENDER_ADDR> - sender wallet address.
  • <RECIPIENT_ADDR> - recipient wallet address.
  • <REFERENCE> - transfer reference returned by createTonPayTransfer.
  • <BODY_BASE64_HASH> - Base64 hash of the transfer body.
  • <TX_HASH> - transaction hash.
  • <TRACE_ID> - trace identifier.

Payload fields

eventstringrequired

Event type. transfer.completed indicates that on-chain processing is finished. The transfer may be successful or failed; check data.status for the result.

timestampstringrequired

ISO 8601 timestamp indicating when the event occurred.

dataobjectrequired

Transfer details such as amount, addresses, and the transaction hash.

data properties
amountstringrequired

Human-readable payment amount with decimals. For example, 10.5.

rawAmountstringrequired

Amount in base units; nanotons for TON.

senderAddrstringrequired

Sender wallet address on TON blockchain.

recipientAddrstringrequired

Recipient wallet address on TON blockchain.

assetstringrequired

Asset identifier. Use "TON" for TON coin or a jetton master address for tokens.

assetTickerstring

Human-readable asset ticker. For example, TON or USDT.

statusstringrequired

Transfer status: success or failed. Process only success status. Log failed status for investigation.

referencestringrequired

Unique transfer reference. Use this to match the webhook with the internal order or transaction.

bodyBase64Hashstringrequired

Base64 hash of the transfer body. Use for advanced verification.

txHashstringrequired

Transaction hash on-chain. Use this to verify the transaction if needed.

traceIdstringrequired

Trace ID for tracking the transaction across systems.

commentToSenderstring

Optional note visible to the sender during signing.

commentToRecipientstring

Optional note visible to the recipient. Use for order IDs or invoice numbers.

datestringrequired

ISO 8601 timestamp of the transfer completion.

errorCodenumber

Error code if the transfer failed.

errorMessagestring

Error message if the transfer failed.

Verify webhook signatures

Every webhook request includes an X-TonPay-Signature header containing an HMAC-SHA256 signature. Verify this signature to ensure the request comes from TON Pay.

Security requirement

In production, webhook requests must be verified using the signature. Without verification, requests can be forged.

import { verifySignature, type WebhookPayload } from "@ton-pay/api";

app.post("/webhook", (req, res) => {
  const signature = req.headers["x-tonpay-signature"];

  if (!verifySignature(req.body, signature, process.env.TONPAY_API_SECRET)) {
    return res.status(401).json({ error: "Invalid signature" });
  }

  const webhookData: WebhookPayload = req.body;

  // Process the webhook in application-specific logic.
  res.status(200).json({ received: true });
});

Important

The webhook API secret is available in the Merchant Dashboard under DeveloperWebhooks. Store it securely and use it only on the server.

Validate webhook requests

Validate fields against the expected transaction data before marking an order as paid.

Return a 2xx response for validation failures, such as an amount mismatch or an unknown order, to prevent retries. Log these issues for further investigation.

  1. Verify the X-TonPay-Signature header and reject invalid requests.

    if (!verifySignature(payload, signature, apiKey)) { // apiKey is optional and used when configured
      return res.status(401).json({ error: "Invalid signature" });
    }
  2. Verify the event type.

    if (webhookData.event !== "transfer.completed") {
      return res.status(400).json({ error: "Unexpected event type" });
    }
  3. Check that the reference matches a transaction created earlier.

    const order = await db.getOrderByReference(webhookData.data.reference);
    if (!order) {
      console.error("Order not found:", webhookData.data.reference);
      return res.status(200).json({ received: true }); // Acknowledge to prevent retry
    }
  4. Verify the amount matches the expected payment amount.

    if (parseFloat(webhookData.data.amount) !== order.expectedAmount) {
      console.error("Amount mismatch:", {
        expected: order.expectedAmount,
        received: webhookData.data.amount,
      });
      return res.status(200).json({ received: true }); // Acknowledge to prevent retry
    }
  5. Verify the payment currency or token.

    if (webhookData.data.asset !== order.expectedAsset) {
      console.error("Asset mismatch:", {
        expected: order.expectedAsset,
        received: webhookData.data.asset,
      });
      return res.status(200).json({ received: true }); // Acknowledge to prevent retry
    }
  6. Check status and process only successful transfers.

    if (webhookData.data.status !== "success") {
      await db.markOrderAsFailed(order.id, webhookData.data.txHash);
      return res.status(200).json({ received: true }); // Acknowledge to prevent retry
    }
  7. Prevent duplicate processing using the reference.

    if (order.status === "completed") {
      return res.status(200).json({ received: true, duplicate: true });
    }

Complete validation example

import { verifySignature, type WebhookPayload } from "@ton-pay/api";

app.post("/webhooks/tonpay", async (req, res) => {
  try {
    // 1. Verify signature
    const signature = req.headers["x-tonpay-signature"] as string;

    if (!verifySignature(req.body, signature, process.env.TONPAY_API_SECRET!)) {
      console.error("Invalid webhook signature");
      return res.status(401).json({ error: "Invalid signature" });
    }

    const webhookData: WebhookPayload = req.body;

    // 2. Verify event type
    if (webhookData.event !== "transfer.completed") {
      return res.status(400).json({ error: "Unexpected event type" });
    }

    // 3. Find the order
    const order = await db.getOrderByReference(webhookData.data.reference);
    if (!order) {
      console.error("Order not found:", webhookData.data.reference);
      return res.status(200).json({ received: true }); // Acknowledge to prevent retry
    }

    // 4. Check for duplicate processing
    if (order.status === "completed") {
      console.log("Order already processed:", order.id);
      return res.status(200).json({ received: true, duplicate: true });
    }

    // 5. Verify transfer status
    if (webhookData.data.status !== "success") {
      await db.updateOrder(order.id, {
        status: "failed",
        txHash: webhookData.data.txHash,
        failureReason: "Transfer failed on blockchain",
      });
      return res.status(200).json({ received: true });
    }

    // 6. CRITICAL: Verify amount
    const receivedAmount = parseFloat(webhookData.data.amount);
    if (receivedAmount !== order.expectedAmount) {
      console.error("Amount mismatch:", {
        orderId: order.id,
        expected: order.expectedAmount,
        received: receivedAmount,
      });
      return res.status(200).json({ received: true }); // Acknowledge to prevent retry
    }

    // 7. CRITICAL: Verify asset/currency
    const expectedAsset = order.expectedAsset || "TON";
    if (webhookData.data.asset !== expectedAsset) {
      console.error("Asset mismatch:", {
        orderId: order.id,
        expected: expectedAsset,
        received: webhookData.data.asset,
      });
      return res.status(200).json({ received: true }); // Acknowledge to prevent retry
    }

    // All validations passed - acknowledge receipt
    res.status(200).json({ received: true });

    // Process order asynchronously
    await processOrderCompletion(order.id, {
      txHash: webhookData.data.txHash,
      senderAddr: webhookData.data.senderAddr,
      completedAt: webhookData.timestamp,
    });
  } catch (error) {
    console.error("Webhook processing error:", error);
    return res.status(500).json({ error: "Internal server error" });
  }
});

Retry behavior

TON Pay retries failed webhook deliveries.

Retry attempts

Up to 3 automatic retries with exponential backoff.

Retry delays

1s, 5s, and 15s.

Delivery outcomes

  • Success responses: 2xx. No retry; webhook marked as delivered.
  • All other responses: 4xx, 5xx, or network errors. Automatic retry with exponential backoff.

Return a 2xx status code only after validation and any required order updates complete, including cases where validation fails but the event is logged and marked as handled. Returning success before validation finishes can result in missed or duplicated processing.

Best practices

  • Validate the following fields before marking an order as paid:

    • Signature: authentication;
    • Reference: the transaction exists in the system;
    • Amount: the expected payment is matched;
    • Asset: correct currency or token;
    • Wallet ID: correct receiving wallet;
    • Status: the transaction succeeded.
    // Avoid: trust without verification
    await markOrderAsPaid(webhook.data.reference);
    
    // Prefer: validate and then process
    if (isValidWebhook(webhook, order)) {
      await markOrderAsPaid(order.id);
    }
  • Return 2xx only after successful validation, then process the webhook to avoid timeouts.

    app.post("/webhook", async (req, res) => {
      // Validate signature first
      if (!verifyWebhookSignature(...)) {
        return res.status(401).json({ error: "Invalid signature" });
      }
    
      // Quick validations
      if (!isValidWebhook(req.body)) {
        return res.status(400).json({ error: "Invalid webhook data" });
      }
    
      // Acknowledge after validation
      res.status(200).json({ received: true });
    
      // Process in background
      processWebhookAsync(req.body).catch(console.error);
    });
  • Implement idempotency. Webhooks may be delivered more than once. Use the reference field to prevent duplicate processing.

    async function processWebhook(payload: WebhookPayload) {
      const order = await db.getOrder(payload.reference);
    
      // Check if already processed
      if (order.status === "completed") {
        console.log("Order already completed:", payload.reference);
        return; // Skip processing
      }
    
      // Process and update status atomically
      await db.transaction(async (tx) => {
        await tx.updateOrder(order.id, { status: "completed" });
        await tx.createPaymentRecord(payload);
      });
    }
  • Log all webhook attempts. Log each request for debugging and security auditing.

    await logger.info("Webhook received", {
      timestamp: new Date(),
      signature: req.headers["x-tonpay-signature"],
      reference: payload.data.reference,
      event: payload.event,
      amount: payload.data.amount,
      asset: payload.data.asset,
      status: payload.data.status,
      validationResult: validationResult,
    });
  • To receive webhooks in production, ensure corresponding endpoints use HTTPS. Regular HTTP endpoints would be ignored by TON Pay.

  • Monitor delivery failures. Set up alerts and review webhook delivery logs in the Merchant Dashboard

  • Store transaction hashes. Save txHash to support on-chain verification for disputes.

    await db.updateOrder(order.id, {
      status: "completed",
      txHash: webhookData.data.txHash,
      senderAddr: webhookData.data.senderAddr,
      completedAt: webhookData.timestamp,
    });

Troubleshooting

Next steps

Last updated on

On this page