Webhooks
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
- Create an HTTPS endpoint on your server that accepts
POSTrequests with a JSON body, for examplehttps://merchant.example.vn/webhooks/vnswitch. - 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.
- 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_KEYenvironment 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.
| Field | Type | Description |
|---|---|---|
merchant_id | string | Your merchant account ID. |
event_id | string | Unique ID of this event, for example evt_01JBX7Q8M2. Use it to detect duplicates. |
event_type | string | What happened. See Webhook event types. |
content.type | string | Kind of object in content.object: payment_details, refund_details, dispute_details, mandate_details or payout_details. |
content.object | object | The full object, in the same format as the API returns it: a payment, refund, dispute, mandate or payout. |
timestamp | string | When the event was created (ISO 8601, UTC). |
processor_merchant_id | string | The 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_type | content.type | Sent when |
|---|---|---|
payment_succeeded | payment_details | The payment succeeded (status: "succeeded"). |
payment_failed | payment_details | The payment failed. |
payment_processing | payment_details | The payment is being processed and the result is not known yet. |
payment_authorized | payment_details | The payment is authorized and waits for capture (requires_capture). |
payment_partially_authorized | payment_details | Only part of the amount was authorized. |
payment_captured | payment_details | Part of the amount was captured (partially_captured). |
payment_cancelled | payment_details | The payment was cancelled. |
payment_cancelled_post_capture | payment_details | The payment was cancelled after capture. |
payment_expired | payment_details | The payment expired before it was completed. |
action_required | payment_details | The payment needs action from the customer or from you, or is under review. |
refund_succeeded | refund_details | The refund succeeded. |
refund_failed | refund_details | The refund failed. |
refund_review | refund_details | The refund needs manual review. |
dispute_opened | dispute_details | The customer opened a dispute. |
dispute_challenged | dispute_details | The dispute was challenged with evidence. |
dispute_accepted | dispute_details | The dispute was accepted. |
dispute_won | dispute_details | The dispute was decided in your favour. |
dispute_lost | dispute_details | The dispute was decided against you. |
dispute_cancelled | dispute_details | The dispute was cancelled. |
dispute_expired | dispute_details | The time to respond to the dispute ran out. |
mandate_active | mandate_details | The mandate became active and can be charged. |
mandate_revoked | mandate_details | The mandate was revoked. Stop charging it. |
payout_initiated | payout_details | The payout was sent to the provider. |
payout_success | payout_details | The payout reached the recipient. |
payout_failed | payout_details | The payout failed. |
payout_cancelled | payout_details | The payout was cancelled. |
payout_expired | payout_details | The payout expired. |
payout_reversed | payout_details | A completed payout was reversed. |
payout_not_permitted | payout_details | The 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:
- 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.
- Compute the HMAC-SHA512 of the raw body with your hash key and encode it as lowercase hex.
- Compare it with the header using a constant-time comparison (
crypto.timingSafeEqual,hmac.compare_digest,MessageDigest.isEqual). A normal string comparison leaks timing information. - If the header is missing or does not match, answer
401and 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_processingafterpayment_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_idonce. Before changing anything, store theevent_idwith a unique constraint. If it already exists, answer200and 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 asprocessingorrequires_customer_action. - Check the amount and currency in the object against your order before you ship goods,
for example
amount: 150000andcurrency: "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.