> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.orderupb2b.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.orderupb2b.com/_mcp/server.

# 驗證與簽章

每個 `/v1` 請求都必須帶上四個標頭：`X-Client-Id`、`X-Timestamp`、`X-Nonce`、`X-Signature`。伺服器會用這四個值加上請求本身的內容重新算出一次簽章，與 `X-Signature` 比對，只有相符才會放行。

## 四個標頭

| 標頭            | 說明                                                                                                  |
| ------------- | --------------------------------------------------------------------------------------------------- |
| `X-Client-Id` | OrderUp 配發的用戶端識別碼，開通時提供。                                                                            |
| `X-Timestamp` | 送出請求當下的 Unix 秒數（不是毫秒）。與伺服器時間的誤差必須在 ±300 秒內，超過會被視為簽章失敗。                                              |
| `X-Nonce`     | 本次請求專用的隨機字串，例如不帶連字號的 UUID。伺服器會記住用過的 nonce，同一個 nonce 在有效期間內重複出現會被視為重放攻擊而拒絕，所以每次請求都要換一個新的值，不能固定或重複使用。 |
| `X-Signature` | 用 `client_secret` 對 canonical string 做 HMAC-SHA256 後，再轉成 Base64 的結果。                                |

## canonical string 的組成

canonical string 是簽章真正計算的對象，由五行組成，行與行之間用換行字元（`\n`）相接：

```text
{HTTP 方法}
{路徑，含 query string}
x-timestamp:{X-Timestamp 的值}
x-nonce:{X-Nonce 的值}
body-sha256:{request body 的 SHA-256，十六進位小寫}
```

五行的順序固定，欄位名稱（`x-timestamp:`、`x-nonce:`、`body-sha256:`）也要照抄，這不是欄位說明文字，是簽章輸入的一部分。

### path 一定含 query string，而且逐字不正規化

第二行的「路徑」不是只有 `/v1/customers` 這種乾淨路徑——只要請求帶了 query string，就要把 `?` 之後的部分原封不動接在路徑後面一起簽。伺服器驗證時是把收到的路徑與 query string原樣串起來計算，**不會排序參數，也不會做任何正規化**：你送出什麼樣的位元組，就要簽那個樣子的位元組，順序、大小寫、百分號編碼都必須與實際送出的請求完全一致。

舉例，一個查詢客戶清單的請求：

```text
GET /v1/customers?page=1&updatedSince=2026-07-01T00%3A00%3A00Z
```

canonical string 的第二行必須是：

```text
/v1/customers?page=1&updatedSince=2026-07-01T00%3A00%3A00Z
```

要簽的是帶著 `%3A` 的原始字串，不是解碼後的 `/v1/customers?page=1&updatedSince=2026-07-01T00:00:00Z`——把 `%3A` 解成 `:` 再簽，算出來的簽章會跟伺服器重算的不一樣，直接收到 `401`。同理，如果你的程式在送出前把參數重新排序（例如把 `updatedSince` 排到 `page` 前面），就必須簽「重新排序後、實際送出的那個字串」，而不是原本想像中的順序——一切以「這個 HTTP 請求實際傳輸的那一行」為準。

沒有 query string 的請求（例如 `POST /v1/customers`）不受影響，第二行就是乾淨的路徑本身，這也是下面 fixture 範例採用的情況。

### 沒有 body 時，仍要算空字串的雜湊

`GET` 請求或任何沒有 body 的請求，第五行不能省略，也不能留空——要對「長度為 0 的空位元組序列」算 SHA-256，而不是對空字串本身做特殊處理跳過這一步。空位元組序列的 SHA-256 固定是：

```text
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```

所以一個沒有 body 的 `GET /v1/product-categories` 請求，canonical string 的最後一行會是 `body-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`。

## 逐字核對的簽章範例

下面這組數字是用正式簽章邏輯實際跑出來的固定輸入與輸出，用來讓你核對自己那端算出來的 canonical string 與簽章是否正確。**直接照抄這些值來驗證你的實作，不要自己重新湊一組。**

**`固定輸入`**

```json title="固定輸入"
{
  "method": "POST",
  "path": "/v1/customers",
  "timestampUnix": 1757400000,
  "nonce": "3f7a9e2c8b414d6a9c2e1a5f6d8b3c07",
  "secret": "example_client_secret_do_not_use_in_production",
  "body": "{\"outerSysCode\":\"ERP-C-0001\",\"name\":\"測試客戶股份有限公司\"}"
}
```

