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.
| Field | Type | Meaning |
|---|---|---|
apiVersion | string | Always "1" |
chainId | decimal string | The chain the answer is about |
registry | address | The CredentialRegistry of the contract set that answered |
blockNumber | integer | The latest block the server had seen when it read the chain |
dataMaxAgeSeconds | integer, 0 to 3,600 | How much older than generatedAt the chain data may be, because of server caching (the server's API_CACHE_SECONDS) |
generatedAt | RFC 3339 time, UTC, whole seconds (YYYY-MM-DDTHH:MM:SSZ) | When the answer was made |
validUntil | RFC 3339 time | 300 seconds after generatedAt. After this the answer is stale, not forged. |
requestNonce | 32-byte hex or null | The nonce the caller sent as ?nonce=, echoed back, or null |
signature
| Field | Meaning |
|---|---|
type | Always "EIP712" |
signer | The address the server claims signed. Never trust it alone: compare it with the address you pinned. |
value | The 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:
| Field | Taken from |
|---|---|
apiVersion | meta.apiVersion |
chainId | meta.chainId as a number |
registry | meta.registry |
bodyHash | keccak256 of the UTF-8 bytes of the RFC 8785 (JSON Canonicalization Scheme) form of { "data": data, "meta": meta } |
generatedAt, validUntil | meta times as Unix seconds |
requestNonce | meta.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.
| Step | Check | SDK reason when it fails |
|---|---|---|
| 1 | The JSON has exactly the shape above | shape |
| 2 | apiVersion is "1" | version |
| 3 | The address recovered from the signature equals signature.signer | signature |
| 4 | That address equals the signer you pinned (the deployment's NEXT_PUBLIC_API_SIGNER) | signer |
| 5 | chainId and registry are the ones you expect, if you pinned them | chain, registry |
| 6 | Now is between generatedAt and validUntil (the SDK allows 60 seconds of clock difference and also refuses answers older than its own maximum age) | stale |
| 7 | dataMaxAgeSeconds is no more than you accept | data-age |
| 8 | requestNonce equals the nonce you sent | replayed |
| 9 | For POST /verify: data.fileSha256 is the SHA-256 of the exact text you sent | file |
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.
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(theMetaandSignatureschemas)packages/sdk/src/(verifyEnvelope)
Tests
src/lib/api/envelope.test.tspackages/sdk/test/envelope.test.tsinterop/test/api-answers.test.mjschecks answers with independent code