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

QuestionAnswer
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:

  1. HTTP trigger (or your API) receives the POST payload.
  2. Decode hex fields into bytes.
  3. Report.parse(): verify signatures and read metadata.
  4. Use trusted body() in your logic.

For the full sender → receiver overview, see API Interactions: CRE reports over HTTP.

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

Onchain vs offchain verification

AspectOffchain (Report.parse)Onchain (KeystoneForwarder)
Where it runsInside your CRE workflow callbackIn a smart contract transaction
Signature checkLocal ecrecover on report hashContract logic onchain
Signer allowlistRead from Capability Registry (getDON, getNodesByP2PIds)Forwarder + registry
Typical useHTTP APIs, webhooks, ingest workflowsConsumer 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

  1. Parse the report header from rawReport (109-byte metadata + body).
  2. Fetch DON info from the registry (if not cached): fault tolerance f and signer addresses.
  3. Verify signatures: compute keccak256(keccak256(rawReport) || reportContext), recover signers, require f+1 valid signatures from authorized nodes.
  4. Return a Report object 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).

Complete example: HTTP receiver workflow

This workflow accepts a JSON payload (matching the format from Submitting Reports via HTTP), verifies it, then processes the trusted body.

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)
  void 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:

  1. An external system POSTs hex-encoded report, context, and signatures to your HTTP trigger.
  2. Report.parse() verifies signatures against the production CRE registry.
  3. On success, you read metadata and body() safely.

Report payload format

Receivers need three fields (plus optional metadata your API may add):

FieldDescription
reportHex-encoded rawReport bytes (metadata header + workflow payload)
contextHex-encoded reportContext (config digest + sequence number)
signaturesArray of hex-encoded 65-byte ECDSA signatures from DON nodes

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:

MethodDescription
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,
}
OptionDescription
acceptedEnvironmentsRegistry environments to check (defaults to production)
acceptedZonesRestrict to specific DON IDs within an environment
skipSignatureVerificationParse 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

  1. Verify before side effects: Call Report.parse() before writing to databases, chains, or external systems.
  2. Permission on metadata: After verification, check workflowId(), workflowOwner(), or donId() match your expectations.
  3. Deduplicate by execution ID: Use executionId() or keccak256(rawReport) to reject replays (see Submitting Reports via HTTP).
  4. Do not skip signature verification in production unless you have another trust path.

Troubleshooting

invalid signature / unknown signer

  • 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.

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). Retry or check simulation vs production EVM access.

raw report too short

  • rawReport is missing the 109-byte metadata header.

Learn more

Get the latest Chainlink content straight to your inbox.