# Xác thực API popup

Chỉ gọi API này từ backend. Dùng `partnerCode`, `accessKey`, `secretKey` hiện có của tài khoản hoặc cửa hàng. Không đặt secret trong trình duyệt, URL, log hay mã nguồn công khai.

## Header bắt buộc

| Header | Giá trị |
| --- | --- |
| Content-Type | application/json |
| X-Pay2S-Partner | partnerCode |
| X-Pay2S-Access-Key | accessKey của tài khoản/cửa hàng |
| X-Pay2S-Timestamp | Unix timestamp theo giây, lệch tối đa 300 giây |
| X-Pay2S-Signature | HMAC SHA256 dạng hex chữ thường |

Chuỗi ký gồm ba dòng, nối bằng ký tự LF (`\n`), không thêm dòng trống cuối:

```text
pay2s-card-popup-v1
<timestamp>
<SHA256 của nguyên văn body JSON UTF-8>
```

```js
import crypto from 'node:crypto';
const body = JSON.stringify(payload);
const timestamp = String(Math.floor(Date.now() / 1000));
const digest = crypto.createHash('sha256').update(body).digest('hex');
const signature = crypto.createHmac('sha256', process.env.PAY2S_SECRET_KEY)
  .update('pay2s-card-popup-v1\n' + timestamp + '\n' + digest)
  .digest('hex');
```

Gửi đúng chuỗi `body` đã ký. Đổi khoảng trắng, thứ tự khóa hoặc kiểu số sau khi ký làm chữ ký không còn khớp. Đồng bộ giờ server.

Khóa cửa hàng tự xác định phạm vi cửa hàng. Không có tham số cho phép chọn `user_id`, `store_id` hoặc merchant ePay của tài khoản khác. API backend không mở CORS để gọi bằng secret từ browser.

[Mở Playground](/playground/card-popup/) · [Tạo phiên](/docs/card-popup/create-session/)

## Cấu hình và client hoàn chỉnh

Cấu hình trên server: PAY2S_PARTNER_CODE, PAY2S_ACCESS_KEY, PAY2S_SECRET_KEY. Không đưa vào source công khai, frontend hoặc URL. PHP cần cURL; Node.js 20+ dùng fetch; Python 3.10+ dùng thư viện chuẩn. Các client không tự follow redirect có header khóa và không tự retry tạo đơn.

### php
[Tải file](/downloads/card-popup/client.php)
```php
<?php
declare(strict_types=1);
function pay2sSign(string $body, string $timestamp, string $secret): string {
    return hash_hmac('sha256', "pay2s-card-popup-v1\n".$timestamp."\n".hash('sha256',$body), $secret);
}
function pay2s(array $payload): array {
    foreach (['PAY2S_PARTNER_CODE','PAY2S_ACCESS_KEY','PAY2S_SECRET_KEY'] as $key) {
        if (!getenv($key)) throw new RuntimeException('Missing '.$key);
    }
    $body=json_encode($payload,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR);
    $timestamp=(string)time();
    $curl=curl_init('https://payment.pay2s.vn/api/card-popup-session.php');
    curl_setopt_array($curl,[CURLOPT_POST=>true,CURLOPT_POSTFIELDS=>$body,CURLOPT_RETURNTRANSFER=>true,
        CURLOPT_FOLLOWLOCATION=>false,CURLOPT_CONNECTTIMEOUT=>10,CURLOPT_TIMEOUT=>30,
        CURLOPT_HTTPHEADER=>['Content-Type: application/json','X-Pay2S-Partner: '.getenv('PAY2S_PARTNER_CODE'),
        'X-Pay2S-Access-Key: '.getenv('PAY2S_ACCESS_KEY'),'X-Pay2S-Timestamp: '.$timestamp,
        'X-Pay2S-Signature: '.pay2sSign($body,$timestamp,getenv('PAY2S_SECRET_KEY'))]]);
    $raw=curl_exec($curl);$status=(int)curl_getinfo($curl,CURLINFO_HTTP_CODE);$error=curl_error($curl);curl_close($curl);
    if ($raw===false) throw new RuntimeException('Pay2S transport error: '.$error);
    $result=json_decode($raw,true,32,JSON_THROW_ON_ERROR);
    if ($status!==200 || ($result['status']??false)!==true) {
        throw new RuntimeException('HTTP '.$status.': '.($result['message']??'Pay2S request failed').' reference='.($result['data']['reference']??''));
    }
    return $result['data'];
}
function verifyPay2sWebhook(array $payload,string $secret,string $partner,string $access): bool {
    if (($payload['partnerCode']??null)!==$partner || ($payload['accessKey']??null)!==$access) return false;
    $fields=['accessKey','amount','extraData','message','orderId','orderInfo','orderType','partnerCode','payType','requestId','responseTime','resultCode','transId'];
    $parts=[];
    foreach($fields as $key){
        if(!array_key_exists($key,$payload)||(!is_string($payload[$key])&&!is_int($payload[$key])))return false;
        $parts[]=$key.'='.$payload[$key];
    }
    if(!is_string($payload['m2signature']??null)||!preg_match('/^[a-f0-9]{64}$/D',$payload['m2signature']))return false;
    return hash_equals(hash_hmac('sha256',implode('&',$parts),$secret),$payload['m2signature']);
}
```

