# Verifying Webhook Signatures

Product: Direct Trade API
Guides follow API reference 0.1.0 and event reference 0.1.0.
Canonical page: https://docs.axiym.io/direct-trade-api/webhooks/verifying-signatures

Axiym signs webhook deliveries so your receiver can verify that the request was
sent by Axiym and that the body was not changed in transit. Verify the
signature before trusting or processing the event payload.

## What is signed

Axiym signs the raw HTTP request body bytes for the webhook `POST` request.

Do not parse the JSON and then re-serialize it for verification. Changes to
whitespace, field order, or encoding will produce a different byte sequence and
the signature check will fail.

```json
{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "timestamp": "2026-07-14T20:08:00Z",
  "type": "withdrawal.completed",
  "data": {
    "withdrawalId": "9b4e2a1c-6d3f-4a8e-bc7d-1f2a3b4c5d6e",
    "status": "COMPLETED"
  }
}
```

## Signature headers

Webhook deliveries include these headers:

| Header        | Description                                               |
| ------------- | --------------------------------------------------------- |
| `X-Signature` | Base64-encoded Ed25519 signature of the raw request body. |
| `X-Key-Id`    | Public key identifier used to verify the signature.       |
| `X-Algorithm` | Signature algorithm. Expected value: `Ed25519`.           |

If any of these headers are missing, reject the webhook and do not process the
payload.

## Retrieve the public key

Use the `X-Key-Id` header value to retrieve the verification key:

```http
GET /webhooks/public-keys/{publicKeyId} HTTP/1.1
Authorization: Bearer <access_token>
X-Request-Id: 9b833995-54fd-4a05-8e74-455c1473875a
```

The API returns a `PublicKey` object.

```json
{
  "publicKeyId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "active": true,
  "algorithm": "ED25519",
  "publicKey": "MCowBQYDK2VwAyEA...",
  "createdAt": "2026-07-14T20:08:00Z"
}
```

Cache public keys by `publicKeyId` and fetch again when you see a new key ID.
This allows Axiym to rotate signing keys without changing your webhook
receiver.

## Verification steps

1. Capture the raw request body bytes before JSON parsing.
2. Read `X-Signature`, `X-Key-Id`, and `X-Algorithm`.
3. Reject the request unless `X-Algorithm` is `Ed25519`.
4. Fetch or load the cached public key for `X-Key-Id`.
5. Verify `X-Signature` against the raw request body using the public key.
6. If verification succeeds, parse the JSON body and process the event.
7. De-duplicate by the event `id` before applying business logic.

Return a non-2xx response, such as `401 Unauthorized`, when signature
verification fails. Axiym treats non-2xx responses as failed deliveries and will
retry according to the webhook retry policy.

## JavaScript example

This example assumes `rawBody` is a `Buffer` containing the exact bytes received
from Axiym and `publicKeyResponse` is the response from
`GET /webhooks/public-keys/{publicKeyId}`.

```js
import { createPublicKey, verify } from "node:crypto";

function getHeader(headers, name) {
  return headers[name] ?? headers[name.toLowerCase()];
}

export function verifyAxiymWebhook(rawBody, headers, publicKeyResponse) {
  const signature = getHeader(headers, "X-Signature");
  const keyId = getHeader(headers, "X-Key-Id");
  const algorithm = getHeader(headers, "X-Algorithm");

  if (!signature || !keyId || algorithm !== "Ed25519") {
    return false;
  }

  if (publicKeyResponse.publicKeyId !== keyId) {
    return false;
  }

  const publicKey = createPublicKey({
    key: Buffer.from(publicKeyResponse.publicKey, "base64"),
    format: "der",
    type: "spki",
  });

  return verify(
    null,
    rawBody,
    publicKey,
    Buffer.from(signature, "base64"),
  );
}
```

## Python example

This example uses `cryptography` and assumes `raw_body` is a `bytes` value
containing the exact request body.

```python
import base64

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.serialization import load_der_public_key


def get_header(headers, name):
    return headers.get(name) or headers.get(name.lower())


def verify_axiym_webhook(raw_body, headers, public_key_response):
    signature = get_header(headers, "X-Signature")
    key_id = get_header(headers, "X-Key-Id")
    algorithm = get_header(headers, "X-Algorithm")

    if not signature or not key_id or algorithm != "Ed25519":
        return False

    if public_key_response["publicKeyId"] != key_id:
        return False

    public_key = load_der_public_key(
        base64.b64decode(public_key_response["publicKey"])
    )

    try:
        public_key.verify(base64.b64decode(signature), raw_body)
        return True
    except InvalidSignature:
        return False
```

## Operational checks

* Retries retain the original signed payload and event `timestamp`. If you
  enforce an event-age limit, agree on the supported delivery and retry window
  with Axiym first; an arbitrary short window can reject legitimate delayed
  deliveries.
* Process each event `id` once. Webhook delivery is at-least-once, so duplicate
  deliveries can occur.
* Return `2xx` only after the event has been safely persisted or queued.
* Fetch the current resource from the API if your system needs the latest
  authoritative state before taking action. For an unconfirmed conversion,
  use the retained create response and fetch the resource only after confirmation.