**`body 的 SHA-256`**

```text title="body 的 SHA-256"
ed3435a4f20e04fb61c25521bd1c5fdc1a8bb6e1bdf501333b0617400a5a6aba
```

**`canonical string`**

```text title="canonical string"
POST
/v1/customers
x-timestamp:1757400000
x-nonce:3f7a9e2c8b414d6a9c2e1a5f6d8b3c07
body-sha256:ed3435a4f20e04fb61c25521bd1c5fdc1a8bb6e1bdf501333b0617400a5a6aba
```

**`X-Signature（Base64）`**

```text title="X-Signature（Base64）"
tZ0/zywCPA9WgMwfhueS33FSNPnfPDHmujjwIWn7aTw=
```

`secret` 一欄是這組範例專用的測試值，本身就標明「請勿用於正式環境」——正式的 `client_secret` 由 OrderUp 在開通時提供，不會是這個值。

## 簽章函式範例

三段範例實作的是同一套演算法：先算 body 的 SHA-256，組出五行 canonical string，再用 `client_secret` 對它做 HMAC-SHA256 並轉成 Base64。用上面的固定輸入分別代入，應該都能算出同一個 `X-Signature`。

**`Node.js`**

```javascript title="Node.js"
const crypto = require('node:crypto');

function buildCanonicalString({ method, pathAndQuery, timestampUnix, nonce, body }) {
  const bodyBytes = Buffer.isBuffer(body) ? body : Buffer.from(body ?? '', 'utf8');
  const bodySha256Hex = crypto.createHash('sha256').update(bodyBytes).digest('hex');

  return [
    method,
    pathAndQuery,
    `x-timestamp:${timestampUnix}`,
    `x-nonce:${nonce}`,
    `body-sha256:${bodySha256Hex}`,
  ].join('\n');
}

function signRequest(secret, request) {
  const canonicalString = buildCanonicalString(request);
  return crypto
    .createHmac('sha256', Buffer.from(secret, 'utf8'))
    .update(Buffer.from(canonicalString, 'utf8'))
    .digest('base64');
}

// signRequest('example_client_secret_do_not_use_in_production', {
//   method: 'POST',
//   pathAndQuery: '/v1/customers',
//   timestampUnix: 1757400000,
//   nonce: '3f7a9e2c8b414d6a9c2e1a5f6d8b3c07',
//   body: '{"outerSysCode":"ERP-C-0001","name":"測試客戶股份有限公司"}',
// });
// => "tZ0/zywCPA9WgMwfhueS33FSNPnfPDHmujjwIWn7aTw="
```

**`C#`**

```csharp title="C#"
using System;
using System.Security.Cryptography;
using System.Text;

public static class RequestSigner
{
    public static string BuildCanonicalString(
        string method, string pathAndQuery, long timestampUnix, string nonce, byte[] body)
    {
        var bodySha256Hex = Convert.ToHexStringLower(SHA256.HashData(body));

        return string.Join(
            '\n',
            method,
            pathAndQuery,
            $"x-timestamp:{timestampUnix}",
            $"x-nonce:{nonce}",
            $"body-sha256:{bodySha256Hex}");
    }

    public static string SignRequest(
        string secret, string method, string pathAndQuery, long timestampUnix, string nonce, byte[] body)
    {
        var canonicalString = BuildCanonicalString(method, pathAndQuery, timestampUnix, nonce, body);
        var keyBytes = Encoding.UTF8.GetBytes(secret);
        var messageBytes = Encoding.UTF8.GetBytes(canonicalString);

        return Convert.ToBase64String(HMACSHA256.HashData(keyBytes, messageBytes));
    }
}

// RequestSigner.SignRequest(
//     "example_client_secret_do_not_use_in_production",
//     "POST",
//     "/v1/customers",
//     1757400000,
//     "3f7a9e2c8b414d6a9c2e1a5f6d8b3c07",
//     Encoding.UTF8.GetBytes("{\"outerSysCode\":\"ERP-C-0001\",\"name\":\"測試客戶股份有限公司\"}"));
// => "tZ0/zywCPA9WgMwfhueS33FSNPnfPDHmujjwIWn7aTw="
```

