Signing requests
Using an SDK?
The SDKs do all of this for you. This page is for writing your own client, or for understanding a refusal.
Every call except enrolment and Anis’s public keys is signed with HTTP Message Signatures (RFC 9421), using
ECDSA P-256 with SHA-256. You sign a precise text — the signature base — built from parts of the request.
Anis rebuilds the same text from what it received and checks your signature against it. One different byte and the
call is refused as invalid_credentials, with no hint which byte, on purpose.
The three kinds of signed call
Which parts you sign is decided by the route — see each route’s page in the API reference.
| Kind | Used by | Signed parts, in this exact order |
|---|---|---|
| Read | every GET |
@method @authority @path @query x-anis-date |
| Change | the two reveals and the signature self-check | @method @authority @path @query content-digest nonce x-anis-date |
| Order | placing an order | @method @authority @path @query content-digest nonce idempotency-key x-anis-date |
idempotency-key comes before x-anis-date, not after it.
The headers you send
| Header | On | Value |
|---|---|---|
X-Anis-Date |
every signed call | The time, ISO-8601 UTC: 2026-09-19T08:00:00Z |
Content-Digest |
changes and orders | sha-256=:<base64 of SHA-256 of the exact body bytes>: |
Nonce |
changes and orders | A fresh random value for every request (the SDKs use 128 random bits, base64url) |
Idempotency-Key |
orders | Your operation id: a UUID, lower-case with hyphens |
Signature-Input |
every signed call | The parameters below |
Signature |
every signed call | sig1=:<base64 of the 64-byte signature>: |
The parameters
sig1=("@method" "@authority" "@path" "@query" "x-anis-date");created=1789804800;expires=1789804860;keyid="3f2a9c14-8d6e-4b21-9f07-5c8ab2d61e43";alg="ecdsa-p256-sha256"
- The label is always
sig1, on bothSignature-InputandSignature. createdandexpiresare Unix seconds. Anis allows at most 300 seconds between them; use 60 or less (the SDKs do) — see Clock.keyidis your key id from enrolment: a UUID, lower-case with hyphens.algis alwaysecdsa-p256-sha256. There is nothing to negotiate.- On changes and orders, add
;nonce="<the Nonce header>"at the end, and it must equal theNonceheader. On a read it must be absent. A mismatch is refused asinvalid_credentialsbefore the signature is even checked.
Every signing failure — a wrong base, a stale time, a digest that does not match the body — is answered
invalid_credentials, on purpose.
The signature base
One line per signed part, "name": value, each ending with a newline — then a last line for
"@signature-params", with no newline after it. An order:
"@method": POST
"@authority": partners.anis.ly
"@path": /v1/wallets/2f1c8a94-6d37-4e52-b8a1-0c9e5d3f7b26/orders
"@query": ?
"content-digest": sha-256=:3AuIAnFFepkxwk6edx9OofFKHz5L8cSdZNVmztycemo=:
"nonce": b2F1dGgtbm9uY2UtMDAx
"idempotency-key": 9b2e4f17-3c6a-4d58-b0e1-7a5c8d2f6b34
"x-anis-date": 2026-09-19T08:00:00Z
"@signature-params": ("@method" "@authority" "@path" "@query" "content-digest" "nonce" "idempotency-key" "x-anis-date");created=1789804800;expires=1789804860;keyid="3f2a9c14-8d6e-4b21-9f07-5c8ab2d61e43";alg="ecdsa-p256-sha256";nonce="b2F1dGgtbm9uY2UtMDAx"
| Part | Value |
|---|---|
@method |
The method, upper-case |
@authority |
The host — plus the port if it is not the scheme’s default — lower-case |
@path |
The path, exactly as sent. Every path is fixed words and UUIDs, so it never needs percent-encoding |
@query |
? followed by the query string exactly as sent. No query signs ? |
content-digest, nonce, idempotency-key, x-anis-date |
The header’s value, exactly as sent |
Sign the base’s UTF-8 bytes with ECDSA P-256 and SHA-256. Send the signature in its 64-byte form — r then
s, 32 bytes each — never DER.
The traps
- No query signs
?, not an empty string. - The query is signed exactly as sent — not re-ordered, re-encoded or rebuilt from parsed values. A paging cursor goes back exactly as you received it.
- The host is lower-case. A mixed-case host signs text Anis never rebuilds.
- Each change digests exactly what it sends. A reveal sends no body at all — not even
{}— so its digest is that of zero bytes:sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:. The signature self-check sends exactly{}:sha-256=:RBNvo1WzZ4oRRq0W9+hknpT7T8If536DEMBg9hyq/4o=:. An order digests its JSON body — compute the digest after the body is final, and send exactly those bytes. - The signature is 64 bytes, never DER. .NET and WebCrypto produce the 64-byte form. PHP’s
openssl_signand Python’scryptographyproduce DER and need converting; a 70-to-72-byte signature is DER.
Clock
Anis accepts a signature whose created is at most about 30 seconds ahead of its clock or 5 minutes behind it, and
whose expires has not passed. Anis’s answers carry their own time, and a client accepts an answer only within 60
seconds of its own clock (see Verifying answers).
That is why signatures should live 60 seconds or less: with a longer window, a server clock a few minutes slow could get an order accepted and completed, and then throw away the answer that carries the card codes as too old. Keep your server clock synchronised.
When a signature will not verify
Call Check your signature. It reports the method, address, path and query Anis signed over and the key it found. Compare them with your base, line by line.
Before your client calls Anis at all, run it against the signing test vectors: they give the exact base for every kind of signed call.