Skip to main content

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.

Off until configured, as of 2026-10-24

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.

NameRule
API_SIGNER_KEY0x plus 64 hex characters. A key that holds no funds and signs nothing else. Server only.
NEXT_PUBLIC_API_SIGNERMust equal the address of API_SIGNER_KEY; any difference is a configuration error. This is the address callers pin.
NEXT_PUBLIC_NETWORK_IDThe 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_SECONDS0 to 60, default 15. How long chain reads are cached, and the dataMaxAgeSeconds the API signs.
API_KEYSJSON 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_LIMITStrue 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:

  1. Reads the configuration and checks the pinned signer.
  2. Loads the v0.3 manifest for the chain; no manifest means off.
  3. Creates the failover RPC provider with a 10 second timeout per call.
  4. 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.
  5. 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​

ReadCached for
Credential record, credential statusAPI_CACHE_SECONDS
Issuer registered, issuer suspended, issuer key lineageAPI_CACHE_SECONDS
Latest block numberThe 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 (lookupInSets in contract-sets.ts) asks the active set first, then the older ones. It answers found: false only 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 422 REGISTRY_NOT_LISTED "The file names a contract registry this API does not answer for." The file is not checked at all.
  • The meta.registry of each answer is the CredentialRegistry of the set that answered.

What a credential answer contains​

credential-lookup.ts turns the chain reads into the data of GET /credentials/{id}:

FieldMeaning
foundWhether any listed set holds the id
issuerThe key that signed the credential
currentIssuerThe key now in charge of that issuer (differs after a key retirement)
issuerRegistered, issuerSuspendedRegistration and suspension of currentIssuer
issuerRetiredWhether the signing key was retired
recordType, issuedAt, validUntilAnchor fields (validUntil 0 means no expiry)
statusactive, suspended, revoked or superseded
expiredvalidUntil 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.
  • fullyVerified and limits: 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 }:

  • meta holds 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 data and meta.

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.

TierReads per hour (GET)Verifications per hour (POST /verify)Counted per
Anonymous60060IP address (IPv6 per /64 block)
Standard key6,000600Key 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​

HeaderValue
Access-Control-Allow-Origin* (answers are public and signed; no cookies are used)
Access-Control-Expose-HeadersRateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, Retry-After
X-Content-Type-Optionsnosniff
Cache-Controlno-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​

StatusCodeWhen
400BAD_REQUESTBad credential id, nonce, file or body
401UNAUTHORIZEDMalformed or unknown API key
413PAYLOAD_TOO_LARGE"The body is larger than 65536 bytes."
415UNSUPPORTED_MEDIA_TYPEPOST /verify not sent as application/json (checked before counting)
422REGISTRY_NOT_LISTEDThe file names an unlisted registry
429RATE_LIMITEDBudget used up
503CONFIG_MISSINGAPI off or no store
503UPSTREAM_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.ts
  • src/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.ts
  • scripts/api-key.mjs
  • docs/api/README.md (owner and caller guide)

Tests​

  • src/lib/server/api/*.test.ts
  • src/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)