# Webhook kết quả thẻ

ePay gửi IPN về Pay2S; Pay2S xác minh chữ ký và đối soát trước khi ghi nhận kết quả. Worker Pay2S gửi **POST JSON** về webhookUrl merchant đã khai báo lúc tạo phiên. Đây là request Pay2S → merchant, không phải merchant gọi endpoint payment.

## Request thành công

Trong Demo và Playground, khung webhook hiển thị body JSON đã nhận, giữ nguyên tên trường và kiểu dữ liệu. `accessKey`, `partnerCode`, `m2signature` được thay bằng `[REDACTED]` để bảo vệ thông tin kết nối demo. Máy chủ kiểm tra chữ ký trên dữ liệu gốc trước khi che; không dùng bản hiển thị đã che để kiểm thử chữ ký. Thời gian nhận và thông tin xác minh nằm ngoài JSON, không thuộc payload. Lần thử cũ chỉ lưu bản tóm tắt sẽ được ghi rõ và cần tạo lần thử mới để xem body đầy đủ.

```json
{
  "accessKey": "EXAMPLE_ACCESS",
  "amount": 100000,
  "extraData": "",
  "message": "Paymentsuccessful",
  "orderId": "ORDER001",
  "orderInfo": "Thanh toan the ORDER001",
  "orderType": "card",
  "partnerCode": "EXAMPLE_PARTNER",
  "payType": "card",
  "requestId": 12345,
  "requestTrace": "ORDER001",
  "responseTime": 1792314000000,
  "resultCode": 0,
  "transId": "PAY2SPOP0123456789ABCDEF0123",
  "m2signature": "ea23daa153c345bccbedf5edee0a3d6eb9cfef09ac8687f499927bf039c04c77"
}
```

## Request hủy
```json
{
  "accessKey": "EXAMPLE_ACCESS",
  "amount": 100000,
  "extraData": "",
  "message": "Paymentcancelled",
  "orderId": "ORDER001",
  "orderInfo": "Thanh toan the ORDER001",
  "orderType": "card",
  "partnerCode": "EXAMPLE_PARTNER",
  "payType": "card",
  "requestId": 12345,
  "requestTrace": "ORDER001",
  "responseTime": 1792314000000,
  "resultCode": 2,
  "transId": "PAY2SPOP0123456789ABCDEF0123",
  "m2signature": "0cd7478143afed75d4e96b41edf363564add60c9e576e995d4508d290ce46f28"
}
```

Hai mẫu dùng khóa giả EXAMPLE_SECRET_NOT_A_REAL_KEY để kiểm thử chữ ký. [Tải fixture](/downloads/card-popup/webhook-fixtures.json). Không dùng khóa này cho tài khoản thật.

| Trường | Kiểu giá trị | Ý nghĩa |
| --- | --- | --- |
| accessKey | string | Access key của phạm vi tạo đơn; đối chiếu cấu hình server |
| partnerCode | string | Mã tài khoản; đối chiếu cấu hình server |
| amount | integer hoặc chuỗi số nguyên | Số tiền VND; đối chiếu giá trị đơn đã lưu |
| orderId | string | Mã đơn merchant |
| orderInfo | string | Mô tả đơn |
| orderType / payType | string | card trong luồng này |
| requestId | integer hoặc string | ID nội bộ Pay2S, không thay thế orderId |
| requestTrace | string | Mã request tham chiếu; không nằm trong chữ ký này |
| responseTime | integer | Unix mili giây trong webhook worker |
| resultCode | integer | 0 thành công, 2 hủy |
| message | string | Paymentsuccessful hoặc Paymentcancelled; giữ nguyên khi ký |
| transId | string | invoice_number Pay2S; không phải ID giao dịch thẻ ePay |
| extraData | string | Hiện là chuỗi rỗng; vẫn có trong chuỗi ký |
| m2signature | string | HMAC SHA256 hex chữ thường |

Chuẩn hiện tại gửi webhook cho thành công/hủy. failed, pending, review tra cứu bằng [API trạng thái](/docs/card-popup/status/); không có webhook failed riêng trong bộ này. Không giả định mọi kết quả không thành công đều là hủy. Webhook không chứa environment trong chuỗi ký: xác định sandbox/production từ đơn/kết nối đã lưu hoặc API.

## Xác minh

Dùng secret tương ứng với bộ khóa tạo đơn. Nối đúng thứ tự **key=value** bằng &; không URL-encode, không bỏ chuỗi rỗng, không trim/đổi kiểu trước khi tính chữ ký:
```text
accessKey,amount,extraData,message,orderId,orderInfo,orderType,partnerCode,payType,requestId,responseTime,resultCode,transId
```

