A webhook is an HTTP request that VNSwitch sends to your server when something happens on your account: a payment succeeds, a refund fails, a dispute opens, a mandate is revoked or a payout completes. Many results arrive later than your API call (for example when the customer approves a payment in their banking app), so webhooks are how you learn the final outcome without polling.

Webhook body (example, payment object shortened). Headers: Content-Type: application/json and X-Webhook-Signature-512: <128 hex characters>.

{
  "merchant_id": "merchant_vn_001",
  "event_id": "evt_01JBX7Q8M2",
  "event_type": "payment_succeeded",
  "content": {
    "type": "payment_details",
    "object": {
      "payment_id": "pay_8f2c1d9e0a",
      "merchant_id": "merchant_vn_001",
      "status": "succeeded",
      "amount": 150000,
      "amount_received": 150000,
      "currency": "VND",
      "customer_id": "cus_demo_001",
      "description": "Order #1001",
      "created": "2026-11-01T08:29:41.000Z"
    }
  },
  "timestamp": "2026-11-01T08:30:02.000Z",
  "processor_merchant_id": "merchant_vn_001"
}

Set up your webhook endpoint

  1. Create an HTTPS endpoint on your server that accepts POST requests with a JSON body, for example https://merchant.example.vn/webhooks/vnswitch.
  2. In the VNSwitch dashboard, set it as the webhook URL of your business profile. VNSwitch sends every event for that profile to this URL. Set it in each environment you use.
  3. Copy the payment response hash key of the same profile from the dashboard. You need it to verify signatures. Keep it on your server, like the secret API key. The examples read it from the VNSWITCH_PAYMENT_RESPONSE_HASH_KEY environment variable; in the docs it appears as <YOUR_PAYMENT_RESPONSE_HASH_KEY>.

If no webhook URL is set on the profile, VNSwitch does not send webhooks for it.

Webhook payload

Every webhook has the same envelope. The changed object is inside content.object.

FieldTypeDescription
merchant_idstringYour merchant account ID.
event_idstringUnique ID of this event, for example evt_01JBX7Q8M2. Use it to detect duplicates.
event_typestringWhat happened. See Webhook event types.
content.typestringKind of object in content.object: payment_details, refund_details, dispute_details, mandate_details or payout_details.
content.objectobjectThe full object, in the same format as the API returns it: a payment, refund, dispute, mandate or payout.
timestampstringWhen the event was created (ISO 8601, UTC).
processor_merchant_idstringThe merchant account whose provider credentials processed the payment. Usually the same as merchant_id.

The amount fields inside content.object follow the usual rules: integers in the currency’s minor unit (see Amounts and currencies).

Webhook event types

event_typecontent.typeSent when
payment_succeededpayment_detailsThe payment succeeded (status: "succeeded").
payment_failedpayment_detailsThe payment failed.
payment_processingpayment_detailsThe payment is being processed and the result is not known yet.
payment_authorizedpayment_detailsThe payment is authorized and waits for capture (requires_capture).
payment_partially_authorizedpayment_detailsOnly part of the amount was authorized.
payment_capturedpayment_detailsPart of the amount was captured (partially_captured).
payment_cancelledpayment_detailsThe payment was cancelled.
payment_cancelled_post_capturepayment_detailsThe payment was cancelled after capture.
payment_expiredpayment_detailsThe payment expired before it was completed.
action_requiredpayment_detailsThe payment needs action from the customer or from you, or is under review.
refund_succeededrefund_detailsThe refund succeeded.
refund_failedrefund_detailsThe refund failed.
refund_reviewrefund_detailsThe refund needs manual review.
dispute_openeddispute_detailsThe customer opened a dispute.
dispute_challengeddispute_detailsThe dispute was challenged with evidence.
dispute_accepteddispute_detailsThe dispute was accepted.
dispute_wondispute_detailsThe dispute was decided in your favour.
dispute_lostdispute_detailsThe dispute was decided against you.
dispute_cancelleddispute_detailsThe dispute was cancelled.
dispute_expireddispute_detailsThe time to respond to the dispute ran out.
mandate_activemandate_detailsThe mandate became active and can be charged.
mandate_revokedmandate_detailsThe mandate was revoked. Stop charging it.
payout_initiatedpayout_detailsThe payout was sent to the provider.
payout_successpayout_detailsThe payout reached the recipient.
payout_failedpayout_detailsThe payout failed.
payout_cancelledpayout_detailsThe payout was cancelled.
payout_expiredpayout_detailsThe payout expired.
payout_reversedpayout_detailsA completed payout was reversed.
payout_not_permittedpayout_detailsThe payout was refused and will not be retried.

Answer every event with a 2xx status, even event types you do not use, and ignore types you do not recognise. New event types can be added.

Verify the webhook signature

Anyone who knows your webhook URL can send requests to it, so verify the signature of every webhook before you act on it.

VNSwitch signs each webhook and sends the signature in the X-Webhook-Signature-512 header:

  • The algorithm is HMAC-SHA512.
  • The key is your profile’s payment response hash key, used as UTF-8 text.
  • The message is the raw request body, byte for byte.
  • The header value is the result as lowercase hexadecimal (128 characters).

To verify a webhook:

  1. Read the raw body before parsing it. If your framework parses JSON first and you serialize it again, the bytes change (spacing, key order, number format) and the signature no longer matches.
  2. Compute the HMAC-SHA512 of the raw body with your hash key and encode it as lowercase hex.
  3. Compare it with the header using a constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest, MessageDigest.isEqual). A normal string comparison leaks timing information.
  4. If the header is missing or does not match, answer 401 and do nothing else. VNSwitch signs every webhook of a profile that has a hash key, and a profile gets one when it is created.

Never skip verification in production, and never log the hash key. If the key leaks, replace it in your profile settings in the VNSwitch dashboard (or contact info@nivc.ai) and update your server.

Respond quickly and handle retries

Your endpoint should answer with a 2xx status within a few seconds. Do the minimum before answering: verify the signature, store the event (or put it on a queue), and return 200. Process it afterwards.

VNSwitch treats any other response (3xx, 4xx, 5xx) or no response as a failed delivery and sends the same event again later, with increasing delays between attempts (minutes at first, then hours, for about a day by default). A retried webhook has the same event_id and the same body as the first attempt.

Because of retries and network issues, you can receive:

  • the same event more than once, and
  • events out of order (for example payment_processing after payment_succeeded).

Process webhook events safely

Follow these rules so that duplicates and out-of-order events never cause a wrong state change:

  • Process each event_id once. Before changing anything, store the event_id with a unique constraint. If it already exists, answer 200 and stop. Do the insert and your state change in the same database transaction where you can.
  • Do not trust the order of arrival. Either re-fetch the object from the API ( payments, refunds, payouts, disputes, mandates) and use its current status, or only move forward: once a payment is in a final state (succeeded, failed, cancelled, expired), never replace it with an earlier one such as processing or requires_customer_action.
  • Check the amount and currency in the object against your order before you ship goods, for example amount: 150000 and currency: "VND".
  • Make your handler safe to run twice. If your server crashes after storing the event but before finishing, the next retry must be able to finish the work.

Webhooks and API calls are complementary: use the API response for the immediate result of your call, and webhooks for anything that changes later. See Payment lifecycle for the statuses a payment goes through.