Skip to main content

Webhook Payload & Verification

Every event is delivered as an HTTP POST to your configured webhook.url.


Request Format

POST /your-endpoint
Content-Type: application/json
X-TrustVC-Signature: ed25519=<base64url-signature>

The body follows the CloudEvents 1.0 specification:

{
"specversion": "1.0",
"id": "550e8400-e29b-41d4-a716-446655440000",
"source": "urn:trustvc:11155111:0xe6b5ce7e3691a0927b2806ce6638b35237dffac4",
"type": "com.trustvc.etr.holder_transfer",
"datacontenttype": "application/json",
"time": "2024-01-15T10:31:00.000Z",
"subject": "1",
"data": {
"chainKey": "ethereum-sepolia",
"chainId": 11155111,
"registryAddress": "0xe6b5ce7e3691a0927b2806ce6638b35237dffac4",
"tokenId": "1",
"blockNumber": 6123456,
"transactionHash": "0xabcdef...",
"logIndex": 0,
"payload": {
"fromHolder": "0xSenderAddress",
"toHolder": "0xReceiverAddress"
}
}
}

Top-Level Fields

FieldDescription
specversionAlways "1.0"
idUUID — globally unique event identifier
sourceurn:trustvc:<chainId>:<registryAddress>
typeEvent type — see Event Types below
timeISO-8601 block timestamp
subjectToken ID as a string
dataEvent-specific payload — see below

data Fields

FieldDescription
chainKeyChain identifier (e.g. ethereum-sepolia)
chainIdNumeric EIP-155 chain ID
registryAddressToken Registry contract address (lowercase)
tokenIdETR token ID as a string
blockNumberBlock the event was confirmed in
transactionHashTransaction that emitted the event
logIndexLog index within the transaction
payloadEvent-specific data (addresses, amounts, etc.)
Idempotency

Use data.transactionHash + data.logIndex as your idempotency key — this combination uniquely identifies any on-chain event.


Event Types

typeTriggerKey payload fields
com.trustvc.etr.mintedToken mintedtokenId, owner
com.trustvc.etr.burnedToken burnedtokenId
com.trustvc.etr.surrenderedToken surrendered to registrytokenId
com.trustvc.etr.restoredToken restored from registrytokenId
com.trustvc.etr.registry_pausedRegistry paused
com.trustvc.etr.registry_unpausedRegistry unpaused
com.trustvc.etr.escrow_createdNew TitleEscrow deployedescrowAddress
com.trustvc.etr.token_receivedEscrow took custodyescrowAddress
com.trustvc.etr.nominationBeneficiary nominee setnominee
com.trustvc.etr.beneficiary_transferBeneficiary transferredfromBeneficiary, toBeneficiary
com.trustvc.etr.holder_transferHolder transferredfromHolder, toHolder
com.trustvc.etr.return_to_issuerToken returned to issuer
com.trustvc.etr.shredToken permanently destroyed
com.trustvc.etr.reject_transfer_beneficiaryBeneficiary transfer rejected
com.trustvc.etr.reject_transfer_holderHolder transfer rejected
com.trustvc.etr.reject_transfer_ownersBoth roles rejected simultaneously

Signature Verification

Every request includes an X-TrustVC-Signature header:

X-TrustVC-Signature: ed25519=<base64url-signature>

The signature is computed over the raw request body bytes using the Ed25519 private key configured in SIGNING_PRIVATE_KEY. Your receiver verifies it with the corresponding public key — the public key cannot forge payloads even if your receiver is compromised.

Verify on raw bytes

Parse the signature header before parsing the JSON body. Always verify the signature against the raw, unparsed body bytes — not a re-serialized object.

Node.js / TypeScript

import crypto from 'node:crypto';
import fs from 'node:fs';

const publicKey = crypto.createPublicKey(fs.readFileSync('public.pem'));

function verifyTrustVCWebhook(rawBody: Buffer, signatureHeader: string): boolean {
const b64url = signatureHeader.replace('ed25519=', '');
const signature = Buffer.from(b64url, 'base64url');
return crypto.verify(null, rawBody, publicKey, signature);
}

// Express example
app.post('/trustvc/events', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyTrustVCWebhook(req.body, req.headers['x-trustvc-signature'] as string)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString());
// handle event...
res.status(200).send('ok');
});

Python

import base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from cryptography.exceptions import InvalidSignature

with open('public.pem', 'rb') as f:
public_key = load_pem_public_key(f.read())

def verify_trustvc_webhook(raw_body: bytes, signature_header: str) -> bool:
b64url = signature_header.replace('ed25519=', '')
# base64url → standard base64
padding = '=' * (-len(b64url) % 4)
signature = base64.b64decode(b64url.replace('-', '+').replace('_', '/') + padding)
try:
public_key.verify(signature, raw_body)
return True
except InvalidSignature:
return False

Getting the Public Key

If you generated the key with openssl rand -base64 32 (a raw seed), derive the public key:

# Convert raw seed to PEM private key, then extract public key
echo "YOUR_BASE64_SEED" | base64 -d > seed.bin
openssl pkey -inform DER -in <(printf '\x30\x2e\x02\x01\x00\x30\x05\x06\x03\x2b\x65\x70\x04\x22\x04\x20'; cat seed.bin) -pubout -out public.pem

If you generated with openssl genpkey, you already have public.pem from Quick Start Step 2.