> For the complete documentation index, see [llms.txt](https://docs.goassay.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.goassay.ai/developers/list-your-api-advanced/write-your-own-verifier.md).

# Write your own verifier

Implement Assay's Ed25519 request verification in another language and check it against the shared fixtures.

Use this page when your language has no Assay verifier. Implement the request signature profile below, then compare your implementation with the shared test vectors. For Node, Python or Go, use the [existing libraries](/developers/list-your-api-get-started/verify-in-your-code.md).

## How it works

Assay uses Ed25519 over an HTTP Message Signatures base in the style of RFC 9421. `Signature-Input` describes the covered components and parameters. `Signature` carries the signature bytes. Both are structured dictionaries; select their member named `assay`.

Get public keys from the configured trusted JWKS at `https://api.goassay.ai/.well-known/assay-request-keys.json`. Never let a request choose the key-fetch URL. Read the original request target and raw body before a framework decodes paths or parses JSON.

## Implement the checks in order

1. Parse both headers as structured dictionaries. Missing headers or missing `assay` members fail with `MISSING`; parsing failures or a signature member that is not a byte sequence fail with `MALFORMED`.
2. Require an inner list of components in `Signature-Input`. Check the parameters below. A missing or different tag is `WRONG_TAG`; invalid required parameters are `MALFORMED`.
3. Require the exact component list and order below. A nonempty body without covered `content-digest` is `MALFORMED`. If covered, require `Content-Digest`, parse its `sha-256` byte sequence and compare it with SHA-256 of the raw body. Missing digest is `MISSING`, invalid structured syntax is `MALFORMED`, and a missing or different SHA-256 value is `DIGEST_MISMATCH`.
4. Check the time window in Unix seconds. Require `expires >= created`, `expires - created <= 300`, and `created - 60 <= now <= expires + 60`. Fail with `EXPIRED` otherwise. These bounds are inclusive.
5. Build the signature base exactly as described below. Require a nonempty trimmed call ID and authority, and a valid target; otherwise fail with `MALFORMED`.
6. Look up `keyid` in the trusted key set and verify Ed25519 over the UTF-8 base bytes. A missing usable key is `UNKNOWN_KEY`; a failed cryptographic check is `BAD_SIGNATURE`.
7. Atomically check and store the nonce only after cryptographic verification. Keep it for `max(1, expires + 60 - now)` seconds. A live existing entry is `REPLAYED`. A store error must reject the request without running the application.

| Parameter            | Required value or type                     |
| -------------------- | ------------------------------------------ |
| `created`, `expires` | Integers, in Unix seconds                  |
| `nonce`              | Nonempty string                            |
| `keyid`              | Nonempty string identifying the public key |
| `alg`                | `ed25519`                                  |
| `tag`                | `assay-request`                            |

Assay generates the nonce from 16 random bytes encoded as base64url. Verification requires a nonempty string; do not invent a different nonce-length requirement.

## Build the canonical form

Without a covered digest, the component list is exactly:

```
("@method" "@authority" "@path" "@query" "x-assay-call-id")
```

With a digest, insert `"content-digest"` immediately before `"x-assay-call-id"`. No other component order is accepted. An empty body may have a covered digest; hash the empty byte string in that case.

| Component         | Value in the base                                                                                                                                                                            |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@method`         | Uppercase HTTP method                                                                                                                                                                        |
| `@authority`      | Configured public authority if set; otherwise trimmed `Host`, lowercased. Keep the port as received. If `Host` is absent and the input is an absolute URL, the library uses that URL's host. |
| `@path`           | Raw path, or `/` if empty                                                                                                                                                                    |
| `@query`          | `?` followed by the raw query; just `?` when absent                                                                                                                                          |
| `content-digest`  | Trimmed complete `Content-Digest` header value, when covered                                                                                                                                 |
| `x-assay-call-id` | Trimmed `X-Assay-Call-Id` header value                                                                                                                                                       |

For an origin-form target such as `/v1/search?q=a%20b`, split at the first `?`. Preserve query order, percent escapes and path segments. Do not decode, sort, resolve or re-encode them. Reject targets starting with `//` or lacking the leading `/`. The library also accepts an absolute URL through a URL parser; prefer your server's raw origin-form target to avoid normalization before verification.

Write each line as `"component-name": value`. Append `"@signature-params":` followed by the raw inner list and parameters of the `assay` member. This is the text after its `assay=`, ending before any next dictionary member; preserve parameter order, spacing and escaping. Do not include `assay=` or rebuild parameters from an unordered map.

Join the lines with a single LF (`\n`), with no trailing newline. Encode as UTF-8. Verify plain Ed25519 over those bytes, without adding a separate hash or using Ed25519ph.

The usual digest header is `sha-256=:BASE64_DIGEST:`. The digest and signature use standard base64 inside structured byte sequences. The JWK's `x` public-key value uses base64url. Select a matching `kid` with `kty="OKP"`, `crv="Ed25519"` and a usable Ed25519 public key; skip unusable keys. Follow the [production cache rules](/developers/list-your-api-advanced/run-in-production.md#keep-key-rotation-working) to handle rotation.

## Example: exact base from a fixture

The first positive fixture has raw body `{"q":"a b"}`. Its canonical base is these seven lines, without a final newline:

```
"@method": POST
"@authority": api.example.test
"@path": /v1/search
"@query": ?q=a%20b
"content-digest": sha-256=:dm7Bv3nwO4zfpapceY8ZEGZiaYiTOc8C3j72DLhHCSw=:
"x-assay-call-id": c-1
"@signature-params": ("@method" "@authority" "@path" "@query" "content-digest" "x-assay-call-id");created=1760700000;expires=1760700300;nonce="AQEBAQEBAQEBAQEBAQEBAQ";keyid="test-2026-10";alg="ed25519";tag="assay-request"
```

To inspect the exact expected string and verify its fixture signature independently, run this from a copy of the verifier repository. It requires Node 20 or newer and uses only built-in modules:

```sh
node --input-type=module <<'JS'
import { readFileSync } from "node:fs";
import { createPublicKey, verify } from "node:crypto";
const vectors = JSON.parse(readFileSync("test-vectors/v1.json", "utf8"));
const c = vectors.cases[0];
const signature = c.expected.headers.Signature.match(/^assay=:([^:]+):$/)[1];
const ok = verify(
  null,
  Buffer.from(c.expected.signatureBase, "utf8"),
  createPublicKey({ key: vectors.publicKeyJwk, format: "jwk" }),
  Buffer.from(signature, "base64"),
);
if (!ok) throw new Error("fixture signature failed");
console.log(JSON.stringify(c.expected.signatureBase));
console.log("fixture signature verified");
JS
```

This checks the fixture itself. Your implementation must also rebuild the base from the request and compare it byte for byte with `expected.signatureBase`.

## Check it worked

Run every case in `test-vectors/v1.json` and `test-vectors/negative.json` through your verifier. For positive cases, merge `request.headers` with `expected.headers`, use `publicKeyJwk`, encode the body as UTF-8 and pin the clock to `params.created`. Require acceptance and the exact expected base.

For negative cases, use each supplied `now` and require the exact `expectedReason`; `OK` means acceptance. Start a fresh replay store per case, but keep it across a case's `sequence`. That is how the replay case proves that the first call passes and the next fails. The fixture seed and public key belong only in tests.

Include concurrency and store-outage checks in your integration tests. The production store must share nonce state across instances and perform its check and insertion atomically.

## Errors

Return HTTP `401`, `content-type: application/json` and `{"error":"invalid_assay_signature","reason":"CODE"}` for a signature rejection. Preserve the check order so your reason agrees with the fixtures. Infrastructure failures must also stay closed; they are not evidence of a bad signature.

See [Reject reasons](/developers/list-your-api-get-started/reject-reasons.md) for the complete caller-facing reason table and fixes.

## Go further

* [Run the verifier in production](/developers/list-your-api-advanced/run-in-production.md).
* List your API (optional): [Write your own verifier](https://docs.goassay.ai/list-your-api/list-a-catalogue-tool).

## Next

* [Staking and penalties on chain](/developers/list-your-api-advanced/staking-and-penalties.md).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.goassay.ai/developers/list-your-api-advanced/write-your-own-verifier.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
