Data Formats
This page documents the JSON payloads, ciphertext format, and API request/response schemas used by Veylent.
Anchor Payload (Signing Record)
The signing record contains the anchor ID, document hash, and the signer's identity. It is serialized as compact JSON, encrypted with the key from the QR code, and stored on the API server. The composite hash (SHA-256 of this JSON) is stored on-chain. The same format is AnchorPayload in the desktop app (payload.rs) and SignerPayload in libs/shared/types.
{
"a": "0x0123456789abcdef0123456789abcdef",
"d": "0xa1b2c3...f0",
"s": {
"t": "individual",
"n": "John Doe",
"e": "john@example.com",
"c": "Acme Corp",
"p": "CTO",
"tr": "google",
"v": true
},
"ts": 1774288200,
"g": {
"la": 6.9271,
"ln": 79.8612
},
"salt": "e4f5a6...b7c8"
}
Field Reference
| Field | Key | Type | Required | Description |
|---|---|---|---|---|
| Anchor ID | a | string | Yes | The signature's anchor ID, 0x + 32 hex chars. Ties the record to its QR code. |
| Document hash | d | string | Yes | 0x + SHA-256 hex of the saved file, through the end of this signer's layer |
| Signer info | s | object | Yes | Signer identity |
| Signer type | s.t | string | Yes | "individual" or "company" |
| Name | s.n | string | Yes | Signer's display name |
s.e | string | Yes | Signer's email address | |
| Company | s.c | string | No | Company name (omitted if empty) |
| Position | s.p | string | No | Job title (omitted if empty) |
| Trust anchor | s.tr | string | No | Auth provider: "email", "google", "microsoft" |
| Verified | s.v | boolean | No | Whether identity was verified by the trust anchor |
| Timestamp | ts | integer | Yes | Unix epoch seconds (UTC) |
| Geolocation | g | object | No | GPS coordinates at signing time |
| Latitude | g.la | number | If g present | Decimal degrees |
| Longitude | g.ln | number | If g present | Decimal degrees |
| Salt | salt | string | Yes | 32 random bytes, hex-encoded (64 chars) |
The tr and v fields are populated from the signer's JWT claims. They allow verifiers to display a trust badge indicating how the signer authenticated (e.g. "Authenticated via Google").
Short keys are used to keep the sealed record small.
Composite Hash
The composite hash is computed by SHA-256 hashing the exact serialized JSON text:
composite_hash = "0x" + hex(SHA-256(json_bytes))
This hash is the only value about the record stored on the blockchain. It commits to the entire record -- any change to any field produces a different hash. Verifiers hash the decrypted text as received, so the JSON is never re-serialized before hashing.
Salt Purpose
The 32-byte random salt ensures that:
- Two identical signings produce different composite hashes
- The composite hash cannot be brute-forced from known signer data
- Each signature is cryptographically unique
Ciphertext Format
The encrypted payload uses AES-128-GCM with a prepended nonce:
| Component | Size | Description |
|---|---|---|
| Nonce | 12 bytes | Random, generated per encryption |
| Encrypted data | Variable | AES-128-GCM ciphertext |
| Auth tag | 16 bytes | GCM authentication tag (appended by AES-GCM) |
The entire ciphertext (nonce + encrypted + tag) is base64url-encoded for storage and transmission.
Key
- Size: 16 bytes (128 bits)
- Generation: CSPRNG (
OsRng) - Encoding: Base64url without padding (22 characters)
- Location: QR code URL fragment only
API Schemas
POST /api/relay -- Submit Anchor
Request:
{
"anchorId": "0x0123456789abcdef0123456789abcdef",
"compositeHash": "0xabc123...def789",
"previousAnchorId": "0x00000000000000000000000000000000",
"encryptedPayload": "<base64url-encoded ciphertext>"
}
| Field | Type | Validation | Description |
|---|---|---|---|
anchorId | string | 0x + 32 hex chars | The signature's anchor ID, as printed in its QR code |
compositeHash | string | 0x + 64 hex chars | SHA-256 of the signing record |
previousAnchorId | string | 0x + 32 hex chars | Previous signer's anchor ID, or all zeros for the first signature |
encryptedPayload | string | String, max 64,000 chars | Base64url-encoded AES-128-GCM ciphertext |
Response:
{
"anchorId": "0x0123456789abcdef0123456789abcdef",
"txHash": "0x123...789",
"blockNumber": 42
}
Returns 409 if the anchor ID is already in use, and 400 if the previous anchor ID is not on the blockchain.
GET /api/verify/:anchorId -- Verify Signature
URL parameter: anchorId -- 0x + 32 hex chars
Response:
{
"anchorId": "0x0123456789abcdef0123456789abcdef",
"txHash": "0x123...789",
"blockNumber": 42,
"compositeHash": "0xabc...def",
"signer": "0xWalletAddress",
"previousAnchorId": "0x00000000000000000000000000000000",
"timestamp": 1711094400,
"chain": [
{
"anchorId": "0x0123456789abcdef0123456789abcdef",
"txHash": "0x123...789",
"blockNumber": 42,
"compositeHash": "0xabc...def",
"signer": "0xWalletAddress",
"previousAnchorId": "0x00000000000000000000000000000000",
"timestamp": 1711094400
}
],
"encryptedPayload": "<base64url-encoded ciphertext>"
}
The chain array contains all signatures in the document's chain, starting with the requested one and walking back to the first. Each entry represents one DocumentAnchored event from the smart contract. signer is the address that submitted the transaction (the relayer). Returns 404 if the anchor ID was never anchored.
QR URL Format
HTTPS://VERIFY.GETVEYLENT.COM/V/<anchorId digits>#<key digits>
| Component | Raw size | Encoded size | Encoding |
|---|---|---|---|
| Base URL | -- | 29 chars | Upper case |
/V/ separator | -- | 3 chars | Upper case |
| Anchor ID | 16 bytes | 40 chars | Two big-endian 8-byte halves, each 20 zero-padded digits |
# separator | -- | 1 char | Plain text |
| Encryption key | 16 bytes | 40 chars | Same as the anchor ID |
| Total | -- | 113 chars | -- |
Links from earlier builds use https://verify.getveylent.com/v/<base64url(anchorId)>#<base64url(key)>, which verifiers still accept. See QR Code for why digits make a smaller code.
PDF Metadata (Signing History)
Each signer's layer stores the full signing history as a JSON stream referenced from the document catalog under /SignChainMeta. The key keeps the product's original name so files signed earlier stay readable. Each layer's copy has one more entry than the layer before it.
{
"version": 3,
"signatures": [
{
"signer": "John Doe",
"email": "john@example.com",
"timestamp": "2026-11-03T10:30:00+00:00",
"anchorId": "0x0123456789abcdef0123456789abcdef",
"qrUrl": "HTTPS://VERIFY.GETVEYLENT.COM/V/0008198552921648689500081985529216486895#...",
"placements": [
{ "page": 1, "x": 72.0, "y": 96.0, "width": 120.0, "height": 40.0 }
],
"textFields": [],
"signerType": "individual",
"company": null,
"position": null,
"geo": [6.9271, 79.8612]
}
]
}
previousAnchorId is present on every entry after the first and holds the previous signer's anchor ID.
An entry is written into the same layer it describes, before that layer is hashed, so it can't hold the layer's own document hash. That hash lives in the sealed record, found through anchorId and opened with the key in qrUrl. The entry itself is plain and editable, so the desktop app shows names from the sealed record when it opens.
This metadata is used by the desktop app to:
- Find each signer's layer (the first save whose history has that signer's entry last)
- Get each signer's anchor ID and the key to their sealed record
- Check that the entries link in order before another signer extends the file
Files with a lower version, or entries without anchorId, are legacy. Their older fields (docHash, prevDocHash, inputFileHash, eofByteOffset, compositeHash, txHash, salt) are still parsed, but the files are reported as legacy and can't be signed further.