Verifying CRE Reports Offchain
This guide is for the receiver side: you already received a CRE report package (usually via HTTP) and need to prove it is authentic before using the payload.
When a workflow delivers results via HTTP (or another offchain channel), nothing onchain automatically validates the report. You must verify signatures before trusting the data.
The CRE SDK provides Report.parse() to do this inside a workflow. Verification runs offchain in your callback: signatures are checked with local cryptography, while authorized signer addresses are loaded via read-only calls to the onchain Capability Registry (default: Ethereum Mainnet). Results are cached per DON.
Where this guide fits
| Question | Answer |
|---|---|
| What is the report? | Same CRE report the sender created with runtime.report(). See Submitting Reports via HTTP. |
| Where does it come from? | Another workflow (or system) already ran sender steps: logic → runtime.report() → HTTP POST. You receive rawReport, context, and signatures in the request body. |
| What does this guide cover? | Step 3 below: Report.parse() before you use body() or take side effects. |
| Same workflow as the sender? | Often no: common pattern is Workflow A (publish) and Workflow B (ingest with HTTP trigger). |
Receiver flow:
- HTTP trigger (or your API) receives the POST payload.
- Decode hex fields into bytes.
Report.parse(): verify signatures and read metadata.- Use trusted
body()in your logic.
Pair this guide with Submitting Reports via HTTP on the sender side. This guide covers local simulation first, then the deploy example with authorizedKeys.
What you'll learn
- When to verify reports offchain vs relying on onchain forwarders
- How
Report.parse()validates signatures and reads metadata - How to build a receiver workflow that accepts reports over HTTP
- How to restrict verification to specific CRE environments or zones
Prerequisites
- SDK:
@chainlink/cre-sdkv1.8.0 or later (report verification support) - Familiarity with Submitting Reports via HTTP (report structure and JSON payload patterns)
- For HTTP-triggered receivers: HTTP Trigger configuration
Onchain vs offchain verification
| Aspect | Offchain (Report.parse) | Onchain (KeystoneForwarder) |
|---|---|---|
| Where it runs | Inside your CRE workflow callback | In a smart contract transaction |
| Signature check | Local ecrecover on report hash | Contract logic onchain |
| Signer allowlist | Read from Capability Registry (getDON, getNodesByP2PIds) | Forwarder + registry |
| Typical use | HTTP APIs, webhooks, ingest workflows | Consumer contracts via onReport |
Offchain verification still uses onchain data as a trust anchor: the first time a DON is seen, the SDK reads the production Capability Registry on Ethereum Mainnet to learn f and authorized signer addresses.
Default (productionEnvironment()):
- Chain: Ethereum Mainnet (chain selector
5009297550715157269) - Registry:
0x76c9cf548b4179F8901cda1f8623568b58215E62
How verification works
- Parse the report header from
rawReport(109-byte metadata + body). - Fetch DON info from the registry (if not cached): fault tolerance
fand signer addresses. - Verify signatures: compute
keccak256(keccak256(rawReport) || reportContext), recover signers, require f+1 valid signatures from authorized nodes. - Return a
Reportobject with accessors for workflow ID, owner, execution ID, body, and more.
If verification fails, Report.parse() throws (for example, unknown signer, insufficient signatures, or registry read failure).
Testing locally with simulation
After you run the submit guide complete example and copy JSON from webhook.site, use this section to exercise a receiver workflow in simulation.
- Save the webhook JSON as
test-report-payload.jsonin your receiver workflow folder. - Use the minimal receiver below with an empty HTTP trigger config (no
authorizedKeysuntil deploy). - From the CRE project root, run
cre workflow simulatewith--http-payload verify-report-receiver/test-report-payload.json(path relative to where you invokecre).
Sim wiring vs full verify
| Mode | Config | What it validates |
|---|---|---|
| Wiring / decode | skipSignatureVerification: true in staging config | JSON + hex decode, workflowId(), executionId(), donId(), body() |
| Full crypto verify | Default Report.parse() (production registry) | Reports from a deployed/production DON. Sim-signed reports fail default verification: simulation uses local DON keys; Report.parse() checks the mainnet Capability Registry. |
Minimal receiver for simulation
Use an empty HTTP trigger config for sim (add authorizedKeys before deploy). Call Report.parse() from your handler with the runtime parameter. The CLI delivers --http-payload file contents as payload.input bytes.
config.staging.json:
{
"skipSignatureVerification": true
}
main.ts:
import {
decodeJson,
handler,
hexToBytes,
HTTPCapability,
Report,
Runner,
type HTTPPayload,
type Runtime,
} from "@chainlink/cre-sdk"
interface Config {
skipSignatureVerification?: boolean
}
type ParsedPayload = {
report: string
context: string
signatures: string[]
}
/** Hex without 0x prefix in JSON → bytes (add 0x before decode). */
const fromHexNoPrefix = (hex: string): Uint8Array => hexToBytes(`0x${hex}`)
/** AggregateError from Report.parse often has an empty .message in sim output. */
const formatError = (err: unknown): string => {
if (err instanceof AggregateError) {
const parts = err.errors.map((e) => (e instanceof Error ? e.message : String(e)))
return parts.join("; ") || "report verification failed"
}
if (err instanceof Error) return err.message
return String(err)
}
export async function run(runtime: Runtime<Config>, payload: HTTPPayload): Promise<{ verified: boolean }> {
try {
const parsed = decodeJson(payload.input) as ParsedPayload
const rawReport = fromHexNoPrefix(parsed.report)
const reportContext = fromHexNoPrefix(parsed.context)
const sigs = parsed.signatures.map((s) => fromHexNoPrefix(s))
runtime.log(`Parsing report (${rawReport.length} bytes, ${sigs.length} signatures)`)
const report = await Report.parse(runtime, rawReport, sigs, reportContext, {
skipSignatureVerification: runtime.config.skipSignatureVerification ?? false,
})
runtime.log(
`Verified report workflowId=${report.workflowId()} executionId=${report.executionId()} donId=${report.donId()}`
)
report.body()
return { verified: true }
} catch (err) {
const msg = formatError(err)
runtime.log(`Report verification failed: ${msg}`)
throw new Error(msg)
}
}
export const initWorkflow = () => {
const http = new HTTPCapability()
return [handler(http.trigger({}), run)]
}
export async function main() {
const runner = await Runner.newRunner<Config>()
await runner.run(initWorkflow)
}
Save webhook JSON as verify-report-receiver/test-report-payload.json. From the CRE project root:
cre workflow simulate verify-report-receiver \
--target staging-settings \
--non-interactive \
--trigger-index 0 \
--http-payload verify-report-receiver/test-report-payload.json
Pass criteria
- Sim wiring:
skipSignatureVerification: true: logs show metadata and{ verified: true }. - Full crypto verify: default config with a production-signed report (not typical for sender-sim → receiver-sim alone).
Complete example: HTTP receiver workflow (deploy)
Once simulation is working, update the trigger config for deployment with authorizedKeys.
It accepts JSON with hex report, context, and signatures (from the submit guide’s complete working example or Pattern 4 for offchain verification (hex)), not base64 Pattern 4 unless you change decoding.
import {
HTTPCapability,
handler,
Report,
type HTTPPayload,
type Runtime,
type SecretsProvider,
} from "@chainlink/cre-sdk"
import { hexToBytes } from "viem"
import { z } from "zod"
export const configSchema = z
.object({
authorized_key: z.string(),
})
.transform((data) => ({
authorizedKey: data.authorized_key,
}))
export type Config = z.infer<typeof configSchema>
type ParsedPayload = {
report: string
context: string
signatures: string[]
}
export async function run(runtime: Runtime<Config>, payload: HTTPPayload): Promise<boolean> {
const parsed: ParsedPayload = JSON.parse(new TextDecoder().decode(payload.input))
const rawReport = hexToBytes(`0x${parsed.report}`)
const reportContext = hexToBytes(`0x${parsed.context}`)
const sigs = parsed.signatures.map((s) => hexToBytes(`0x${s}`))
const report = await Report.parse(runtime, rawReport, sigs, reportContext)
runtime.log(`Verified report from workflow ${report.workflowId()}, execution ${report.executionId()}`)
// Use report.body() for your application logic (ABI-encoded payload from the sender workflow)
report.body()
return true
}
export const initWorkflow = (config: Config, _secretsProvider: SecretsProvider) => {
const http = new HTTPCapability()
return [
handler(http.trigger({ authorizedKeys: [{ type: "KEY_TYPE_ECDSA_EVM", publicKey: config.authorizedKey }] }), run),
]
}
What's happening:
- An external system POSTs hex-encoded
report,context, andsignaturesto your HTTP trigger. Report.parse()verifies signatures against the production CRE registry.- On success, you read metadata and
body()safely.
Report payload format
Receivers need three JSON fields (plus optional metadata your API may add). The JSON key is context even though the SDK field is reportContext:
| JSON field | SDK field | Description |
|---|---|---|
report | rawReport | Hex-encoded bytes (metadata header + workflow payload), no 0x |
context | reportContext | Hex-encoded config digest + sequence number |
signatures | sigs | Array of hex-encoded 65-byte ECDSA signatures, no 0x |
The reportContext layout used by the SDK:
- Bytes 0–31: config digest
- Bytes 32–39: sequence number (big-endian
uint64)
API reference
See SDK Reference: Core: Report verification for full signatures, types, and configuration.
Report.parse()
Report.parse(
runtime: Runtime,
rawReport: Uint8Array,
signatures: Uint8Array[],
reportContext: Uint8Array,
config?: ReportParseConfig,
): Promise<Report>
Parses and verifies a report. Throws if verification fails.
Report accessors
After a successful parse:
| Method | Description |
|---|---|
workflowId() | Workflow hash (bytes32 as hex) |
workflowOwner() | Deployer address (hex) |
workflowName() | Workflow name field from metadata |
executionId() | Unique execution identifier |
donId() | DON that produced the report |
timestamp() | Report timestamp (Unix seconds) |
body() | Encoded payload after the 109-byte header |
seqNr() | Sequence number from report context |
configDigest() | Config digest from report context |
ReportParseConfig
import { productionEnvironment, zoneFromEnvironment, type ReportParseConfig } from "@chainlink/cre-sdk"
const config: ReportParseConfig = {
acceptedZones: [zoneFromEnvironment(productionEnvironment(), 1)],
acceptedEnvironments: [productionEnvironment()],
skipSignatureVerification: false,
}
| Option | Description |
|---|---|
acceptedEnvironments | Registry environments to check (defaults to production) |
acceptedZones | Restrict to specific DON IDs within an environment |
skipSignatureVerification | Parse metadata only, without registry reads or signature checks. Use only for testing or when another layer verifies signatures. There is no separate verifySignatures() on Report in TypeScript; call Report.parse() without this flag for production verification. |
Most workflows should use the default config (production environment only).
Best practices
- Verify before side effects: Call
Report.parse()before writing to databases, chains, or external systems. - Permission on metadata: After verification, check
workflowId(),workflowOwner(), ordonId()match your expectations. - Deduplicate by execution ID: Use
executionId()orkeccak256(rawReport)to reject replays (see Submitting Reports via HTTP). - Do not skip signature verification in production unless you have another trust path.
Troubleshooting
Empty error after verify sim
Report.parse()may throw anAggregateErrorof multipleinvalid signatureerrors.AggregateError.messageis often empty, so the CLI printsExecution resulted in an error being returned:with nothing after the colon.- Format errors in your handler before rethrowing (see the simulation example above).
invalid signature / unknown signer in sim with fresh webhook JSON
- Expected when using default
Report.parse()on a sim-signed report: simulator DON keys do not match mainnet registry signers. - For local wiring tests, set
skipSignatureVerification: true. For real crypto verify, use a deployed sender or production-signed reports.
invalid signature / unknown signer (deployed)
- Signatures may be from a different DON or stale registry config.
- Confirm the sender workflow used production CRE and the report was not tampered with.
unexpected token: 'test' on simulate
- Wrong
--http-payloadpath. Invokecrefrom the project root and use a path such asverify-report-receiver/test-report-payload.json.
Receiver JSON parse error
- You copied a binary/octet-stream webhook body instead of Pattern 4 JSON. Use Pattern 4 for offchain verification (hex).
wrong number of signatures
- At least f+1 valid signatures are required. Extra invalid signatures are skipped; too few valid ones fails verification.
could not read from chain ...
- Registry read failed (RPC/network). Configure
ethereum-mainnetRPC inproject.yaml(required for default verify, including sim). Sepolia-only RPC is not sufficient for defaultReport.parse().
raw report too short
rawReportis missing the 109-byte metadata header.
Learn more
- Submitting Reports via HTTP: sender workflow; create and POST the report
- SDK Reference: Core: Report verification:
Report.parse, accessors, andReportParseConfig - HTTP Trigger Overview: trigger deployed receiver workflows
- Submitting Reports Onchain: onchain forwarder verification path
- Building Consumer Contracts: permissioning
onReportwith workflow metadata