### js
[Tải file](/downloads/card-popup/client.mjs)
```js
import crypto from 'node:crypto';
export function sign(body, timestamp, secret) {
  return crypto.createHmac('sha256', secret).update('pay2s-card-popup-v1\n' + timestamp + '\n' + crypto.createHash('sha256').update(body, 'utf8').digest('hex')).digest('hex');
}
export async function pay2s(payload) {
  const env = process.env;
  for (const key of ['PAY2S_PARTNER_CODE','PAY2S_ACCESS_KEY','PAY2S_SECRET_KEY']) if (!env[key]) throw new Error('Missing ' + key);
  const body = JSON.stringify(payload), timestamp = String(Math.floor(Date.now()/1000));
  const response = await fetch('https://payment.pay2s.vn/api/card-popup-session.php', {
    method:'POST', redirect:'error', signal:AbortSignal.timeout(30000),
    headers:{'Content-Type':'application/json','X-Pay2S-Partner':env.PAY2S_PARTNER_CODE,
      'X-Pay2S-Access-Key':env.PAY2S_ACCESS_KEY,'X-Pay2S-Timestamp':timestamp,
      'X-Pay2S-Signature':sign(body,timestamp,env.PAY2S_SECRET_KEY)}, body
  });
  const result = await response.json();
  if (!response.ok || result.status !== true) {
    const error = new Error(result.message || 'Pay2S request failed');
    error.httpStatus=response.status;error.reference=result.data?.reference;throw error;
  }
  return result.data;
}
const fields=['accessKey','amount','extraData','message','orderId','orderInfo','orderType','partnerCode','payType','requestId','responseTime','resultCode','transId'];
export function verifyWebhook(payload, secret, partner, access) {
  if (!payload || payload.partnerCode!==partner || payload.accessKey!==access) return false;
  if (fields.some(k=>!Object.hasOwn(payload,k)||!['string','number'].includes(typeof payload[k])||(typeof payload[k]==='number'&&!Number.isSafeInteger(payload[k])))) return false;
  if (!/^[a-f0-9]{64}$/.test(payload.m2signature||'')) return false;
  const expected=crypto.createHmac('sha256',secret).update(fields.map(k=>k+'='+payload[k]).join('&'),'utf8').digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected),Buffer.from(payload.m2signature));
}
```