**`Python`**

```python title="Python"
import hashlib
import hmac
import base64


def build_canonical_string(method, path_and_query, timestamp_unix, nonce, body: bytes) -> str:
    body_sha256_hex = hashlib.sha256(body).hexdigest()

    return "\n".join([
        method,
        path_and_query,
        f"x-timestamp:{timestamp_unix}",
        f"x-nonce:{nonce}",
        f"body-sha256:{body_sha256_hex}",
    ])


def sign_request(secret: str, method, path_and_query, timestamp_unix, nonce, body: bytes) -> str:
    canonical_string = build_canonical_string(method, path_and_query, timestamp_unix, nonce, body)
    digest = hmac.new(secret.encode("utf-8"), canonical_string.encode("utf-8"), hashlib.sha256).digest()
    return base64.b64encode(digest).decode("ascii")


# sign_request(
#     "example_client_secret_do_not_use_in_production",
#     "POST",
#     "/v1/customers",
#     1757400000,
#     "3f7a9e2c8b414d6a9c2e1a5f6d8b3c07",
#     '{"outerSysCode":"ERP-C-0001","name":"測試客戶股份有限公司"}'.encode("utf-8"),
# )
# => "tZ0/zywCPA9WgMwfhueS33FSNPnfPDHmujjwIWn7aTw="
```

沒有 body 的請求（例如 `GET`），把上面函式的 `body` 參數傳入長度為 0 的空位元組（Node 的 `Buffer.alloc(0)`、C# 的 `Array.Empty<byte>()`、Python 的 `b""`）即可，不需要另外分支處理。

## 收到 401 時的排查清單

`401` 代表這個請求沒有通過驗證，回應本身不會說明是哪一項失敗（避免洩漏線索給攻擊者）。失敗的原因不只簽章一種——四個標頭少帶或帶了空值、`X-Timestamp` 不是純數字、`X-Client-Id` 不是 OrderUp 配發的值或尚未開通，得到的都是一模一樣的 `401`。依序檢查：

1. **四個標頭沒有全部帶齊，或其中一個是空值。** `X-Client-Id`、`X-Timestamp`、`X-Nonce`、`X-Signature` 缺任何一個、或值是空字串，都會在簽章比對之前就被擋下。另外 `X-Timestamp` 必須是 Unix 秒數的純數字，寫成 ISO-8601 時間字串或帶小數點的毫秒值一樣會被拒絕。
2. **`X-Client-Id` 打錯了，或這組憑證還沒開通。** 這個值必須與 OrderUp 配發給你的完全一致（大小寫也算），而且該組憑證必須已經是啟用狀態。剛拿到憑證、還在等開通就先接上去測，看到的就是這個 `401`——若其他項都排除了，請先向 OrderUp 確認這組 `X-Client-Id` 已經開通。
3. **本機時鐘沒有對齊 NTP。** `X-Timestamp` 與伺服器時間誤差超過 ±300 秒就會被拒絕，機器上的時間飄移是最常見的原因。
4. **`X-Nonce` 被重複使用。** 每次請求都要換一個新的隨機值；重試同一個請求時如果沿用了舊的 nonce 而不是重新產生，會被視為重放攻擊。
5. **簽的是正規化過的 query string，不是實際送出的那個。** 常見於 HTTP 用戶端函式庫會自動幫你排序參數或把 `%3A` 解碼成 `:`——簽章必須用「線路上實際傳輸的那個路徑＋query string」，見上面「path 一定含 query string」的說明。
6. **body 在簽完名之後又被序列化器動過。** 例如簽章時用了一份 JSON 字串，送出時卻讓 HTTP 函式庫重新序列化物件（欄位順序、空白、跳脫字元都可能因此改變），這樣簽章對應的 body 就跟實際送出的 body 不是同一組位元組了。務必確保「拿去算 `body-sha256` 的那份 body」與「實際放進請求裡的那份 body」逐位元組相同。
7. **`client_secret` 前後夾帶了空白或換行。** 從設定檔或環境變數讀取時很容易多帶一個換行字元，導致 HMAC 用的 key 跟伺服器那端不一致。

排除以上七項仍然失敗，請聯繫 OrderUp，並提供發生問題那次請求的時間戳記與 nonce（請勿提供 `client_secret`），協助排查。