> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bilt.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Sign your requests

> Sign every order report with your Ed25519 key, following the Standard Webhooks v1a scheme

Bilt accepts an order report only when you sign it with a private key that only you hold.

## Before you begin

* Get a key pair for each environment. Staging and production each need their own.
* Generate the pair yourself and send Bilt only the `whpk_` public key. Or ask Bilt to generate it: Bilt shows the private key once and does not store it.
* Send the public key to your Bilt integration manager. Bilt returns a key ID of the form `bk_<environment>_<22 letters and digits>`.
* Keep the `whsk_` private key in a secret manager on your servers. Never send it to Bilt.

The private key is `whsk_` plus the base64 of the 32-byte Ed25519 seed. The public key is `whpk_` plus the base64 of the 32-byte Ed25519 public key. These snippets generate a pair:

```javascript Node.js theme={null}
import { generateKeyPairSync } from 'node:crypto';

const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const b64 = (base64url) => Buffer.from(base64url, 'base64url').toString('base64');

console.log('whpk_' + b64(publicKey.export({ format: 'jwk' }).x)); // send this to Bilt
console.log('whsk_' + b64(privateKey.export({ format: 'jwk' }).d)); // keep this secret
```

```python Python theme={null}
import base64
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

private_key = Ed25519PrivateKey.generate()
raw = serialization.Encoding.Raw
public_bytes = private_key.public_key().public_bytes(raw, serialization.PublicFormat.Raw)
private_bytes = private_key.private_bytes(
    raw, serialization.PrivateFormat.Raw, serialization.NoEncryption()
)

print("whpk_" + base64.b64encode(public_bytes).decode())   # send this to Bilt
print("whsk_" + base64.b64encode(private_bytes).decode())  # keep this secret
```

## How it works

Bilt follows the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) specification with its asymmetric `v1a` scheme. You sign `{webhook-id}.{webhook-timestamp}.{raw request body}` with Ed25519 and send the signature in headers. Bilt answers `202` first, then checks the signature with your public key shortly after.

## Steps

**1. Serialize the body once**

Serialize the event to a JSON string. Sign that exact string and send the same bytes. Encoding the object again after signing breaks the signature.

**2. Pick the webhook-id**

Use a new ID, such as a UUID, for each new request. Reuse the same ID when you retry the same body. A batch is one request with one `webhook-id`.

**3. Take the timestamp**

Use the current Unix time in integer seconds as `webhook-timestamp`. Keep your server clock in sync.

**4. Sign**

Sign `{webhook-id}.{webhook-timestamp}.{body}` with your private key. The official `standardwebhooks` libraries only support the symmetric `v1` scheme today, so these examples use each language's own Ed25519 support:

```javascript Node.js theme={null}
import { createPrivateKey, randomUUID, sign } from 'node:crypto';

// whsk_ key -> Node key object (the hex prefix wraps the raw 32-byte key in PKCS #8).
const seed = Buffer.from(process.env.BILT_SIGNING_KEY.slice('whsk_'.length), 'base64');
const privateKey = createPrivateKey({
  key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), seed]),
  format: 'der',
  type: 'pkcs8',
});

export async function sendOrderReport(report, webhookId = randomUUID()) {
  const body = JSON.stringify(report);
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const signature = sign(null, Buffer.from(`${webhookId}.${timestamp}.${body}`), privateKey);

  return fetch('https://webhooks.staging.bilt.dev/v1/partners/order-reports', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'webhook-id': webhookId,
      'webhook-timestamp': timestamp,
      'webhook-signature': `v1a,${signature.toString('base64')}`,
      'X-Bilt-Key-Id': process.env.BILT_KEY_ID,
    },
    body,
  });
}
```

```python Python theme={null}
import base64
import json
import os
import time
import uuid

import requests
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

private_key = Ed25519PrivateKey.from_private_bytes(
    base64.b64decode(os.environ["BILT_SIGNING_KEY"].removeprefix("whsk_"))
)


def send_order_report(report, webhook_id=None):
    webhook_id = webhook_id or str(uuid.uuid4())
    body = json.dumps(report).encode()
    timestamp = str(int(time.time()))
    signature = private_key.sign(f"{webhook_id}.{timestamp}.".encode() + body)

    return requests.post(
        "https://webhooks.staging.bilt.dev/v1/partners/order-reports",
        data=body,  # send the exact bytes that were signed
        headers={
            "Content-Type": "application/json",
            "webhook-id": webhook_id,
            "webhook-timestamp": timestamp,
            "webhook-signature": "v1a," + base64.b64encode(signature).decode(),
            "X-Bilt-Key-Id": os.environ["BILT_KEY_ID"],
        },
    )
```

**5. Send the headers**

Send `webhook-id`, `webhook-timestamp`, `webhook-signature` (`v1a,` plus the base64 64-byte signature), `X-Bilt-Key-Id`, and `Content-Type: application/json`. On a retry, keep the ID and body and sign again with the current time.

**6. Check your code**

Ed25519 signatures are deterministic. Sign these inputs with the published example key, and never use it for real requests:

| Input | Value |
| - | - |
| Private key | `whsk_xB2UfErwSU1RM5qWAtKYdh9S8ElMtzoe9KWOWG+PGOE=` |
| Public key | `whpk_2QWLpHjbK5VMR/SKVM9yrn5QmaDW94iSJG8ApbcUoe0=` |
| `webhook-id` | `6f1c2a9e-3b4d-4e8f-9a7c-1d2e3f4a5b6c` |
| `webhook-timestamp` | `1791993600` |
| Body | `{"orderId":"ORD-20261007-000481"}` |

You should get this header:

```http theme={null}
webhook-signature: v1a,gHXWgWXdsbda6909UQC7Rr3TaFqwK/6HY+E76FQ1El+fUHkZUp1qqOGUZD1vTAfIt6kkVQ23EaVEcVzI72xNDg==
```

**7. Rotate or revoke your key**

To rotate, send Bilt a new public key and switch to the new key ID. The old key keeps working for 7 days, then Bilt rejects it. During rotation you can send one `v1a,` entry per key, space-separated. Any one valid entry passes, and other schemes such as `v1,` are ignored.

If a private key may have leaked, ask Bilt to revoke it. Revocation is immediate. Register a new key to keep sending. Scheduled rotation is optional.

## What can go wrong

A request that fails the signature check gets no error response, because Bilt already sent the `202`. Bilt does not process it or retry it. Ask your integration manager for the reason, giving the `webhook-id`. A self-serve lookup is planned.

* `unknown_key`: `X-Bilt-Key-Id` is not a live key for you. It was never registered, was revoked, is past its rotation window, or your integration is not enabled yet. See [Errors and retries](/partners/errors-and-retries.md#signature-failures).
* `stale_timestamp`: `webhook-timestamp` is more than 300 seconds from when Bilt received the request, in either direction. See [Errors and retries](/partners/errors-and-retries.md#signature-failures).
* `bad_signature`: no `v1a,` entry is a valid signature for that key. The usual cause is signing different bytes than you sent. See [Errors and retries](/partners/errors-and-retries.md#signature-failures).
* `400` right away: a signing header is missing, longer than 1,024 bytes, or the timestamp is not an integer. See [Report an order event](/api-reference/order-reports/report-an-order-event.md).

Fix the cause and send the request again. It was never processed.

## Next steps

* [Send order events](/partners/order-reporting/send-order-events.md) covers what goes in the body.
* [Handle rejections and resends](/partners/order-reporting/handle-rejections-and-resends.md) covers retries.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.