- Payload — the bytes you sign for a write:
[Header ‖ RequestId ‖ Body]. See Signed payloads. - Canonical message — for a few read endpoints, a short fixed byte string the endpoint documents. You sign that, not your JSON body. See Session signatures.
Start from the error you got
| You got | What it almost always means | Fix |
|---|---|---|
400 request_timestamp_skew | Your request_id timestamp is outside the time window. | Generate the request id fresh |
401 | The signature didn’t verify — wrong encoding, wrong bytes signed, wrong scheme, or wrong key length. | Base64 · Sign the right bytes · Match your scheme |
404 on a read you expected to work | Your credential is scoped to a different subaccount. | Stay within scope |
415 unsupported_content_type | The Content-Type header is missing or wrong. | Set the content type |
200 but it didn’t happen | A 200 means processed, not accepted — read the body. | A 200 can still be a rejection |
The rules
Ordered by frequency.1. Generate the request id fresh, every time
Yourrequest_id (and X-REQUEST-ID on reads) must be a UUIDv7 generated at send time. UUIDv7 embeds a timestamp, and the exchange rejects a request whose timestamp is not current. Generate the id immediately before sending, keep your clock in sync (NTP), and do not reuse or pre-generate ids. A UUIDv4 (or any non-v7) has no usable timestamp and is rejected.
A stale timestamp returns 400 request_timestamp_skew. The same id is also your idempotency key — resend with the same id after a timeout, and a duplicate returns duplicate_request_id instead of executing twice.
2. Encode every field as standard base64
Use standard base64 — the+ / / alphabet with = padding. URL-safe base64 (- / _) does not decode and the request fails with 401. This applies to payload, signature, and public_key in the JSON envelope, and to X-PUBLIC-KEY / X-SIGNATURE on reads.
3. Sign the right bytes
This is the most common401. You sign different things depending on the endpoint:
- Writes (orders, cash, key/session management via the envelope) — sign the payload bytes.
- Reads that use the header triple (list/create/delete API keys, device login) — sign the endpoint’s canonical message, not your JSON body.
4. Match the scheme to your key
The three schemes are not interchangeable. Setsignature_type in the header to your credential and produce exactly what that curve expects:
| Your key | signature_type | What you sign |
|---|---|---|
| Session key (Ed25519) | 0 | the raw payload bytes |
| Master key (Secp256k1) | 1 | the EIP-712 digest of the payload (not the raw bytes) |
| Master key (Passkey) | 2 | a WebAuthn assertion from the authenticator |
401.
5. Stay within your credential’s scope
Scope is fail-closed: a credential pinned to one subaccount can only ever act on that subaccount — it can never reach a wider one. Asking for a subaccount or resource your credential isn’t scoped to returns404 Not Found (not 403). So a surprise 404 on a read you expected to succeed usually means you’re using a credential pinned to a different subaccount.
6. Set the content type
Send aContent-Type the signed-write path accepts:
application/json— for theBase64SignedPayloadenvelope.application/octet-stream— for the binary[payload ‖ public_key ‖ signature]frame.
415 unsupported_content_type.
A 200 can still be a rejection
A200 OK means the request was processed, not accepted — read the body to know which, per the error model. Error responses (the 400 / 401 / 404 / 415 above) use application/problem+json with a stable code — branch on code, not on the human-readable detail.
Field and length reference
Decode lengths must match the curve, or verification fails with401.
| Curve | signature_type | Public key | Signature |
|---|---|---|---|
| Ed25519 | 0 | 32 bytes | 64 bytes |
| Secp256k1 | 1 | 33-byte compressed | 64 bytes (r ‖ s) |
| Passkey (P-256) | 2 | 33-byte compressed | raw r ‖ s, plus the WebAuthn fields |
RequestId is always 16 raw bytes (the UUIDv7) and the Header is 8 bytes. Signed payloads has the full byte layout.