Verifying answers

Using an SDK?

SDK 2.0.1 and later verify signed answers before you see them and discard any that fail. This page is for writing your own client.

Anis signs the answers where an answer moves money, delivers the cards, establishes your key or stops access — and on those calls every answer is signed, success or refusal. On these calls a client requires the signature, and throws away any answer it cannot verify: it does not log it and carry on, it does not act on it. An answer without a signature is one it cannot verify — a failure, never an answer to accept. Never write “verify if a signature is present”: a forger simply removes it. An order answer that cannot be verified is treated like one that never arrived — the outcome is unknown and the order is resumed with the same operation id.

Which answers are signed

Decide by the call you made, never by what the answer carries.

Anis signs every answer — success and every refusal Because
Add or remove an allowed address — Staging only — testing endpoints, not available in production the answer confirms an access-network change
Suspend your application and revoke a key the answer confirms access stopped
Place an order money moves
Read an order the answer is the order’s outcome
Create or read a meter charge the answer reports charging and its tokens or refund
Create, read or correct a direct recharge the answer reports payment or delivery, or accepts a recipient correction with signed 202, Location and the three-member processing acknowledgement, without an direct recharge or order body
Set the webhook endpoint, test or redeliver the answer confirms a delivery-management change
Reveal a card and reveal an invoice the answer delivers the card codes
Read an invitation, submit a key, prove you hold it and read the enrolment status the answer establishes your key
Check your signature the call exists to test signatures

The answers of every other call are not signed, success or refusal: read your profile, list wallets, read a wallet, the five catalogue calls (categories, subcategories, a subcategory, its cards, one card), list owned cards and read an owned card. They carry no Signature and no Signature-Input, and still carry Content-Digest and X-Request-Id. There is nothing to verify on them — TLS protects them — so do not require a signature there. The public key document is not signed either.

Meter packages and charge lists, direct recharge products, locations, quotes and lists, webhook endpoint reads and webhook event lists have unsigned answers. Their requests are still signed. Incoming webhooks use their own verification profile, described in Verifying webhooks.

Your own requests are unchanged: every call is still signed by you, as in Signing requests.

The rest of this page is about the signed answers.

What Anis signs

Answers are signed with ECDSA P-256 and SHA-256, label sig1, with parameters created, keyid, alg and tag="anis-response-v2" — and no expires. The signed parts, in this order:

# Part Present when
1 "@status" always
2 "content-digest" always
3 "x-request-id" always
4 "signature-input";req you signed the request (absent on enrolment answers) — its value is your own Signature-Input
5 "signature";req you signed the request — its raw Signature field exactly as received (sig1=:…:); absent on enrolment and public answers
6 "location" the answer carries Location
7 "retry-after" the answer carries Retry-After
8 "idempotency-replayed" the answer carries Idempotency-Replayed: true
9 "cache-control" the answer carries Cache-Control: no-store

This illustrative base uses placeholders for the key id and request signature. The base is built like a request’s: one line per part, then "@signature-params" with no newline after it.

"@status": 200
"content-digest": sha-256=:epGQsVay09pY/GXv6MpAjMbsZ7VvobF1nAdylVo/mMk=:
"x-request-id": 01J9R2K8T4V6XQ0M3B7C5D9E1F
"signature-input";req: sig1=("@method" "@authority" "@path" "@query" "x-anis-date");created=1789804800;expires=1789804860;keyid="…";alg="ecdsa-p256-sha256"
"signature";req: sig1=:<request signature bytes in base64>:
"@signature-params": ("@status" "content-digest" "x-request-id" "signature-input";req "signature";req);created=1789804800;keyid="partner-signing/v1-active";alg="ecdsa-p256-sha256";tag="anis-response-v2"

;req sits outside the quotes, identically in its line and in @signature-params.

