Verification API (server side)
The v1 verification API lets a machine (an admissions system, an HR tool) ask about a credential without a wallet and without running its own chain node. Every success answer is signed by a dedicated server key, so a caller who pins that key's address can tell an answer from this deployment apart from a forged or altered one.
This page explains how the server builds and protects the API. The endpoint reference for callers is REST API, the signed answer format is API envelope, and the client library is SDK.
The API is built but answers 503 CONFIG_MISSING "The verification API is not available on this deployment." until the deployment owner sets API_SIGNER_KEY and NEXT_PUBLIC_API_SIGNER. The owner guide is docs/api/README.md in the repository.
Request path
Configuration
readApiConfig (src/lib/server/api/config.ts) returns null, which means off, unless the signer key, the chain id and an RPC endpoint are all present. A value that is present but malformed throws ApiConfigError, which also answers 503 and is logged by name.
| Name | Rule |
|---|---|
API_SIGNER_KEY | 0x plus 64 hex characters. A key that holds no funds and signs nothing else. Server only. |
NEXT_PUBLIC_API_SIGNER | Must equal the address of API_SIGNER_KEY; any difference is a configuration error. This is the address callers pin. |
NEXT_PUBLIC_NETWORK_ID | The chain the API answers for. The bundled v0.3 manifest must be for the same chain. |
RPC_URLS, RPC_URL (or the public names) | Chain endpoints |
API_CACHE_SECONDS | 0 to 60, default 15. How long chain reads are cached, and the dataMaxAgeSeconds the API signs. |
API_KEYS | JSON list of { "id", "sha256", "tier": "standard" } with exactly those fields. id is 1 to 40 lowercase letters, digits or hyphens; sha256 is 64 hex characters (the hash of the key, never the key). No id or hash may repeat. |
API_ALLOW_LOCAL_LIMITS | true lets production run with per-instance memory counters instead of Redis. Otherwise production needs the shared store. |
npm run api:key -- <id> makes a new key and prints the key once, its hash, and the API_KEYS entry; see Command-line tools.
Runtime
getApiRuntime (src/lib/server/api/wiring.ts) builds the runtime on first use and rebuilds it when the settings change:
- Reads the configuration and checks the pinned signer.
- Loads the v0.3 manifest for the chain; no manifest means off.
- Creates the failover RPC provider with a 10 second timeout per call.
- Builds a read-only credential gateway. Its identity refuses to sign transactions ("The verification API cannot sign credentials."), so no code path can send one.
- Wraps the chain reads in a TTL cache.
src/lib/server/api/instance.ts creates the handlers with that runtime and the key-value store, and keeps them for the life of the server instance.
Chain reads and caching
| Read | Cached for |
|---|---|
| Credential record, credential status | API_CACHE_SECONDS |
| Issuer registered, issuer suspended, issuer key lineage | API_CACHE_SECONDS |
| Latest block number | The smaller of 3 seconds and API_CACHE_SECONDS |
Each of these caches holds at most 5,000 entries (MAX_CACHED_READS). Because a cached answer can be up to API_CACHE_SECONDS old, every signed answer states dataMaxAgeSeconds with that value, and states the block number it read at.
Contract sets
The API answers for every contract set in the manifest (see Contract-set routing):
- Credential lookup by id (
lookupInSetsincontract-sets.ts) asks the active set first, then the older ones. It answersfound: falseonly when every set answered; if any set could not be read, the request fails with 503 rather than claiming the credential does not exist. - File verification reads the registry the file names (
domain.verifyingContract) and uses that set. A registry that is not listed is refused with 422REGISTRY_NOT_LISTED"The file names a contract registry this API does not answer for." The file is not checked at all. - The
meta.registryof each answer is theCredentialRegistryof the set that answered.
What a credential answer contains
credential-lookup.ts turns the chain reads into the data of GET /credentials/{id}:
| Field | Meaning |
|---|---|
found | Whether any listed set holds the id |
issuer | The key that signed the credential |
currentIssuer | The key now in charge of that issuer (differs after a key retirement) |
issuerRegistered, issuerSuspended | Registration and suspension of currentIssuer |
issuerRetired | Whether the signing key was retired |
recordType, issuedAt, validUntil | Anchor fields (validUntil 0 means no expiry) |
status | active, suspended, revoked or superseded |
expired | validUntil is set and in the past |
A credential whose status reads as "none" although the record exists is treated as a chain data error (503), never as active.
File verification
POST /verify reads the body as text, at most 65,536 characters (MAX_VC_CHARS, checked against the declared length and again while streaming). verify-file.ts runs the same checks as the verify page for a VC or Open Badge file: offline signature and claims checks, on-chain anchor, issuer and status checks, status lists when present, and the standard proof of a badge. It adds:
fileSha256: the SHA-256 of the exact text received, so a signed verdict cannot be passed off for another file.fullyVerifiedandlimits: plain sentences about what the verdict does not prove (for example that holder details were not checked).
A contract-wallet issuer makes the server call the wallet address named in the file to check the signature; the guide lists this as a known limit.
Signing
Every 200 answer except the OpenAPI document is { data, meta, signature }:
metaholds the API version, chain id, answering registry, block number,dataMaxAgeSeconds,generatedAt,validUntil(300 seconds later) and the caller's nonce if one was sent.- The signature is EIP-712 over a hash of the canonical JSON of
dataandmeta.
Field-by-field details and the checking steps are in API envelope. Errors are not signed.
Keys and limits
auth.ts reads Authorization: Bearer <key>, where the key is 43 to 128 characters of A-Z a-z 0-9 _ -. The server hashes it with SHA-256 and compares the hash with every configured hash in constant time, without stopping at the first match. No header means anonymous. A malformed or unknown key is never treated as anonymous: it gets 401 "The API key is not valid." with WWW-Authenticate: Bearer, and the attempt counts against the IP.
| Tier | Reads per hour (GET) | Verifications per hour (POST /verify) | Counted per |
|---|---|---|---|
| Anonymous | 600 | 60 | IP address (IPv6 per /64 block) |
| Standard key | 6,000 | 600 | Key id |
Every counted answer carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. A used-up budget gets 429 with Retry-After. If the store cannot count, the API answers 503 "Requests cannot be counted right now. Try again shortly." rather than serving uncounted.
Headers
| Header | Value |
|---|---|
Access-Control-Allow-Origin | * (answers are public and signed; no cookies are used) |
Access-Control-Expose-Headers | RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, Retry-After |
X-Content-Type-Options | nosniff |
Cache-Control | no-store for POST /verify and for any request with a nonce; private, no-store with an API key; public, max-age=15 for anonymous reads |
OPTIONS answers 204 with GET, POST, OPTIONS, Authorization, Content-Type and Access-Control-Max-Age: 86400. The OpenAPI document is served with public, max-age=86400.
Errors and logging
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | Bad credential id, nonce, file or body |
| 401 | UNAUTHORIZED | Malformed or unknown API key |
| 413 | PAYLOAD_TOO_LARGE | "The body is larger than 65536 bytes." |
| 415 | UNSUPPORTED_MEDIA_TYPE | POST /verify not sent as application/json (checked before counting) |
| 422 | REGISTRY_NOT_LISTED | The file names an unlisted registry |
| 429 | RATE_LIMITED | Budget used up |
| 503 | CONFIG_MISSING | API off or no store |
| 503 | UPSTREAM_ERROR | "The chain could not be read right now. Try again shortly." with Retry-After: 30 |
A chain failure is logged as "The verification API could not read the chain:" with the error name and code only, never the message, which could quote an RPC address.
Source files
src/app/api/v1/credentials/[id]/route.ts,src/app/api/v1/verify/route.ts,src/app/api/v1/status/route.ts,src/app/api/v1/openapi.json/route.tssrc/lib/server/api/(config.ts,wiring.ts,instance.ts,handler.ts,auth.ts,api-limits.ts,body.ts,contract-sets.ts,credential-lookup.ts,verify-file.ts,responses.ts)src/lib/api/envelope.ts,src/lib/api/openapi.tsscripts/api-key.mjsdocs/api/README.md(owner and caller guide)
Tests
src/lib/server/api/*.test.tssrc/lib/api/envelope.test.ts,src/lib/api/openapi.test.ts- An independent client check in
interop/ - E2E:
e2e/journeys/verification-api.journey.ts(also runs the SDK against the live handlers)