# Xác thực và ký yêu cầu

> Ký các yêu cầu API bằng HMAC-SHA256 với project UUID và API key của bạn.

Mọi yêu cầu API (ngoại trừ webhook đến) đều phải kèm theo project UUID và chữ ký yêu cầu. Chữ ký chứng minh rằng yêu cầu xuất phát từ bạn và không bị ai thay đổi trên đường truyền.

## API keys

2328.io sử dụng **hai key** dùng chung một thuật toán ký nhưng áp dụng cho các endpoint khác nhau:

| Key | Dùng cho |
|-----|----------|
| **API key** | Thanh toán, ví tĩnh, số dư, tỷ giá và xác minh webhook thanh toán / ví tĩnh |
| **Payout API key** | Tất cả endpoint `/v1/payout/*` và xác minh webhook rút tiền |

Cả hai key đều nằm trong phần cài đặt project tại [2328.io](https://2328.io). Các ví dụ bên dưới ghi chung chung là "API key" — hãy thay bằng key phù hợp cho endpoint mà bạn đang gọi.

> **INFO:** **Tuyệt đối không** lẫn lộn hai key: ký một yêu cầu rút tiền bằng API key thông thường (hoặc ký yêu cầu thanh toán bằng Payout key) sẽ trả về lỗi chữ ký.

## Headers bắt buộc

| Header | Type | Required | Description |
|--------|------|----------|-------------|
| `Content-Type` | string | yes | Luôn là `application/json` |
| `project` | string | yes | Project UUID của bạn |
| `sign` | string | yes | Chữ ký HMAC-SHA256 của yêu cầu, được tính bằng API key của bạn |
| `User-Agent` | string | có | Định danh ứng dụng của bạn (ví dụ `MyShop/1.4 (+https://myshop.example)`). Yêu cầu không có `User-Agent` có thể bị chặn. |

## Cách hoạt động của chữ ký

Hãy hình dung chữ ký như là dấu vân tay của thân yêu cầu. Nó được tạo ra bằng cách:

1. Serialize thân yêu cầu sang JSON (compact — không có khoảng trắng dư).
2. Mã hóa Base64 chuỗi JSON đó. Bước này chuẩn hóa đầu vào giữa các ngôn ngữ — khi đã là ASCII thuần, mọi ngôn ngữ đều cho ra cùng các byte để HMAC.
3. Tính **HMAC-SHA256** của chuỗi Base64 bằng API key của bạn, sau đó chuyển kết quả sang hex chữ thường.

Đối với **GET** và các loại yêu cầu khác không có thân, hãy ký chuỗi rỗng thay vì JSON.

> **INFO:** Chữ ký của chuỗi rỗng là cố định với một API key cho trước. Bạn có thể cache lại nếu thực hiện nhiều cuộc gọi GET.

## Triển khai

#### php

```php
<?php
function apiSign(array $data, string $apiKey): string {
    $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    $base64 = base64_encode($json);
    return hash_hmac('sha256', $base64, $apiKey);
}
```

#### js

```js
import crypto from "crypto";

export function apiSign(data, apiKey) {
  const json = JSON.stringify(data);
  const base64 = Buffer.from(json).toString("base64");
  return crypto.createHmac("sha256", apiKey).update(base64).digest("hex");
}
```

#### ts

```ts
import { createHmac } from "crypto";

export function apiSign(data: object, apiKey: string): string {
  const json = JSON.stringify(data);
  const base64 = Buffer.from(json).toString("base64");
  return createHmac("sha256", apiKey).update(base64).digest("hex");
}
```

#### python

```python
import json
import hmac
import hashlib
import base64

def api_sign(data: dict, api_key: str) -> str:
    # ensure_ascii=False keeps non-ASCII characters (Cyrillic, Chinese, …)
    # as-is. Without it, Python escapes them to \uXXXX and the signature
    # diverges from PHP / Node / Go.
    body = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
    b64 = base64.b64encode(body.encode("utf-8")).decode()
    return hmac.new(api_key.encode(), b64.encode(), hashlib.sha256).hexdigest()
```

#### go

```go
package sign

import (
    "bytes"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/hex"
    "encoding/json"
)

func ApiSign(data any, apiKey string) (string, error) {
    // json.Encoder with SetEscapeHTML(false) — without it, Go escapes <, >, &
    // to \u003c etc., which breaks compatibility with PHP / Node / Python.
    var buf bytes.Buffer
    enc := json.NewEncoder(&buf)
    enc.SetEscapeHTML(false)
    if err := enc.Encode(data); err != nil {
        return "", err
    }
    // Encoder appends a trailing newline — drop it.
    body := bytes.TrimRight(buf.Bytes(), "\n")

    b64 := base64.StdEncoding.EncodeToString(body)
    h := hmac.New(sha256.New, []byte(apiKey))
    h.Write([]byte(b64))
    return hex.EncodeToString(h.Sum(nil)), nil
}
```

### Yêu cầu không có thân (GET)

Đối với các yêu cầu không có thân (ví dụ `GET /v1/payout/status/{uuid}`), hãy ký một chuỗi rỗng:

#### curl

```curl
SIGN=$(printf '' | openssl dgst -sha256 -hmac "$API_KEY" -hex | awk '{print $NF}')
```

#### php

```php
$sign = hash_hmac('sha256', base64_encode(''), $apiKey);
```

#### js

```js
import { createHmac } from "crypto";

const sign = createHmac("sha256", apiKey)
  .update(Buffer.from("").toString("base64"))
  .digest("hex");
```

#### ts

```ts
import { createHmac } from "crypto";

const sign: string = createHmac("sha256", apiKey)
  .update(Buffer.from("").toString("base64"))
  .digest("hex");
```

#### python

```python
import hmac
import hashlib
import base64

sign = hmac.new(
    api_key.encode(),
    base64.b64encode(b"").decode().encode(),
    hashlib.sha256,
).hexdigest()
```

#### go

```go
package sign

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

func EmptyBodySign(apiKey string) string {
    b64 := base64.StdEncoding.EncodeToString([]byte(""))
    h := hmac.New(sha256.New, []byte(apiKey))
    h.Write([]byte(b64))
    return hex.EncodeToString(h.Sum(nil))
}
```

## Ví dụ yêu cầu đầy đủ

#### curl

```curl
curl -X POST https://api.2328.io/api/v1/payment \
  -H "Content-Type: application/json" \
  -H "User-Agent: MyShop/1.0 (+https://myshop.example)" \
  -H "project: YOUR_PROJECT_UUID" \
  -H "sign: YOUR_HMAC_SIGNATURE" \
  -d '{"amount":"100.00","currency":"USD","order_id":"ORDER-123"}'
```

#### php

```php
<?php
function apiSign(string $body, string $apiKey): string {
    return hash_hmac('sha256', base64_encode($body), $apiKey);
}

$project = 'YOUR_PROJECT_UUID';
$apiKey  = 'YOUR_API_KEY';

$data = [
    'amount'   => '100.00',
    'currency' => 'USD',
    'order_id' => 'ORDER-123',
];

$body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$sign = apiSign($body, $apiKey);

$ch = curl_init('https://api.2328.io/api/v1/payment');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'User-Agent: MyShop/1.0 (+https://myshop.example)',
        "project: $project",
        "sign: $sign",
    ],
]);

$response = json_decode(curl_exec($ch), true);
```

#### js

```js
import { createHmac } from "crypto";

function apiSign(body, apiKey) {
  const base64 = Buffer.from(body, "utf8").toString("base64");
  return createHmac("sha256", apiKey).update(base64).digest("hex");
}

const data = {
  amount: "100.00",
  currency: "USD",
  order_id: "ORDER-123",
};

const body = JSON.stringify(data);
const sign = apiSign(body, process.env.API_KEY);

const res = await fetch("https://api.2328.io/api/v1/payment", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "User-Agent": "MyShop/1.0 (+https://myshop.example)",
    project: process.env.PROJECT_UUID,
    sign,
  },
  body,
});

const json = await res.json();
```

#### ts

```ts
import { createHmac } from "crypto";

function apiSign(body: string, apiKey: string): string {
  const base64 = Buffer.from(body, "utf8").toString("base64");
  return createHmac("sha256", apiKey).update(base64).digest("hex");
}

type CreatePaymentBody = {
  amount: string;
  currency: string;
  order_id: string;
};

type CreatePaymentResponse = { state: number; result: unknown };

const data: CreatePaymentBody = {
  amount: "100.00",
  currency: "USD",
  order_id: "ORDER-123",
};

const body = JSON.stringify(data);
const sign = apiSign(body, process.env.API_KEY!);

const res = await fetch("https://api.2328.io/api/v1/payment", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "User-Agent": "MyShop/1.0 (+https://myshop.example)",
    project: process.env.PROJECT_UUID!,
    sign,
  },
  body,
});

const json = (await res.json()) as CreatePaymentResponse;
```

#### python

```python
import json
import hmac
import hashlib
import base64
import httpx

def api_sign(body: str, api_key: str) -> str:
    b64 = base64.b64encode(body.encode("utf-8")).decode()
    return hmac.new(api_key.encode(), b64.encode(), hashlib.sha256).hexdigest()

data = {
    "amount": "100.00",
    "currency": "USD",
    "order_id": "ORDER-123",
}

body = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
sign = api_sign(body, API_KEY)

r = httpx.post(
    "https://api.2328.io/api/v1/payment",
    headers={
        "Content-Type": "application/json",
        "User-Agent": "MyShop/1.0 (+https://myshop.example)",
        "project": PROJECT_UUID,
        "sign": sign,
    },
    content=body.encode("utf-8"),
)
response = r.json()
```

#### go

```go
package main

import (
    "bytes"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/hex"
    "encoding/json"
    "net/http"
)

func ApiSign(body []byte, apiKey string) string {
    b64 := base64.StdEncoding.EncodeToString(body)
    h := hmac.New(sha256.New, []byte(apiKey))
    h.Write([]byte(b64))
    return hex.EncodeToString(h.Sum(nil))
}

func marshalCanonical(v any) ([]byte, error) {
    var buf bytes.Buffer
    enc := json.NewEncoder(&buf)
    enc.SetEscapeHTML(false)
    if err := enc.Encode(v); err != nil {
        return nil, err
    }
    return bytes.TrimRight(buf.Bytes(), "\n"), nil
}

func main() {
    data := struct {
        Amount   string `json:"amount"`
        Currency string `json:"currency"`
        OrderID  string `json:"order_id"`
    }{
        Amount:   "100.00",
        Currency: "USD",
        OrderID:  "ORDER-123",
    }

    body, err := marshalCanonical(data)
    if err != nil {
        panic(err)
    }
    sign := ApiSign(body, apiKey)

    req, _ := http.NewRequest("POST",
        "https://api.2328.io/api/v1/payment",
        bytes.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("User-Agent", "MyShop/1.0 (+https://myshop.example)")
    req.Header.Set("project", projectUUID)
    req.Header.Set("sign", sign)

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()
}
```

> **DANGER:** **Tuyệt đối không để lộ API key trong mã chạy phía client.** Hãy ký yêu cầu trên backend của bạn. Một API key bị rò rỉ sẽ cho bất kỳ ai toàn quyền truy cập tài khoản merchant của bạn.

## Xác minh chữ ký webhook

Khi 2328.io gửi webhook cho bạn, cùng thuật toán đó được chạy ngược lại:

1. Lấy trường `sign` ra khỏi payload.
2. Mã hóa JSON các trường còn lại (compact, không khoảng trắng).
3. Mã hóa Base64 chuỗi đó.
4. Tính `HMAC-SHA256` bằng key phù hợp.
5. So sánh với `sign` nhận được bằng cách so sánh **thời gian không đổi** (`hash_equals`, `crypto.timingSafeEqual`, `hmac.compare_digest`, `subtle.ConstantTimeCompare`, `OpenSSL.fixed_length_secure_compare`).

Key dùng để ký phụ thuộc vào nguồn webhook:

| Webhook | Key để xác minh |
|---------|---------------------|
| Webhook thanh toán / ví tĩnh (`/v1/payment`, `/v1/static-wallet`) | **API key** |
| Webhook rút tiền (`/v1/payout`) | **Payout API key** |

> **WARNING:** **Lỗi xác minh thường gặp.** Bộ mã hóa JSON của bạn phải tạo ra **đúng những byte giống hệt** như bên gửi đã tạo — nếu không, Base64 sẽ khác đi và chữ ký sẽ không khớp.
> 
> - **Go**: dùng `json.NewEncoder` với `SetEscapeHTML(false)`. `json.Marshal` mặc định sẽ escape `<`, `>`, `&` thành `<` và làm hỏng chữ ký.
> - **Python**: truyền `ensure_ascii=False` vào `json.dumps`. Nếu không, các ký tự không thuộc ASCII (Kirin, Trung Quốc, …) sẽ bị escape thành `\uXXXX`.
> - **JSON rút gọn**: không có khoảng trắng giữa các trường (`separators=(",", ":")` trong Python).
> - **Thứ tự trường** (Go): một `map[string]any` thường sẽ ngẫu nhiên hóa thứ tự khóa khi mã hóa lại. Hãy dùng `json.RawMessage`, một struct có thứ tự, hoặc loại bỏ `sign` khỏi byte gốc.
> 
> Nếu việc xác minh vẫn thất bại, hãy tự chạy `apiSign` trên payload — nó phải tạo ra cùng chuỗi hex với `sign` đã nhận.

> **INFO:** **Một chữ ký hợp lệ không ngăn được replay.** Nó chỉ chứng minh rằng webhook đến từ 2328.io — không ngăn được kẻ tấn công gửi lại một webhook *đã bị bắt được* sau đó. Luôn kiểm tra idempotency bằng `uuid` (hoặc `txid` cho ví tĩnh) trước khi ghi có tiền. Từ chối với HTTP `401` nếu chữ ký bị thiếu hoặc sai.

Các ví dụ mã đầy đủ có trên **[Webhook Notifications](/docs/webhooks#verifying-the-signature)**. Xử lý retry và quy tắc idempotency có trong [Best practices](/docs/webhooks#best-practices).