### python
[Tải file](/downloads/card-popup/pay2s_client.py)
```python
import os
import json
import time
import hashlib
import hmac
import urllib.request
import urllib.error

def sign(body: bytes, timestamp: str, secret: str) -> str:
    canonical = 'pay2s-card-popup-v1\n' + timestamp + '\n' + hashlib.sha256(body).hexdigest()
    return hmac.new(secret.encode('utf-8'), canonical.encode('utf-8'), hashlib.sha256).hexdigest()

class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None

def pay2s(payload: dict) -> dict:
    partner, access, secret = [os.environ[k] for k in ('PAY2S_PARTNER_CODE','PAY2S_ACCESS_KEY','PAY2S_SECRET_KEY')]
    body = json.dumps(payload, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode('utf-8')
    timestamp = str(int(time.time()))
    request = urllib.request.Request('https://payment.pay2s.vn/api/card-popup-session.php', data=body, method='POST', headers={
        'Content-Type':'application/json', 'X-Pay2S-Partner':partner, 'X-Pay2S-Access-Key':access,
        'X-Pay2S-Timestamp':timestamp, 'X-Pay2S-Signature':sign(body,timestamp,secret)})
    try:
        with urllib.request.build_opener(NoRedirect()).open(request, timeout=30) as response:
            result = json.load(response)
    except urllib.error.HTTPError as error:
        try:
            detail = json.loads(error.read())
        except (ValueError, UnicodeError):
            detail = {'message':'Non-JSON error response'}
        raise RuntimeError(f"HTTP {error.code}: {detail.get('message')} reference={detail.get('data', {}).get('reference', '') if isinstance(detail.get('data'), dict) else ''}") from error
    if result.get('status') is not True:
        raise RuntimeError(result.get('message', 'Pay2S request failed'))
    return result['data']

FIELDS = ['accessKey','amount','extraData','message','orderId','orderInfo','orderType','partnerCode','payType','requestId','responseTime','resultCode','transId']
def verify_webhook(payload: dict, secret: str, partner: str, access: str) -> bool:
    if not isinstance(payload, dict) or payload.get('partnerCode') != partner or payload.get('accessKey') != access:
        return False
    if any(k not in payload or type(payload[k]) not in (str, int) for k in FIELDS):
        return False
    signature = payload.get('m2signature')
    if not isinstance(signature, str) or len(signature) != 64 or any(c not in '0123456789abcdef' for c in signature):
        return False
    canonical = '&'.join(k + '=' + str(payload[k]) for k in FIELDS)
    expected = hmac.new(secret.encode('utf-8'), canonical.encode('utf-8'), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
```

## Ký file JSON để gọi cURL (Bash + OpenSSL)
```bash
TIMESTAMP=$(date +%s)
BODY_HASH=$(openssl dgst -sha256 request.json | awk '{print $NF}')
SIGNATURE=$(printf 'pay2s-card-popup-v1\n%s\n%s' "$TIMESTAMP" "$BODY_HASH" | openssl dgst -sha256 -hmac "$PAY2S_SECRET_KEY" | awk '{print $NF}')
```

Chỉ dùng môi trường CLI riêng để thử; khóa truyền qua tham số OpenSSL có thể xuất hiện trong danh sách tiến trình. Ứng dụng production dùng client trong process. Byte newline cuối file request.json cũng phải được tính vào SHA256.

## Envelope và lỗi HTTP

| Trường | Kiểu | Ý nghĩa |
| --- | --- | --- |
| status / success | boolean | Kết quả thao tác API, không phải trạng thái thanh toán |
| message | string | Thông báo; không dùng làm mã nghiệp vụ cố định |
| data | object/array | Dữ liệu; lỗi có thể là [] hoặc {reference:...} |

| HTTP | Xử lý |
| --- | --- |
| 200 | Đọc data; create thành công chưa phải thanh toán thành công |
| 400 | webhookUrl bị từ chối theo kiểm tra URL công khai; sửa URL trước khi gửi lại |
| 403 | Tính năng popup chưa bật |
| 404 | Không tìm thấy phiên trong phạm vi khóa |
| 405 | Sai phương thức; endpoint yêu cầu POST |
| 409 | Sai chữ ký/timestamp, dữ liệu hoặc điều kiện, xung đột orderId; sửa nguyên nhân trước khi gửi lại |
| 413 | Body vượt 32768 byte |
| 429 | Giảm tần suất; không thay orderId để né giới hạn |
| 503 | Lỗi dịch vụ; giữ request, kiểm tra lại; liên hệ hỗ trợ với reference nếu có |

JSON hỏng hiện có thể đi qua exception handler chung thành 503; không giả định luôn trả 400. Luôn gửi JSON object đúng schema. Mã này chưa có errorCode nghiệp vụ riêng. Không log secret, chữ ký đầy đủ hoặc popupUrl.

Giới hạn Playground **10 đơn mỗi phiên trình duyệt** và 2.000–1.000.000 VND chỉ thuộc trang trải nghiệm; không phải quota của API merchant.