The checks, in order

  1. Both Signature and Signature-Input are present — on a signed call, always; the label is sig1; alg is ecdsa-p256-sha256.
  2. The body matches its Content-Digest — checked before the signature. The signature covers the digest header, not the body, so a client that checks the signature first accepts a swapped body whenever the digest was rewritten too.
  3. Rebuild the list of signed parts from what the answer carries (the table above) and require the answer’s Signature-Input to name exactly that list. Never trust the list it advertises: an answer validly signed over a shorter list leaves its body unprotected.
  4. created is within 60 seconds of your clock — an answer has no expires, so without this bound a captured answer could be replayed to you forever.
  5. The keyid names a key in Anis’s published keys (below). Reject a kid in revoked[] as key_compromised — whatever the dates, and without fetching the document again. Reject a retired key when created > retiredAt as key_retired, and any key — active, next or retired — when created < notBefore as key_not_yet_valid.
  6. The signature is 64 bytes and verifies over the rebuilt base.

Anything else: throw the answer away. The answer test vectors cover every one of these checks.

A missing or different tag="anis-response-v2" is rejected as profile_unexpected; a different covered list or order is rejected as covered_components_mismatch.

Anis’s public keys

GET /.well-known/partner-signing-keys.json — the one route that needs no signature, and whose own answer is not signed — publishes every key that may sign an answer, including the next one before a rotation and the previous one after it. Each has a kid, which is what an answer’s keyid names. Treat kid as an opaque name.

Each entry in keys[] has status (active, next or retired) and notBefore. A retired key also has retiredAt: when it stopped signing, the earlier of its expiry and its successor’s notBefore. It is listed while the current time is before retiredAt plus 90 days — as long as an answer it signed can still be disputed.

A compromised key never appears in keys[] and never signs. It appears in revoked[] with kid and compromisedAt for 90 days after that time. Anis announces a compromise within 24 hours by email and in the document. See the draft Evidence and disputes.

Each key carries purpose: answer or webhook. It is optional in the schema for compatibility, but always emitted by Anis. Use answer keys for API answers and webhook keys for incoming notifications. A webhook uses a separate signing key and profile; do not accept a key solely because it appears in the list.

  • Keep the document for at most 5 minutes. Fetch it again after that, so a key Anis withdraws stops being trusted within minutes. A cache shared between processes counts the 5 minutes from when the document was first fetched, not from when it was copied into the cache. With Anis’s own refresh, a client that follows these rules stops trusting a withdrawn key within about 7 minutes.
  • An unknown kid: fetch the document again — at most once every 30 seconds for each Anis address your client uses (a failed fetch counts). Concurrent checks share one fetch. Inside those 30 seconds an answer naming an unknown key is rejected as unknown_key without fetching. A kid listed in revoked[] is never a reason to fetch.
  • Refuse the whole document if any key carries a private member (d) — not just that key. A document that leaks a private key is not to be trusted at all.
  • Each coordinate is exactly 32 bytes. Refuse a key of any other shape.

This route answers HTTP 503 when the key list cannot be read or Anis has not refreshed it for 9 minutes: dependency_unavailable. Keep your cached copy until its original expiry — at most 5 minutes after it was fetched — and retry with backoff. A failed fetch does not extend that expiry; once it expires, you cannot verify signed answers until you fetch a fresh document.

The one unsigned answer on a signed call

If Anis’s own signing is unavailable, a signed call is answered 503 without a signature. It is an answer on a signed call that you cannot verify, so treat it like any other: retry a read, resume an order with the same operation id. (The calls whose answers are never signed do not depend on Anis’s signing, and are not affected.)

If Anis has no key it may sign with, every signed call answers 503 without a signature. If Anis has not been able to read its key list for 9 minutes, the key document answers 503 too. The calls whose answers are not signed keep working throughout.

Get allowed addresses: Staging only — testing endpoints, not available in production. Its answer is unsigned; add/remove answers, including refusals, require signature verification. These three routes are permanently excluded from SDKs.

If the webhook key is unavailable, the key document keeps serving the answer keys and omits only the webhook key. API answer verification continues; reject webhook deliveries until an eligible webhook key can be fetched.