> For the complete documentation index, see [llms.txt](https://docs.loomgate.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.loomgate.io/webhooks/verify-signatures.md).

# Xác minh chữ ký

Kiểm tra header Loomgate-Signature (Ed25519) bằng Node.js, PHP hoặc Python.

Mỗi webhook có header:

```
Loomgate-Signature: t=<unix giây>,kid=<id khóa>,v1=<chữ ký base64>
```

## Thuật toán

1. Tách `t`, `kid`, `v1` từ header. Thiếu header → `missing_header`; sai định dạng → `malformed_header`.
2. Lấy khóa công khai có `kid` đó từ [`GET /partner/v1/webhook_signing_keys`](/api-reference/webhook-signing-keys.md). Không có → tải lại danh sách một lần; vẫn không có → `unknown_key`.
3. `|bây giờ − t|` lớn hơn 300 giây → `timestamp_out_of_tolerance` (chống phát lại).
4. Nội dung được ký là chuỗi UTF-8:

   ```
   {t}.{endpoint_id}.{raw_body}
   ```

   trong đó `endpoint_id` là ID endpoint của **bạn** (`we_…`) và `raw_body` là body **gốc** đúng từng byte, không phải JSON đã parse rồi stringify lại.
5. Kiểm tra chữ ký Ed25519 `v1` (base64) trên nội dung đó bằng khóa công khai. Sai → `invalid_signature`.

{% hint style="warning" %}
Lỗi hay gặp nhất là dùng body đã bị framework parse thành JSON. Hãy lấy body thô: `express.raw()` trong Express, `file_get_contents('php://input')` trong PHP, `request.get_data()` trong Flask.
{% endhint %}

## Code mẫu

{% tabs %}
{% tab title="Node.js (SDK)" %}

```js
import { verifyWebhook } from '@loompay/loomgate-js-sdk/server';

// rawBody: Buffer hoặc string của body gốc
const result = verifyWebhook(rawBody, req.get('Loomgate-Signature'), {
  endpointId: process.env.LOOMGATE_WEBHOOK_ENDPOINT_ID, // we_…
  publicKeys, // { [kid]: public_key_pem }
});
if (!result.ok) return res.status(400).send(result.code);
```

{% endtab %}

{% tab title="Node.js (không SDK)" %}

```js
import crypto from 'node:crypto';

/** Trả 'ok' hoặc lý do thất bại. rawBody: body gốc (Buffer hoặc string). */
export function verifyLoomgateSignature(rawBody, header, endpointId, publicKeys, { toleranceSeconds = 300, now = Date.now() / 1000 } = {}) {
  if (!header) return 'missing_header';
  const parts = {};
  for (const item of header.split(',')) {
    const i = item.indexOf('=');
    if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim();
  }
  if (!/^\d+$/.test(parts.t ?? '') || !parts.kid || !parts.v1) return 'malformed_header';
  const pem = publicKeys[parts.kid];
  if (!pem) return 'unknown_key';
  if (Math.abs(now - Number(parts.t)) > toleranceSeconds) return 'timestamp_out_of_tolerance';

  const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8');
  const payload = Buffer.concat([Buffer.from(`${parts.t}.${endpointId}.`, 'utf8'), body]);
  try {
    const ok = crypto.verify(null, payload, crypto.createPublicKey(pem), Buffer.from(parts.v1, 'base64'));
    return ok ? 'ok' : 'invalid_signature';
  } catch {
    return 'invalid_signature';
  }
}
```

{% endtab %}

{% tab title="PHP" %}

```php
<?php
/**
 * Trả 'ok' hoặc lý do thất bại. Cần ext-sodium (PHP ≥ 7.2) hoặc sodium_compat.
 * $rawBody: file_get_contents('php://input'). $publicKeys: [kid => SPKI PEM].
 */
function loomgate_verify_signature(string $rawBody, ?string $header, string $endpointId, array $publicKeys, int $toleranceSeconds = 300, ?int $now = null): string
{
    if ($header === null || $header === '') {
        return 'missing_header';
    }
    $parts = [];
    foreach (explode(',', $header) as $item) {
        $pair = explode('=', $item, 2);
        if (count($pair) === 2) {
            $parts[trim($pair[0])] = trim($pair[1]);
        }
    }
    $t = $parts['t'] ?? '';
    if (!ctype_digit($t) || empty($parts['kid']) || empty($parts['v1'])) {
        return 'malformed_header';
    }
    if (!isset($publicKeys[$parts['kid']])) {
        return 'unknown_key';
    }
    if (abs(($now ?? time()) - (int) $t) > $toleranceSeconds) {
        return 'timestamp_out_of_tolerance';
    }

    // Khóa Ed25519 dạng SPKI là 44 byte DER: 12 byte tiền tố cố định rồi 32 byte khóa.
    $der = base64_decode(preg_replace('/-----[A-Z ]+-----|\s+/', '', $publicKeys[$parts['kid']]), true);
    if ($der === false || strlen($der) !== 44 || substr($der, 0, 12) !== hex2bin('302a300506032b6570032100')) {
        return 'invalid_signature';
    }
    $signature = base64_decode($parts['v1'], true);
    if ($signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) {
        return 'invalid_signature';
    }
    $payload = $t . '.' . $endpointId . '.' . $rawBody;
    return sodium_crypto_sign_verify_detached($signature, $payload, substr($der, 12)) ? 'ok' : 'invalid_signature';
}

// Ví dụ dùng:
$result = loomgate_verify_signature(file_get_contents('php://input'), $_SERVER['HTTP_LOOMGATE_SIGNATURE'] ?? null, 'we_…', $publicKeys);
if ($result !== 'ok') {
    http_response_code(400);
    exit($result);
}
```

{% endtab %}

{% tab title="Python" %}

```python
# pip install cryptography
import base64
import binascii
import re
import time

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import load_pem_public_key


def verify_loomgate_signature(raw_body: bytes, header, endpoint_id: str, public_keys: dict,
                          tolerance_seconds: int = 300, now: float | None = None) -> str:
    """Trả "ok" hoặc lý do thất bại. raw_body: body gốc (bytes)."""
    if not header:
        return "missing_header"
    parts = {}
    for item in header.split(","):
        key, sep, value = item.partition("=")
        if sep:
            parts[key.strip()] = value.strip()
    t, kid, v1 = parts.get("t", ""), parts.get("kid"), parts.get("v1")
    if not re.fullmatch(r"\d+", t) or not kid or not v1:
        return "malformed_header"
    pem = public_keys.get(kid)
    if not pem:
        return "unknown_key"
    if abs((time.time() if now is None else now) - int(t)) > tolerance_seconds:
        return "timestamp_out_of_tolerance"

    payload = f"{t}.{endpoint_id}.".encode() + raw_body
    try:
        key = load_pem_public_key(pem.encode())
        if not isinstance(key, Ed25519PublicKey):
            return "invalid_signature"
        key.verify(base64.b64decode(v1, validate=True), payload)
        return "ok"
    except (InvalidSignature, ValueError, binascii.Error):
        return "invalid_signature"

# Flask: verify_loomgate_signature(request.get_data(), request.headers.get("Loomgate-Signature"), "we_…", public_keys)
```

{% endtab %}
{% endtabs %}

## Xoay khóa ký

* Cache danh sách khóa từ `GET /partner/v1/webhook_signing_keys`. Gặp `kid` lạ, tải lại danh sách **một lần** rồi kiểm tra lại.
* Khóa có `active: true` đang ký webhook mới. Khóa `active: false` vẫn còn trong danh sách thì vẫn dùng để xác minh (webhook gửi lại có thể được ký bằng khóa cũ).
* Khóa bị gỡ khỏi danh sách thì không tin nữa: đừng cache quá lâu (ví dụ tối đa 1 giờ), và thay toàn bộ cache mỗi lần tải.

## Tự kiểm tra code của bạn

Bộ dữ liệu sau (khóa thử, không dùng cho webhook thật) phải cho kết quả `ok` khi `now = 1790000010`, và `invalid_signature` khi đổi `endpoint_id` thành `we_test_endpoint_B` hoặc sửa một ký tự của body:

```json
{
  "endpoint_id": "we_test_endpoint_A",
  "public_keys": {
    "test-k1": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEACohLp4pIK41EKWONIhPyYrJxpbc3GSzrxacRtrriabA=\n-----END PUBLIC KEY-----\n"
  },
  "header": "t=1790000000,kid=test-k1,v1=/lvIiLtH/ndQufwkco75zmDQjuagCiDFztEbB1NLT8RateCwUEvzdIh2P/ZoXtm3xJ9eeamN2ZYxKvBag+iADQ==",
  "body": "{\"id\":\"evt_test_1\",\"object\":\"event\",\"type\":\"payment_intent.succeeded\",\"created\":1790000000,\"data\":{\"object\":{\"id\":\"lg_pi_test_1\",\"object\":\"payment_intent\",\"amount\":10000,\"amount_total\":10320,\"currency\":\"usd\",\"status\":\"succeeded\"}}}"
}
```


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.loomgate.io/webhooks/verify-signatures.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
