Authentication
Most endpoints are scoped to a merchant owner address and authenticated by a
short-lived session key that the merchant registered ahead of time. The
session key is just an EVM private key the client controls — its public half
is bound to the owner via POST /auth/register-pubkey.
Every authenticated request carries two headers:
| Header | Value |
|---|---|
x-session-nonce | Strictly-increasing integer (millisecond timestamp works) |
x-session-signature | EIP-191 signature over sess:{nonce}:{sha256(body)} |
The server recovers the EVM address from the signature, looks up the matching
session key in its registry, and uses the bound owner as the request's
authenticated identity. There's no X-Owner-Address header on the wire — the
owner is implied by the signing key.
{sha256(body)} is the lowercase hex digest of the request body bytes; for
empty-body calls (e.g. POST /poll/events with no payload) hash the empty
string.
Optional headers
| Header | Default | Effect |
|---|---|---|
x-encryption: none | encrypt-when-registered | Opt out of encrypted responses. Useful for backend integrations that don't carry the owner's encryption key. |
x-session-pubkey | inferred from signature | Disambiguates between multiple registered keys for the same owner. Only needed when more than one key is active. |
Signing snippets
- JavaScript
- Python
- Bash
import { Wallet, sha256, toUtf8Bytes } from 'ethers';
const SESSION_PRIVATE_KEY = '0x...';
const body = JSON.stringify({ /* … */ });
const nonce = Date.now();
const hash = sha256(toUtf8Bytes(body)).slice(2);
const sig = await new Wallet(SESSION_PRIVATE_KEY)
.signMessage(`sess:${nonce}:${hash}`);
await fetch('https://api.feemaker.io/invoice', {
method: 'POST',
headers: {
'x-session-nonce': String(nonce),
'x-session-signature': sig,
'x-encryption': 'none',
},
body,
});
from eth_account import Account
from eth_account.messages import encode_defunct
from hashlib import sha256
import json, time, requests
SESSION_PRIVATE_KEY = '0x...'
body = json.dumps({ })
nonce = int(time.time() * 1000)
body_hash = sha256(body.encode()).hexdigest()
sig = Account.sign_message(
encode_defunct(text=f'sess:{nonce}:{body_hash}'),
private_key=SESSION_PRIVATE_KEY,
).signature.hex()
requests.post('https://api.feemaker.io/invoice',
data=body,
headers={
'x-session-nonce': str(nonce),
'x-session-signature': sig,
'x-encryption': 'none',
})
# Requires foundry (cast) + openssl on PATH.
SESSION_PRIVATE_KEY=0x...
BODY='{}'
NONCE=$(($(date +%s%N) / 1000000))
HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $2}')
SIG=$(cast wallet sign --private-key $SESSION_PRIVATE_KEY "sess:$NONCE:$HASH")
curl -X POST https://api.feemaker.io/invoice \
-H "x-session-nonce: $NONCE" \
-H "x-session-signature: $SIG" \
-H "x-encryption: none" \
-d "$BODY"
Replay protection
Each registered session key has a per-key nonce counter. The server only
accepts a request when its x-session-nonce is strictly greater than the
last value it saw for that key. Use a millisecond timestamp or a monotonic
counter — never reuse a nonce.
Soft auth vs strict auth
When the server is in strict-auth mode every owner-scoped endpoint requires session headers. In soft-auth mode they're optional, but if you send them they must validate — an expired or unregistered key is rejected, never silently treated as "unauthenticated".
Public, payer-side reads (e.g. fetching an invoice by guid for a checkout page) don't require headers in either mode.