> ## Documentation Index
> Fetch the complete documentation index at: https://docs.helloclever.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks: Real-time Payment Notifications

> Set up Hello Clever webhooks to receive POST notifications on payment status changes. Includes payload reference, error codes, security, and retry behaviour.

Hello Clever webhooks let your server receive real-time notifications whenever a payment’s status changes (for example, when a payment moves from `pending` to `authorised`, or from `authorised` to `waiting`). Instead of polling the API, you register an endpoint and Hello Clever sends a POST request to it automatically on each status transition.

## How webhooks work

When a payment status changes, Hello Clever sends a `POST` request to your configured `endpoint_url` with a JSON payload describing the current state of the transaction. Your endpoint must return HTTP `200` to acknowledge receipt. If it does not, Hello Clever retries up to 3 times with a 15-minute delay between each attempt.

<Note>
  Because retries can deliver the same payload more than once, implement idempotent handling in your webhook endpoint. Deduplicate on the transaction identifier: `uuid` for the [standard payload](#standard-payload), or `transaction_info.uuid` for the [Payment Gateway 3 payload](#payment-gateway-3-payload).
</Note>

## Setting up webhooks

You can configure your webhook endpoint in two ways:

**SDK integration**: Contact Hello Clever to register your default webhook URL. Any payment created via the SDK will send notifications to that URL.

**API integration**: Include a `webhook_notification` object in your payment creation request. If provided, it overrides the default webhook URL for that payment.

```json theme={null}
{
  "webhook_notification": {
    "endpoint_url": "https://your-domain.com/webhook",
    "authorization_header": "Bearer your-secret-token"
  }
}
```

<ResponseField name="webhook_notification" type="object">
  Webhook configuration to include in a payment creation request.

  <Expandable>
    <ResponseField name="endpoint_url" type="string" required>
      The URL where Hello Clever will POST webhook notifications.
    </ResponseField>

    <ResponseField name="authorization_header" type="string">
      Optional. A value Hello Clever will send in the `Authorization` header of every webhook request to this endpoint, allowing you to verify the source.
    </ResponseField>
  </Expandable>
</ResponseField>

## Payment statuses

Hello Clever sends a webhook notification whenever a payment transitions to any of the following statuses:

| Status | Description |
| - | - |
| `pending` | Customer started a new payment but has not proceeded yet. |
| `authorised` | Payment has been authorised and is ready to be captured. |
| `waiting` | Payment has been approved; awaiting funds to settle. |
| `received` | Funds have been received. |
| `expired` | Payment session expired before completion. |
| `return_pending` | Refund request initiated, awaiting processing. |
| `return_expired` | Refund request expired (expiration window is 10 days). |
| `partially_refunded` | A partial refund has been issued to the customer. |
| `return_received` | Full amount has been refunded to the customer. |
| `return_rejected` | Refund request was denied. The system will not retry. |
| `failed` | Payment failed due to an error or decline. |
| `in_dispute` | Customer has raised a dispute; it is under review. |
| `dispute_lost` | Dispute was resolved in the customer’s favour. The disputed amount is not returned to the merchant. |

<Note>
  When a payment is in `in_dispute` status, contact Hello Clever support to submit evidence for the dispute resolution process.
</Note>

## Webhook object

Hello Clever sends two different payload shapes. Which one you receive depends on the product that created the payment, so confirm which applies to your integration before you map fields:

| Product | Payload shape |
| - | - |
| Payment APIs (v1), Multi-Currency Payment APIs (v2), Card APIs | Transaction fields sit at the top level, including `status`. See [Standard payload](#standard-payload). |
| Payment Gateway 3 (v3) | Payment link fields sit at the top level and transaction state is nested under `transaction_info`. See [Payment Gateway 3 payload](#payment-gateway-3-payload). |

### Standard payload

Payment APIs, Multi-Currency Payment APIs, and Card APIs all send this shape. Each webhook POST body contains the following fields:

| Field | Type | Description |
| - | - | - |
| `uuid` | string | Unique identifier for the payment transaction. |
| `name` | string | Customer’s name. |
| `email` | string | Customer’s email address. |
| `external_id` | string | Your reference ID for this payment. |
| `status` | string | Current status of the payment. |
| `pay_code` | object | When 3DS is required, contains `3ds_url`. When the payment failed, contains `error_code` and `error_message`. |
| `pay_code.3ds_url` | string | URL for the customer to complete 3DS authentication (when required). |
| `pay_code.error_code` | string | Error or decline code (e.g. `issuer_declined`). |
| `pay_code.error_message` | string | Human-readable error or decline message. |
| `currency` | string | Transaction currency code (ISO 4217). |
| `amount` | string | Original payment amount. |
| `total` | string | Total amount including any fees. |
| `paid_amount` | string | Amount that has been paid. |
| `is_refundable` | boolean | Whether the payment can be refunded. |
| `payment_method` | string | Payment method used (e.g. `card`). |
| `expired_at` | string | Timestamp indicating when the payment URL expires (UTC). |
| `status_text` | string | Detailed explanation of the current status. |
| `description` | string | Description accompanying the payment request, such as the purpose of the payment or a related invoice reference. |
| `gst` | boolean | Whether Goods and Services Tax (GST) applies to the transaction. `true` means GST is included. |
| `gst_amount` | string | The portion of `total` attributable to GST. |
| `pay_by` | string | The last date and time by which the payment must be made. |
| `stage` | string | Where the payment sits in its flow. Values are product-specific: see [stage on Create Payin](/api/v2/payin) for Multi-Currency Payment APIs. |
| `webhook_notification` | object | Webhook configuration details. |
| `webhook_notification.endpoint_url` | string | URL where webhooks are sent. |
| `webhook_notification.authorization_header` | string | Masked authorization header value. |
| `refund_information` | object | Details about any refunds. |
| `refund_information.total_amount` | string | Total amount available for refund. |
| `refund_information.refund_amount` | string | Amount that was refunded. |
| `refund_information.description` | string | Reason for the refund. |
| `sender_details` | object | Payment method details. |
| `sender_details.card` | object | Card payment details. |
| `sender_details.card.card_type` | string | Type of card used. |
| `sender_details.card.card_brand` | string | Card brand (e.g. `visa`, `mastercard`). |
| `sender_details.card.card_last_4` | string | Last 4 digits of the card number. |
| `sender_details.card.card_country` | string | Two-letter country code associated with the card. |
| `created_at` | string | Timestamp when the payment was created. |
| `token` | object | Card token details (only present when status is `authorised` or `waiting`). |
| `token.id` | string | Reusable token for the card (format: `tok_...`). |
| `token.type` | string | Token type (e.g. `card`). |

#### Example payload: card payment

```json theme={null}
{
  "uuid": "QVABPPC7",
  "name": "Hello Clever",
  "email": "test@example.com",
  "external_id": "123",
  "status": "authorised",
  "pay_code": {
    "3ds_url": "https://3ds-example.com"
  },
  "currency": "USD",
  "amount": "100.0",
  "total": "100.0",
  "paid_amount": "0.0",
  "is_refundable": false,
  "payment_method": "card",
  "expired_at": "",
  "webhook_notification": {
    "endpoint_url": "https://webhook.site/12da7803-c4cf-4f32-812d-aaeaecf20d9d",
    "authorization_header": "****"
  },
  "refund_information": {
    "total_amount": "250.0",
    "refund_amount": "10.0",
    "description": "Testing refund"
  },
  "sender_details": {
    "card": {
      "card_type": "card",
      "card_brand": "visa",
      "card_last_4": "1111",
      "card_country": "US"
    }
  },
  "created_at": "2025-05-30T05:11:17.602+0000",
  "token": {
    "id": "tok_dfe1988a1ffc0d6562d3",
    "type": "card"
  }
}
```

#### Example payload: bank transfer

For bank transfer and QR methods, `pay_code` carries the details the customer needs to complete the payment instead of a 3DS URL:

```json theme={null}
{
  "uuid": "44QU0367",
  "name": "Hello Clever",
  "email": "test@example.com",
  "external_id": null,
  "status": "waiting",
  "pay_code": {
    "qr_string": "00020101021238610010A000000727013100069704520117101050696623331910208QRIBFTTA53037045405100005802VN62460113B00OCDRJ8YULO0505PGVQR0816TT BWIGBT0BCTYTZ630423BC",
    "bank_information": {
      "bank_name": "TMCP Kien Long (Kienlongbank)",
      "account_number": "101050696623331",
      "account_name": "AABB",
      "branch_name": "TP.Ho Chi Minh",
      "description": "Test Payin",
      "swift_code": "KLBKVNVX"
    },
    "message": "Approved by financial institution"
  },
  "currency": "VND",
  "amount": "10000.0",
  "total": "10000.0",
  "paid_amount": "10000.0",
  "is_refundable": false,
  "payment_method": "vn_vietqr_vnd",
  "expired_at": "2025-12-23T06:00:00.000+0000",
  "webhook_notification": {
    "endpoint_url": "https://your-domain.com/webhook",
    "authorization_header": "****"
  },
  "status_text": "Payment has been approved, awaiting funds to settle",
  "description": "Test Payin",
  "gst": false,
  "gst_amount": 0,
  "pay_by": "2025-09-29T07:46:31.300+0000",
  "sender_details": null,
  "stage": "normal_stage"
}
```

### Payment Gateway 3 payload

Payment Gateway 3 sends a payment link object rather than a flat transaction object. The differences matter when you write your handler:

* Payment status is at `transaction_info.status`, **not** at the top level.
* Amounts are JSON numbers, not strings.
* The customer arrives as `sender_info`, not as top-level `name` and `email`.
* `transaction_info` is `null` until the customer starts a payment against the link.

| Field | Type | Description |
| - | - | - |
| `uuid` | string | Unique identifier for the payment link. |
| `expires_at` | string | Expiry time of the payment link (ISO 8601, UTC). |
| `payment_url` | string | URL of the hosted checkout page. |
| `payin_method_code` | string | Code of the selected payment method. `null` when the customer has not chosen one yet. |
| `amount` | number | Original payment amount. |
| `description` | string | Description of the payment transaction. |
| `redirect_url` | object | URLs the customer is returned to after checkout. |
| `redirect_url.success` | string | Redirect URL used on success. |
| `webhook_notification` | object | Webhook configuration details. |
| `webhook_notification.endpoint_url` | string | URL where webhooks are sent. |
| `webhook_notification.authorization_header` | string | Masked authorization header value. |
| `sender_info` | object | Information about the customer sending the payment. |
| `sender_info.email` | string | Customer's email address. |
| `sender_info.first_name` | string | Customer's first name. |
| `sender_info.last_name` | string | Customer's last name. |
| `sender_info.gender` | string | Customer's gender. |
| `sender_info.contact_type` | string | Contact type, for example `individual`. |
| `sender_info.dob` | string | Date of birth. |
| `sender_info.reg_no` | string | Registration number. |
| `sender_info.state` | string | State or province. |
| `sender_info.state_code` | string | State code. |
| `sender_info.city` | string | City. |
| `sender_info.address` | string | Full street address. |
| `sender_info.postal_code` | string | Postal or ZIP code. |
| `sender_info.country_code` | string | ISO 2-letter country code. |
| `sender_info.phone` | string | Customer's phone number. |
| `sender_info.user_id` | string | Customer's ID in your system. |
| `sender_info.user_name` | string | Customer's username in your system. |
| `sender_info.account_name` | string | Account name in your system. |
| `sender_info.account_number` | string | Mobile money account number. |
| `sender_info.bank_code` | string | Bank code used for the charge. |
| `sender_info.document_type` | string | Type of identity document, for example `passport`. |
| `sender_info.document_number` | string | Identity document number. |
| `sender_info.upi_id` | string | Customer's UPI ID. |
| `sender_info.utr` | string | Unique Transaction Reference (UTR) number. |
| `beneficiary_info` | object | Information about the beneficiary. |
| `beneficiary_info.upi_id` | string | Beneficiary UPI ID. |
| `metadata` | object | The custom key-value pairs you attached to the transaction, returned unchanged. |
| `transaction_info` | object | State of the transaction created against this link. `null` until the customer starts a payment. |
| `transaction_info.uuid` | string | Transaction UUID. Use this to deduplicate deliveries. |
| `transaction_info.currency` | string | Transaction currency (3-letter code, for example `USD`, `EUR`, `VND`). |
| `transaction_info.amount` | number | Original payment amount. |
| `transaction_info.paid_amount` | number | Amount the customer has actually paid. |
| `transaction_info.refund_amount` | number | Amount refunded to the customer. `0` when no refund has been issued. |
| `transaction_info.status` | string | Current status of the payment. See [Payment statuses](#payment-statuses). |
| `transaction_info.status_text` | string | Detailed explanation of the current status. |

#### Example payload

```json theme={null}
{
  "uuid": "7AMCEF7H",
  "expires_at": "2025-10-02T19:05:03.971Z",
  "payment_url": "https://paylink.cleverhub.co/N0FNQ0VGNAD",
  "payin_method_code": "vn_vietqr_vnd",
  "amount": 3000,
  "description": "Test payin",
  "redirect_url": {
    "success": "https://example.com/success"
  },
  "webhook_notification": {
    "authorization_header": "****",
    "endpoint_url": "https://example.com/webhook"
  },
  "sender_info": {
    "email": "test@example.com",
    "first_name": "John",
    "last_name": "Doe",
    "gender": "male",
    "contact_type": "individual",
    "dob": "1990-01-01",
    "reg_no": "A1234567",
    "state": "Ho Chi Minh",
    "user_id": "user_12345",
    "upi_id": "johndoe@upi",
    "utr": "756690058333",
    "user_name": "john_doe123",
    "phone": "0123456789",
    "country_code": "VN",
    "address": "123 abc",
    "city": "Ho Chi Minh City",
    "postal_code": "700000",
    "account_name": "John Doe",
    "account_number": "233240000000",
    "bank_code": "VCB",
    "document_type": "passport",
    "document_number": "P123456789",
    "state_code": "SG"
  },
  "beneficiary_info": {
    "upi_id": "1234567890@oksbi"
  },
  "metadata": {
    "custom_note": "Priority customer",
    "external_id": "1HUJMW34"
  },
  "transaction_info": {
    "uuid": "3D8G9WJJ",
    "currency": "VND",
    "amount": 3000.0,
    "paid_amount": 0.0,
    "refund_amount": 0.0,
    "status": "pending",
    "status_text": "Customer started a new payment but hasn't proceeded yet."
  }
}
```

<Note>
  Deduplicate Payment Gateway 3 deliveries on `transaction_info.uuid`. The top-level `uuid` identifies the payment link, which can produce more than one transaction.
</Note>

## Token for authorised cards

When a card payment reaches `authorised` or `waiting` status, Hello Clever includes a `token` object in the webhook payload. Save this token securely: you can use it to create future payments for the same customer without requiring them to re-enter their card details.

```json theme={null}
{
  "token": {
    "id": "tok_773085396b86562040f4",
    "type": "card"
  }
}
```

<Warning>
  The `token` object is only present in webhook notifications when the status changes to `authorised` or `waiting`. It is not included in notifications for any other status.
</Warning>

## Webhook security

Hello Clever supports two security mechanisms you can use independently or together.

### Authorization header

If you set an `authorization_header` in your webhook configuration, Hello Clever will send that value in the `Authorization` header of every webhook request to your endpoint:

```
Authorization: Bearer your-secret-token
```

Verify this header on your server against your known secret to confirm the request is from Hello Clever.

### HTTP webhook signature (HMAC-SHA256)

Hello Clever also signs each webhook payload using HMAC-SHA256 and sends the signature in the `HTTP-WEBHOOK-SIGNATURE` header:

```
HTTP-WEBHOOK-SIGNATURE: ff456ed178c487a2fd323043315320f8ec0908327a1b76f1ad54d9f81a000f9d
```

To verify the signature, recompute the HMAC-SHA256 of the **raw request body** using your Webhook Secret Key and compare it with the header value.

<Steps>
  <Step title="Find your Webhook Secret Key">
    In the Merchant Portal, go to **Dashboard → Developer → Authentication → Webhook tab** and copy the value from the **Webhook secret key** field.
  </Step>

  <Step title="Compute the expected signature">
    Use the raw request body (do not parse and re-stringify JSON) and your Webhook Secret Key:

    <CodeGroup>
      ```javascript Node.js theme={null}
      const crypto = require('crypto');

      function verifySignature(rawBody, webhookSecretKey, receivedSignature) {
        const expectedSignature = crypto
          .createHmac('sha256', webhookSecretKey)
          .update(rawBody, 'utf8')
          .digest('hex');
        return receivedSignature === expectedSignature;
      }

      // In your request handler:
      const receivedSignature = req.headers['http-webhook-signature'];
      const isValid = verifySignature(req.rawBody, process.env.WEBHOOK_SECRET, receivedSignature);
      ```

      ```python Python theme={null}
      import hmac
      import hashlib

      def verify_signature(raw_body: str, webhook_secret_key: str, received_signature: str) -> bool:
          expected_signature = hmac.new(
              webhook_secret_key.encode('utf-8'),
              raw_body.encode('utf-8'),
              hashlib.sha256
          ).hexdigest()
          return received_signature == expected_signature

      # In your request handler:
      received_signature = request.headers.get('HTTP-WEBHOOK-SIGNATURE')
      is_valid = verify_signature(request.get_data(as_text=True), WEBHOOK_SECRET, received_signature)
      ```

      ```java Java theme={null}
      import javax.crypto.Mac;
      import javax.crypto.spec.SecretKeySpec;
      import java.nio.charset.StandardCharsets;

      public boolean verifySignature(String rawBody, String secret, String receivedSignature) throws Exception {
          Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
          SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
          sha256_HMAC.init(secretKey);
          byte[] hash = sha256_HMAC.doFinal(rawBody.getBytes(StandardCharsets.UTF_8));
          StringBuilder hexString = new StringBuilder();
          for (byte b : hash) {
              String hex = Integer.toHexString(0xff & b);
              if (hex.length() == 1) hexString.append('0');
              hexString.append(hex);
          }
          return hexString.toString().equals(receivedSignature);
      }
      ```

      ```ruby Ruby theme={null}
      require 'openssl'

      def verify_signature(raw_body, webhook_secret_key, received_signature)
        expected_signature = OpenSSL::HMAC.hexdigest('sha256', webhook_secret_key, raw_body)
        received_signature == expected_signature
      end

      # In your request handler:
      received_signature = request.headers['HTTP-WEBHOOK-SIGNATURE']
      is_valid = verify_signature(request.raw_post, ENV['WEBHOOK_SECRET'], received_signature)
      ```

      ```csharp C# theme={null}
      using System;
      using System.Security.Cryptography;
      using System.Text;

      public static bool VerifySignature(string rawBody, string secret, string receivedSignature)
      {
          var key = Encoding.UTF8.GetBytes(secret);
          using (var hmac = new HMACSHA256(key))
          {
              var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(rawBody));
              var expectedSignature = BitConverter.ToString(hash).Replace("-", "").ToLower();
              return expectedSignature == receivedSignature;
          }
      }

      // In your request handler:
      // var receivedSignature = Request.Headers["HTTP-WEBHOOK-SIGNATURE"];
      // var isValid = VerifySignature(rawBody, webhookSecret, receivedSignature);
      ```

      ```go Go theme={null}
      package main

      import (
          "crypto/hmac"
          "crypto/sha256"
          "encoding/hex"
      )

      func verifySignature(rawBody, secret, receivedSignature string) bool {
          h := hmac.New(sha256.New, []byte(secret))
          h.Write([]byte(rawBody))
          expectedSignature := hex.EncodeToString(h.Sum(nil))
          return hmac.Equal([]byte(expectedSignature), []byte(receivedSignature))
      }

      // In your request handler:
      // receivedSignature := r.Header.Get("HTTP-WEBHOOK-SIGNATURE")
      // isValid := verifySignature(string(rawBody), webhookSecret, receivedSignature)
      ```
    </CodeGroup>
  </Step>

  <Step title="Compare and accept or reject">
    If the signatures match, the payload is authentic and unmodified. If they differ, reject the request; it may have been tampered with or sent by an untrusted source.
  </Step>
</Steps>

<Tip>
  For maximum security, validate both the `Authorization` header and the `HTTP-WEBHOOK-SIGNATURE`. The `Authorization` header confirms the sender; the signature confirms the payload has not been tampered with.
</Tip>

### Testing your signature verification

If your verification is rejecting valid requests, check your computation against the delivered signature by hand before changing your code.

<Steps>
  <Step title="Capture the raw request body">
    Open your request log and copy the raw body of the webhook request exactly as it arrived. Do not reformat it, and do not parse and re-serialise the JSON: whitespace and key order are part of what was signed.
  </Step>

  <Step title="Compute the HMAC outside your application">
    Run the raw body through any HMAC-SHA256 calculator, using your Webhook Secret Key as the key. Set the input encoding to UTF-8 and the output encoding to lowercase hex.
  </Step>

  <Step title="Compare against the delivered header">
    Find the `http-webhook-signature` header on the same request and compare the two values:

    ```
    Computed:  ff456ed178c487a2fd323043315320f8ec0908327a1b76f1ad54d9f81a000f9d
    Delivered: ff456ed178c487a2fd323043315320f8ec0908327a1b76f1ad54d9f81a000f9d
    ```

    If they match, the signature is valid and the bug is in your application's handling. If they differ, the request was either tampered with or signed with a different key: confirm you are using the Webhook Secret Key for the correct merchant.
  </Step>
</Steps>

<Warning>
  Use a calculator you trust with the secret. Pasting your Webhook Secret Key into a third-party website exposes it. Prefer a local tool, for example `openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" body.json`.
</Warning>

## Error handling and retries

If your endpoint does not return HTTP `200`, Hello Clever retries the webhook call up to **3 times** with a **15-minute delay** between each attempt.

To handle retries correctly:

* Return `200` immediately upon receiving the webhook, before performing any heavy processing.
* Process the event asynchronously (e.g. via a queue) to avoid timeouts.
* Use the transaction identifier to detect and discard duplicate deliveries: `uuid`, or `transaction_info.uuid` on Payment Gateway 3.

## Error codes

When a payment fails, the `pay_code` object includes `error_code` and `error_message`. The following table lists all possible error codes:

| Error code | Message |
| - | - |
| `account_closed` | The customer’s bank account has been closed. |
| `amount_invalid` | The payment amount is invalid, or exceeds the amount that’s allowed. |
| `amount_too_large` | The specified amount is greater than the maximum amount allowed. Use a lower amount and try again. |
| `amount_too_small` | The specified amount is less than the minimum amount allowed. Use a higher amount and try again. |
| `authentication_expired` | The card authorisation has expired. |
| `authentication_failed` | The payment can’t be authorised. |
| `authentication_required` | The card was declined because the transaction requires authentication such as 3D Secure. |
| `capture_not_authorised` | Transaction must be in `authorised` status before it can be captured. |
| `card_decline_rate_limit_exceeded` | This card has been declined too many times. You can try to charge this card again after 24 hours. |
| `card_expired` | The card has expired. |
| `card_lost` | The payment was declined because the card is reported lost. |
| `card_not_supported` | The card does not support this type of purchase. |
| `card_number_incorrect` | The card number is incorrect. |
| `card_restricted` | The customer can’t use this card to make this payment. |
| `card_stolen` | The payment was declined because the card is reported stolen. |
| `card_velocity_exceeded` | The customer has exceeded the balance, credit limit, or transaction amount limit on their card. |
| `currency_not_supported` | The card does not support the specified currency. |
| `customer_canceled` | The customer has stopped the payment with their bank. |
| `cvc_incorrect` | The CVC number is incorrect. |
| `debit_not_authorised` | The customer has notified their bank that this payment was unauthorised. |
| `expiry_month_invalid` | The expiration month is invalid. |
| `expiry_year_invalid` | The expiration year is invalid. |
| `fraudulent` | The payment was declined because it is suspected to be fraudulent. |
| `generic_declined` | The card was declined for an unknown reason or the payment was blocked. |
| `insufficient_funds` | The card has insufficient funds to complete the purchase. |
| `issuer_declined` | The card was declined for an unknown reason. |
| `issuer_not_available` | The card issuer couldn’t be reached, so the payment couldn’t be authorised. |
| `pin_required` | The card was declined because it requires a PIN. |
| `pin_try_exceeded` | The allowable number of PIN tries was exceeded. |
| `processing_error` | An error occurred while processing the card. Try again later or with a different payment method. |
| `return_url_invalid` | Return URL is invalid. |
| `transaction_is_blocked` | Transaction is blocked. If you paid, a refund will be issued. |
| `transaction_duplicated` | A transaction with identical details was submitted recently. |
| `withdrawal_count_limit_exceeded` | The customer has exceeded the balance or credit limit available on their card. |

## Best practices

1. Verify the `Authorization` header or `HTTP-WEBHOOK-SIGNATURE` on every incoming request before processing.
2. Implement idempotency using the transaction identifier (`uuid`, or `transaction_info.uuid` on Payment Gateway 3) to prevent duplicate side effects from retried deliveries.
3. Return `200 OK` as soon as you receive the request; process the payload asynchronously.
4. Keep your webhook endpoint URL private; avoid sharing it publicly.
5. Use HTTPS for your webhook endpoint to protect the payload in transit.
6. Test your endpoint during development using tools like [webhook.site](https://webhook.site) or a local tunnel such as [ngrok](https://ngrok.com).


## Related topics

- [Real-time Payin Service](/services/real-time-payin.md)
- [Real-time Payments vs. Card Payments](/platform-overview/payment-concepts/rtp-vs-cards.md)
- [How it works](/getting-started/how-it-works.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.