> ## 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.

# Signing Order Reports

> How to sign every order-reporting request with an Ed25519 key pair, following the Standard Webhooks specification, and how to register, rotate and revoke your key

<Info>
  For engineering teams at partner companies. This page covers how you prove that an
  order-reporting request comes from you. It applies only to the
  [Order Reporting](/partners/order-reporting/order-reporting-api) routes. Account linking keeps
  its existing OAuth sign-in and does not change.
</Info>

## 1. How signing works

Every request you send to the order-reporting routes is signed with a private key that only you
hold. Bilt stores only the matching public key, so Bilt never holds a secret that could be used to
send requests as you.

Bilt follows the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md)
specification, using its **asymmetric scheme `v1a`**: an Ed25519 (Edwards-curve Digital Signature
Algorithm on Curve25519) signature over the request. If you already verify webhooks with Standard
Webhooks, this is the same scheme used in the other direction.

The routes it covers, on the hosts listed in
[Order Reporting §3](/partners/order-reporting/order-reporting-api#3-environments):

| Route | What it carries |
| - | - |
| `POST /v1/partners/order-reports` | One order report |
| `POST /v1/partners/order-reports/batch` | Several order reports in one request |

***

## 2. Your key pair

An Ed25519 key pair has two halves:

| Key | Format | Who holds it |
| - | - | - |
| Private key | `whsk_` followed by the base64 of the 32-byte Ed25519 private key (the seed) | **Only you.** Keep it in a secret manager on your servers. Never send it to Bilt |
| Public key | `whpk_` followed by the base64 of the 32-byte Ed25519 public key | Bilt. It is not a secret |

You can get a key pair in one of two ways. Pick one:

* **Generate it yourself (recommended).** Run one of the snippets below and send Bilt only the
  `whpk_` value.
* **Ask Bilt to generate it.** Bilt shows you the private key **once**, at creation, and does not
  store it. If you lose it, register a new key.

Node.js (version 18 or later):

```javascript 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, with the [`cryptography`](https://cryptography.io) package:

```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
```

***

## 3. Key lifecycle

Key registration, rotation and revocation are done by Bilt's team on your request. Contact your
Bilt integration manager for each step. Keys are per environment: staging and production each
need their own key pair.

1. **Register.** Send Bilt your `whpk_` public key (or ask Bilt to generate the pair). Bilt
   returns a **key id** of the form `bk_<environment>_<22 letters and digits>`, for example
   `bk_prod_…`. Send it in the `X-Bilt-Key-Id` header of every request signed with that key.
2. **Rotate.** When you want a new key, ask Bilt to rotate: send the new pair's `whpk_` public key
   (or ask Bilt to generate the new pair). Bilt returns a new key id. The old key keeps working for
   **7 days**, so you can switch over without downtime. After 7 days, requests signed with the old
   key are rejected.
3. **Revoke.** If your private key may have been exposed, ask Bilt to revoke it. Revocation takes
   effect immediately: from then on every request signed with that key is rejected. Register a new
   key to keep sending.

Rotating on a schedule is optional. Revoking is required whenever a private key may have leaked.

***

## 4. Signing a request

### 4.1 Headers

Every request carries these headers. All are required.

| Header | Value |
| - | - |
| `webhook-id` | Your own unique id for this request, for example a UUID (universally unique identifier). Use a new one for every new request and **the same one when you retry the same body**. Bilt uses it to recognize retries. A batch is one request with one `webhook-id` |
| `webhook-timestamp` | When you signed the request, as an integer number of seconds since the Unix epoch, for example `1791993600` |
| `webhook-signature` | `v1a,` followed by the base64 of the 64-byte Ed25519 signature |
| `X-Bilt-Key-Id` | The key id Bilt returned when your public key was registered. This header is a Bilt addition: the Standard Webhooks specification has no key id header |
| `Content-Type` | `application/json` |

`webhook-signature` may hold several space-separated entries, for example one per key while you
rotate. Bilt accepts the request when any one `v1a,` entry is valid. Entries for other schemes,
such as `v1,`, are ignored.

### 4.2 What you sign

Sign these bytes with your private key:

```text theme={null}
{webhook-id}.{webhook-timestamp}.{raw request body}
```

The body part is **exactly the bytes you send**. Serialize your JSON once, sign that string, and
send the same string. Re-serializing after signing (for example, letting your HTTP client encode
the object again) changes the bytes and breaks the signature.

### 4.3 Steps

1. Serialize the body to a JSON string.
2. Pick the `webhook-id`: a new one for a new request, or the one you used before if this is a
   retry of the same body.
3. Take the current Unix time in seconds as `webhook-timestamp`.
4. Sign `{webhook-id}.{webhook-timestamp}.{body}` with your Ed25519 private key.
5. Send the body with the four headers above.

On a retry, repeat steps 3 to 5: keep the `webhook-id` and the body, and sign again with the
current time.

### 4.4 Examples

The official Standard Webhooks libraries (`standardwebhooks` for JavaScript and Python) only
support the symmetric `v1` scheme today, not `v1a`. These examples use each language's standard
Ed25519 support instead. Any Standard Webhooks library that supports `v1a` signing produces the
same headers.

Node.js (version 18 or later), no extra packages:

```javascript 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, with the [`cryptography`](https://cryptography.io) and `requests` packages:

```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"],
        },
    )
```

### 4.5 Check your code

Ed25519 signatures are deterministic, so you can test your signing code against these fixed
values. They use a published example key: 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"}` |

Expected header:

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

***

## 5. What Bilt checks

Bilt checks a request in two stages.

**Right away**, before answering:

| Status | Cause |
| - | - |
| `400` | A signing header is missing, longer than 1,024 bytes, or `webhook-timestamp` is not an integer. Nothing was stored |
| `413` | Body over 1 MiB. Nothing was stored |
| `500` | Bilt could not store the request. Retry it (see below) |
| `202 Accepted` | Bilt received the request. It is **not** verified yet; processing is asynchronous |

**Shortly after**, Bilt verifies the request. It is processed only when all of these hold:

* `X-Bilt-Key-Id` names a key that is registered for you, not revoked, and not past its 7-day
  rotation window.
* `webhook-timestamp` is within **300 seconds** (5 minutes) of when Bilt received the request, in
  either direction. Keep your server clock synchronized.
* At least one `v1a,` entry in `webhook-signature` is a valid signature of
  `{webhook-id}.{webhook-timestamp}.{body}` for that key.

A request that fails verification is not processed and is not retried by Bilt. You get no error
response for it, because the `202` was already sent. Bilt records it as a rejected request with one
of these reasons:

| Reason | Cause |
| - | - |
| `unknown_key` | `X-Bilt-Key-Id` is not a live key for your integration: never registered, revoked, past its rotation window, or your integration is not enabled yet |
| `stale_timestamp` | `webhook-timestamp` is more than 300 seconds from when Bilt received the request |
| `bad_signature` | No `v1a,` entry in `webhook-signature` is a valid signature for that key |

**Looking up rejected requests:** available on request from Bilt. Ask your Bilt integration manager,
giving the `webhook-id`. A self-serve API is planned and not available yet.

A rejected request was never processed. Fix the cause and send it again.

**Retries are safe.** A request with a `webhook-id` Bilt already has, and the same body, is
ignored. The same `webhook-id` with a different body is rejected with `IDEMPOTENCY_KEY_REUSED`, and
the first request does not change. Retry after a timeout or a `5xx` with the same `webhook-id` and
body, signed again with the current time (§4.3). The 300-second window also limits how long anyone
who captured a signed request could replay it. This covers network retries of one request. How to
resend an event that Bilt rejected after checking its content is in
[Order Reporting](/partners/order-reporting/order-reporting-api).

***

## 6. Security

* Keep the `whsk_` private key in a secret manager, server-side only. Never put it in a browser,
  a mobile app, a log or a support ticket.
* Never send your private key to Bilt. Bilt only ever needs the `whpk_` public key.
* If a private key may have leaked, ask Bilt to revoke it right away, then register a new one.


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