> 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/en/webhooks/verify-signatures.md).

# Verify signatures

Check the Loomgate-Signature header (Ed25519) in Node.js, PHP or Python.

Every webhook has the header:

```
Loomgate-Signature: t=<unix seconds>,kid=<key id>,v1=<base64 signature>
```

## Algorithm

1. Parse `t`, `kid` and `v1` from the header. Missing header → `missing_header`; malformed → `malformed_header`.
2. Get the public key with that `kid` from [`GET /partner/v1/webhook_signing_keys`](/en/api-reference/webhook-signing-keys.md). Not found → reload the list once; still not found → `unknown_key`.
3. If `|now − t|` is greater than 300 seconds → `timestamp_out_of_tolerance` (replay protection).
4. The signed content is the UTF-8 string:

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

   where `endpoint_id` is **your** endpoint ID (`we_…`) and `raw_body` is the **raw** body, byte for byte, not JSON that was parsed and then stringified again.
5. Verify the Ed25519 signature `v1` (base64) over that content with the public key. Invalid → `invalid_signature`.

{% hint style="warning" %}
The most common mistake is using a body that your framework has already parsed as JSON. Get the raw body: `express.raw()` in Express, `file_get_contents('php://input')` in PHP, `request.get_data()` in Flask.
{% endhint %}

## Sample code

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

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

// rawBody: the raw body as a Buffer or string
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 (no SDK)" %}

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

/** Returns 'ok' or the failure reason. rawBody: the raw body (Buffer or 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
/**
 * Returns 'ok' or the failure reason. Requires ext-sodium (PHP ≥ 7.2) or 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';
    }

    // An Ed25519 SPKI key is 44 bytes of DER: a fixed 12-byte prefix followed by the 32-byte key.
    $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';
}

// Example usage:
$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:
    """Returns "ok" or the failure reason. raw_body: the raw body (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 %}

## Signing key rotation

* Cache the key list from `GET /partner/v1/webhook_signing_keys`. When you see an unknown `kid`, reload the list **once** and check again.
* The key with `active: true` signs new webhooks. A key with `active: false` that is still in the list is still used for verification (retried webhooks may be signed with an older key).
* A key removed from the list is no longer trusted: don't cache for too long (for example at most 1 hour), and replace the whole cache on every reload.

## Test your code

The following data set (a test key, not used for real webhooks) must give `ok` when `now = 1790000010`, and `invalid_signature` when you change `endpoint_id` to `we_test_endpoint_B` or change one character of the 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/en/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.
