Skip to main content

API envelope

Every successful answer of the v1 verification API (except the OpenAPI document) is a signed envelope. The signature lets a caller check that the answer came from the deployment they trust and was not changed on the way, even when it passed through a cache or a proxy.

The server side is described in Verification API (server side), the endpoints in REST API, and a client that checks envelopes for you in SDK.

Shape​

{
"data": { "id": "0x3333333333333333333333333333333333333333333333333333333333333333", "found": false },
"meta": {
"apiVersion": "1",
"chainId": "11155111",
"registry": "0x4444444444444444444444444444444444444444",
"blockNumber": 9000000,
"dataMaxAgeSeconds": 15,
"generatedAt": "2026-10-24T09:00:00Z",
"validUntil": "2026-10-24T09:05:00Z",
"requestNonce": "0x5555555555555555555555555555555555555555555555555555555555555555"
},
"signature": {
"type": "EIP712",
"signer": "0x6666666666666666666666666666666666666666",
"value": "0x<130 lowercase hex characters>"
}
}

All values above are made up.

data​

The answer itself. Its fields depend on the endpoint (credential facts, a file verdict, or the API status); see REST API. data is covered by the signature in full.

meta​

meta must have exactly these eight keys, no more and no fewer.

FieldTypeMeaning
apiVersionstringAlways "1"
chainIddecimal stringThe chain the answer is about
registryaddressThe CredentialRegistry of the contract set that answered
blockNumberintegerThe latest block the server had seen when it read the chain
dataMaxAgeSecondsinteger, 0 to 3,600How much older than generatedAt the chain data may be, because of server caching (the server's API_CACHE_SECONDS)
generatedAtRFC 3339 time, UTC, whole seconds (YYYY-MM-DDTHH:MM:SSZ)When the answer was made
validUntilRFC 3339 time300 seconds after generatedAt. After this the answer is stale, not forged.
requestNonce32-byte hex or nullThe nonce the caller sent as ?nonce=, echoed back, or null

signature​

FieldMeaning
typeAlways "EIP712"
signerThe address the server claims signed. Never trust it alone: compare it with the address you pinned.
valueThe 65-byte signature as 0x plus 130 lowercase hex characters. The SDK also requires the last byte to be 1b or 1c (recovery id 27 or 28), so one signature has only one spelling.

What is signed​

The signature is an EIP-712 typed-data signature.

Domain:

{ "name": "Certichain API", "version": "1", "chainId": 11155111 }

The domain has no verifyingContract, because the API is not a contract. The chain id in the domain is the answer's meta.chainId.

Type:

ApiResponse(string apiVersion,uint256 chainId,address registry,bytes32 bodyHash,uint64 generatedAt,uint64 validUntil,bytes32 requestNonce)

Value:

FieldTaken from
apiVersionmeta.apiVersion
chainIdmeta.chainId as a number
registrymeta.registry
bodyHashkeccak256 of the UTF-8 bytes of the RFC 8785 (JSON Canonicalization Scheme) form of { "data": data, "meta": meta }
generatedAt, validUntilmeta times as Unix seconds
requestNoncemeta.requestNonce, or 32 zero bytes when it is null

Because bodyHash covers the canonical form of both data and meta, the key order or whitespace of the JSON does not matter, but any change to any value does. blockNumber and dataMaxAgeSeconds are signed through bodyHash even though they are not separate typed fields.

Checking an answer​

A caller should accept data only when all of these hold. The SDK (verifyEnvelope in packages/sdk) runs them in this order; checkEnvelope in src/lib/api/envelope.ts runs the first five and reports the window and nonce.

StepCheckSDK reason when it fails
1The JSON has exactly the shape aboveshape
2apiVersion is "1"version
3The address recovered from the signature equals signature.signersignature
4That address equals the signer you pinned (the deployment's NEXT_PUBLIC_API_SIGNER)signer
5chainId and registry are the ones you expect, if you pinned themchain, registry
6Now is between generatedAt and validUntil (the SDK allows 60 seconds of clock difference and also refuses answers older than its own maximum age)stale
7dataMaxAgeSeconds is no more than you acceptdata-age
8requestNonce equals the nonce you sentreplayed
9For POST /verify: data.fileSha256 is the SHA-256 of the exact text you sentfile

Sending a fresh random nonce with each request and checking the echo (step 8) stops someone replaying an older, still valid answer to you. Step 9 stops a verdict about one file being passed off as a verdict about another.

Pin the signer

An envelope is only as good as the address you compare it with. Get the API signer address from the deployment owner through a channel you trust and hard-code it. Never take it from the answer itself or from the same server at run time.

Errors are not signed​

Error answers use the plain shape { "error": { "code", "message" } } with no meta and no signature. A caller must treat any unsigned answer as "no answer", never as a negative verdict. Error codes are listed in Server routes.

Source files​

  • src/lib/api/envelope.ts (signEnvelope, checkEnvelope, bodyHashOf, API_RESPONSE_TYPES)
  • src/lib/server/api/handler.ts (metaFor)
  • src/lib/api/openapi.ts (the Meta and Signature schemas)
  • packages/sdk/src/ (verifyEnvelope)

Tests​

  • src/lib/api/envelope.test.ts
  • packages/sdk/test/envelope.test.ts
  • interop/test/api-answers.test.mjs checks answers with independent code