HMAC SHA256 trên UTF-8, so sánh constant-time với m2signature. Chữ ký webhook khác chữ ký request API. Dùng hàm verify trong [PHP](/downloads/card-popup/client.php), [Node.js](/downloads/card-popup/client.mjs), [Python](/downloads/card-popup/pay2s_client.py).

### PHP
```php
<?php
require __DIR__.'/client.php';
$p=json_decode(file_get_contents('php://input'),true,32,JSON_THROW_ON_ERROR);
if (!is_array($p) || !verifyPay2sWebhook($p,getenv('PAY2S_SECRET_KEY'),getenv('PAY2S_PARTNER_CODE'),getenv('PAY2S_ACCESS_KEY'))) {
    http_response_code(401); exit;
}
// BẠN PHẢI TRIỂN KHAI hàm này bằng database của mình trước khi chạy:
// đối chiếu orderId, amount, môi trường; lưu inbox và cập nhật đơn đúng một lần.
persistAndApplyOnce($p);
header('Content-Type: application/json');
http_response_code(200);
echo json_encode(['success'=>true]);
```

### Node.js (handler HTTP)
```js
import {verifyWebhook} from './client.mjs';
// payload đã parse từ JSON, giới hạn kích thước body tại HTTP server.
export async function handleWebhook(payload, persistAndApplyOnce) {
  if (!verifyWebhook(payload, process.env.PAY2S_SECRET_KEY,
      process.env.PAY2S_PARTNER_CODE, process.env.PAY2S_ACCESS_KEY)) {
    return {httpStatus:401, body:{success:false}};
  }
  // Callback do ứng dụng triển khai, phải commit database trước ACK.
  await persistAndApplyOnce(payload);
  return {httpStatus:200, body:{success:true}};
}
```

### Python (handler độc lập framework)
```python
import os
from pay2s_client import verify_webhook
def handle_webhook(payload, persist_and_apply_once):
    if not verify_webhook(payload, os.environ['PAY2S_SECRET_KEY'],
                          os.environ['PAY2S_PARTNER_CODE'], os.environ['PAY2S_ACCESS_KEY']):
        return 401, {'success': False}
    # Callback do ứng dụng triển khai, phải commit database trước ACK.
    persist_and_apply_once(payload)
    return 200, {'success': True}
```

Ba handler là khung tích hợp: cần gắn vào route POST webhookUrl, giới hạn body, parse JSON, truyền đúng HTTP status/Content-Type và triển khai persistAndApplyOnce. Không ACK khi chưa lưu được hoặc dữ liệu không khớp.

## Response merchant phải trả

Sau khi lưu bền vững (kể cả webhook trùng đã được xử lý):
```http
HTTP/1.1 200 OK
Content-Type: application/json

{"success":true}
```

HTTP 200 với body rỗng hoặc chỉ {"status":true} **không phải ACK được worker hiện tại chấp nhận**. Worker cũng hỗ trợ message chứa "Received payment result success", nhưng nên dùng success=true.

Sai chữ ký: trả HTTP 401; lỗi database/tạm thời: trả HTTP 500/503 để được thử lại. Body lỗi ví dụ:
```json
{
  "success": false,
  "message": "Unable to persist payment event"
}
```


## Chống xử lý trùng và thử lại

1. Kiểm tra chữ ký và accessKey/partnerCode.
2. Tìm đơn thuộc đúng tài khoản, đối chiếu amount và environment đã lưu. Không dùng requestTrace chưa ký để quyết định giao hàng.
3. Trong transaction, khóa đơn và lưu sự kiện với unique key gồm phạm vi tài khoản + transId + resultCode.
4. Chỉ chuyển sang paid/giao hàng một lần. Hủy/failed/review không giao hàng. Sự kiện muộn mâu thuẫn trạng thái phải đối soát.
5. Commit rồi trả ACK; bản gửi trùng hợp lệ trả ACK mà không giao hàng lại.

Worker hiện đặt MAX_RETRY_COUNT=5, khoảng lùi theo nhánh số lần là 15, 60, 300, rồi 900 giây. Đây là lịch triển khai hiện tại, còn phụ thuộc cron/worker và lỗi mạng, không phải cam kết thời điểm giao. Merchant vẫn cần lưu inbox và đối soát qua API khi chưa nhận webhook.

[Mã xác minh hoàn chỉnh](/docs/card-popup/authentication/) · [Trạng thái](/docs/card-popup/status/)
