Signed receipts
Sign a request with your own key and get back a receipt, signed by Engy, that binds your request to the exact response and the amount billed.
A signed receipt is a record, signed by both you and Engy, of one API call. It states who made the request, the exact bytes that were sent, the exact bytes that Engy returned, the amount billed, and whether the request was served only by zero data retention (ZDR) workers.
Receipts are useful when a third party needs to check what happened on a call: for example, an agent that runs in a confidential computing environment and must show which model calls it made, or an auditor reconciling usage against bills.
- You can't deny making the request. Engy verifies your signature against the bytes it received before it serves the request.
- Engy can't deny the response or the charge. Engy's signature covers your signature, the exact response bytes, and the billed amount. Changing any of them invalidates the receipt.
Receipts are optional. Requests without the signing headers behave as before.
How receipts work
- You generate an Ed25519 key pair and keep the private key.
- For each request, you sign a message that covers the endpoint, your API key, and the exact request body, and you send the signature in four headers.
- Engy checks the signature before it serves the request. If the signature doesn't verify, Engy rejects the request without serving or billing it.
- When the request succeeds, Engy appends a receipt to the response, signed with Engy's receipt key.
- You (or anyone you share the receipt with) verify it with Engy's public key.
Receipts are available on /v1/chat/completions, /v1/completions, /v1/messages, and /v1/responses, for both streaming and non-streaming requests.
Engy's public key
Get Engy's current receipt key from the API:
curl https://api.engy.ai/v1/receipts/key{
"alg": "ed25519",
"key": "d99b8d426fb5aa9bca19e70ef44ceec4d918aef982fa067ddeb93d71c0c3cffd",
"receipt": "engy-receipt v2",
"caller_headers": ["X-Engy-Caller-Key", "X-Engy-Caller-Time", "X-Engy-Caller-Nonce", "X-Engy-Caller-Sig"],
"caller_message": "engy-caller-v2|{key}|{time}|{nonce}|{path}|{sha256 hex of the API key}|{sha256 hex of the request body}"
}Every response to a signed request also carries the key in the X-Engy-Receipt-Key header.
Published keys:
| Key | In use since |
|---|---|
d99b8d426fb5aa9bca19e70ef44ceec4d918aef982fa067ddeb93d71c0c3cffd | 2026-11-07 |
If Engy rotates the key, the new key is added to this table. Receipts signed with an earlier key remain verifiable with that key.
Sign a request
Add these headers to the request:
| Header | Value |
|---|---|
X-Engy-Caller-Key | Your Ed25519 public key, as 64 hex characters |
X-Engy-Caller-Time | The current time, in Unix milliseconds |
X-Engy-Caller-Nonce | A fresh random value for every request, as 32 to 64 lowercase hex characters |
X-Engy-Caller-Sig | Your Ed25519 signature, in hex, of the message below |
The signed message is the UTF-8 string:
engy-caller-v2|{key}|{time}|{nonce}|{path}|{auth_sha256}|{body_sha256}| Part | Value |
|---|---|
key, time, nonce | The same values as in the headers |
path | The request path, for example /v1/chat/completions |
auth_sha256 | The SHA-256 hex digest of your Engy API key (the value after Bearer, or the x-api-key value) |
body_sha256 | The SHA-256 hex digest of the exact request body bytes |
A signature is valid for one request, on one endpoint, with one API key. Sign the bytes you actually send: serialize the body once, hash those bytes, and send the same bytes.
Where the receipt appears
The receipt is a string of the form <token>.<signature>. Its location depends on the response type:
| Response | Receipt location | The receipt's resp_sha256 covers |
|---|---|---|
| Non-streaming (JSON) | An extra top-level field, "x_engy_receipt", added as the last member of the response object | The response body without that field |
Streaming (text/event-stream) | A final SSE comment line, : engy-receipt <token>.<signature>, after all other events | Every byte before that comment line |
SSE clients ignore comment lines, so a streaming client that doesn't check receipts is unaffected.
A receipt is attached only when the request completed successfully with status 200. If a stream breaks or ends with an error event, there is no receipt.
Receipt format
token is the receipt JSON encoded as base64url without padding. signature is Engy's Ed25519 signature, in hex, over the decoded token bytes. Verify the signature over the bytes first, then parse them as JSON.
{
"v": 2,
"kind": "engy-receipt",
"engy_key": "d99b8d42...",
"time": 1794100000000,
"path": "/v1/chat/completions",
"status": 200,
"caller": {
"key": "...",
"time": "1794099999000",
"nonce": "...",
"auth_sha256": "...",
"sig": "..."
},
"req_sha256": "...",
"resp_sha256": "...",
"request_id": "...",
"model": "kimi-k3",
"usage": {"prompt_tokens": 812, "completion_tokens": 64},
"charged_micro": 1234,
"key_id": "...",
"served": {"zdr": true}
}| Field | Description |
|---|---|
engy_key | The Engy public key that signed the receipt. |
time | When Engy signed the receipt, in Unix milliseconds. |
path, status | The endpoint and the HTTP status of the response. |
caller | Your public key, time, nonce, API key digest, and signature, exactly as you sent them. |
req_sha256 | The SHA-256 hex digest of the request body that Engy received. |
resp_sha256 | The SHA-256 hex digest of the response bytes, excluding the receipt itself. |
request_id | The Engy request ID, the same one shown in your usage logs. |
model, usage | The model and the token usage that Engy recorded. |
charged_micro | The amount billed for the request, in millionths of a US dollar. |
key_id | The ID of the Engy API key that was billed. |
served.zdr | true only if every worker that received the prompt, including any worker attempted before a retry, is covered by zero data retention. |
Receipts don't identify the worker or provider that served the request.
Example
The following script signs a request, sends it, and verifies the receipt. It requires httpx and cryptography.
import base64
import hashlib
import json
import os
import re
import time
import httpx
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
BASE_URL = "https://api.engy.ai"
API_KEY = os.environ["ENGY_API_KEY"]
PATH = "/v1/chat/completions"
# In practice, load a long-lived key. A fresh key works for a demo.
private_key = Ed25519PrivateKey.generate()
my_key = private_key.public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw
).hex()
def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
# 1. Serialize the body once, and sign exactly these bytes.
body = json.dumps({
"model": "kimi-k3",
"messages": [{"role": "user", "content": "Hello"}],
}).encode()
t = str(int(time.time() * 1000))
nonce = os.urandom(16).hex()
message = "|".join([
"engy-caller-v2", my_key, t, nonce, PATH,
sha256_hex(API_KEY.encode()), sha256_hex(body),
]).encode()
response = httpx.post(
BASE_URL + PATH,
content=body,
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"X-Engy-Caller-Key": my_key,
"X-Engy-Caller-Time": t,
"X-Engy-Caller-Nonce": nonce,
"X-Engy-Caller-Sig": private_key.sign(message).hex(),
},
timeout=600,
)
response.raise_for_status()
raw = response.content
# 2. Separate the receipt from the bytes it covers.
def split_receipt(raw: bytes):
marker = b": engy-receipt "
i = raw.rfind(marker)
if i >= 0 and (i == 0 or raw[i - 1:i] == b"\n") and raw.endswith(b"\n\n"):
return raw[:i], raw[i + len(marker):].strip().decode()
m = re.search(
rb',"x_engy_receipt":"([A-Za-z0-9_-]+\.[0-9a-f]{128})"\}(\s*)$', raw
)
if m:
return raw[:m.start()] + b"}" + m.group(2), m.group(1).decode()
return raw, None
covered, receipt = split_receipt(raw)
assert receipt, "no receipt on this response"
# 3. Verify Engy's signature, then check that the receipt matches this call.
engy_key = httpx.get(BASE_URL + "/v1/receipts/key").json()["key"]
token, _, signature = receipt.partition(".")
receipt_bytes = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))
Ed25519PublicKey.from_public_bytes(bytes.fromhex(engy_key)).verify(
bytes.fromhex(signature), receipt_bytes
) # raises InvalidSignature if the receipt was altered
r = json.loads(receipt_bytes)
assert r["engy_key"] == engy_key
assert r["caller"]["key"] == my_key and r["caller"]["nonce"] == nonce
assert r["req_sha256"] == sha256_hex(body)
assert r["resp_sha256"] == sha256_hex(covered)
print("receipt verified:", r["request_id"], r["model"], r["usage"],
"charged_micro =", r["charged_micro"], "zdr =", r["served"]["zdr"])For a pinned verifier, compare engy_key with a key from the published keys table instead of fetching it at verification time.
For streaming requests, read the full stream as bytes and pass it to split_receipt unchanged. Don't reassemble the stream from parsed events, because resp_sha256 covers the exact bytes on the wire.
Errors
Engy checks a signed request before serving it. A rejected request is not served and not billed.
| Status | Cause |
|---|---|
400 | A header is malformed or missing, for example a nonce that isn't 32 to 64 lowercase hex characters. |
400 | X-Engy-Caller-Time is more than 5 minutes from Engy's clock. |
400 | The signature doesn't verify against this endpoint, this API key, and the body that Engy received. |
400 | The nonce was already used with this key. |
400 | Authorization, x-api-key, or an X-Engy-Caller-* header was sent more than once. |
413 | The signed request body is larger than 64 MiB. |
503 | Engy couldn't check the nonce against earlier requests. Retry the request. |
Errors use the usual error format for the endpoint, including the Anthropic format on /v1/messages. See Errors and limits.
To retry a signed request, sign it again with a new nonce and the current time. Sending the same signed request twice is rejected as a replay.
Data retention
Engy doesn't store receipts or request bodies for them. To reject replays, Engy keeps only your public key and the nonce of each signed request, for about 11 minutes. Receipts don't change zero data retention.
Limitations
- Engy's gateway signs the receipt. The receipt proves what Engy received, returned, and billed. To check which model produced the output, also request a proof.
- A signed request body is buffered in full before it is served, so receipts aren't available for request bodies larger than 64 MiB.
- There is no receipt for a stream that breaks or ends with an error.