Verifying CRE Reports Offchain

This guide is for the receiver side: you already received a CRE report package — typically via HTTP from a Submitting Reports via HTTP sender workflow — 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.

There are two ways to verify, depending on who receives the POST:

My receiver is...Use
My own API or server (Express, FastAPI, Go server, etc.)Verifying outside CRE — standard crypto libraries, no CRE SDK needed
A CRE workflow with an HTTP triggerReport.parse() — see CRE receiver workflow

Both paths run the same cryptographic checks against the same onchain Capability Registry. The difference is whether you use the CRE SDK's helper (which requires runtime) or implement it yourself with standard libraries.

What is a CRE report?

A CRE report is a DON-signed package another workflow (or system) created with runtime.report(). You receive its bytes over HTTP (or another channel) as rawReport, reportContext, and signatures. Before you use the encoded payload, you must confirm the signatures match authorized DON signers on the Capability Registry.

See Key Terms: Report for how reports are created and delivered. Pair this guide with Submitting Reports via HTTP on the sender side.

Receiver flow (both paths):

  1. Your endpoint (API or CRE HTTP trigger) receives the POST payload.
  2. Decode hex fields into bytes.
  3. Verify signatures against the Capability Registry.
  4. Use the trusted payload body in your logic.

What you'll learn

  • When to verify reports offchain vs relying on onchain forwarders
  • How Report.parse() validates signatures and reads metadata (inside a CRE workflow)
  • How to build a CRE receiver workflow that accepts reports over HTTP
  • How to verify reports in your own API server without a CRE receiver workflow
  • How to restrict verification to specific CRE environments or zones

Prerequisites

  • A report payload to verify — three hex fields: report, context, signatures. If you don't have one yet, follow Submitting Reports via HTTP to create a sender workflow and capture its output first.
  • SDK: @chainlink/cre-sdk v1.8.0 or later (for the CRE receiver workflow path)
  • For HTTP-triggered receivers: HTTP Trigger configuration

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 useCRE receiver workflows with an HTTP triggerConsumer 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

At its core, verification answers one question: did enough authorized nodes from the right DON sign this exact report? To answer it, the receiver decodes the report's metadata header to find which DON produced it, asks the onchain Capability Registry for that DON's authorized signers and quorum threshold (f), then checks that at least f+1 of the provided signatures come from those addresses. Both paths in this guide — the outside-CRE script and the CRE receiver workflow — run this same sequence:

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

Verifying outside CRE

Report.parse() requires the CRE runtime object and can only run inside a CRE workflow callback. If your API server receives reports directly, you can verify without the CRE SDK using standard libraries.

If you followed the submit guide, you already have a JSON payload with report, context, and signatures as hex strings — this section shows how to verify it.

  1. Create a new folder for your verification script and install viem:

    mkdir verify-report && cd verify-report
    npm init -y
    npm install viem
    
  2. Save the following as verify.ts.

    Parses the 109-byte report header to identify the DON, reads the authorized signer list and fault tolerance f from the Capability Registry on Ethereum Mainnet, then ecrecovers each signature and requires at least f+1 to match. The ABI layout mirrors report.ts in cre-sdk-typescript.

    // verify-report/verify.ts
    import { keccak256, concatHex, toHex, recoverAddress, hexToBytes, createPublicClient, http } from "viem"
    import { mainnet } from "viem/chains"
    
    const REPORT_HEADER_LENGTH = 109
    const CAPABILITY_REGISTRY = "0x76c9cf548b4179F8901cda1f8623568b58215E62" as const
    
    /** Parse key fields from the 109-byte rawReport metadata header. */
    function parseHeader(rawReport: Uint8Array) {
      if (rawReport.length < REPORT_HEADER_LENGTH) {
        throw new Error(`rawReport too short: need ${REPORT_HEADER_LENGTH} bytes, got ${rawReport.length}`)
      }
      const view = new DataView(rawReport.buffer, rawReport.byteOffset)
      return {
        donId: view.getUint32(37, false), // bytes 37–40
        workflowId: toHex(rawReport.slice(45, 77)), // bytes 45–76
        workflowOwner: toHex(rawReport.slice(87, 107)), // bytes 87–106
        body: rawReport.slice(REPORT_HEADER_LENGTH), // bytes 109+
      }
    }
    
    /** keccak256(keccak256(rawReport) || reportContext) */
    function reportHash(rawReport: Uint8Array, reportContext: Uint8Array): `0x${string}` {
      return keccak256(concatHex([keccak256(toHex(rawReport)), toHex(reportContext)]))
    }
    
    /** Read the last 4 bytes of a 32-byte uint256 ABI slot as a JS number. */
    function readSlot(bytes: Uint8Array, offset: number): number {
      return new DataView(bytes.buffer, bytes.byteOffset + offset + 28, 4).getUint32(0, false)
    }
    
    // In-process cache: registry data only changes during DON reconfiguration.
    const signerCache = new Map<number, { f: number; signers: Set<string> }>()
    
    type PublicClient = ReturnType<typeof createPublicClient>
    
    /**
     * Fetch fault tolerance f and authorized signer addresses from the Capability Registry.
     * Makes two eth_call reads: getDON(donId) then getNodesByP2PIds(nodeP2PIds).
     * Result is cached by DON ID.
     */
    async function fetchSigners(client: PublicClient, donId: number): Promise<{ f: number; signers: Set<string> }> {
      const cached = signerCache.get(donId)
      if (cached) return cached
    
      // Step 1: getDON(uint32 donId) — selector keccak256("getDON(uint32)")[0:4] = 0x23537405
      const donIdPadded = new Uint8Array(32)
      new DataView(donIdPadded.buffer).setUint32(28, donId, false)
      const getDONCalldata = concatHex(["0x23537405", toHex(donIdPadded)])
    
      const getDONResult = await client.call({ to: CAPABILITY_REGISTRY, data: getDONCalldata })
      if (!getDONResult.data) throw new Error("getDON returned empty response")
      const getDONBytes = hexToBytes(getDONResult.data)
      if (getDONBytes.length < 224) throw new Error(`getDON response too short: ${getDONBytes.length} bytes`)
    
      // Response layout (see report.ts in cre-sdk-typescript for full ABI documentation):
      //   slot 3 (bytes  96-127): f               (uint8, zero-padded to 32)
      //   slot 6 (bytes 192-223): ptr[nodeP2PIds] relative to tupleStart = 32
      const f = readSlot(getDONBytes, 96)
      const nodeP2PIdsPtr = readSlot(getDONBytes, 192)
      const nodeCountOff = 32 + nodeP2PIdsPtr
      const nodeCount = readSlot(getDONBytes, nodeCountOff)
    
      const nodeP2PIds: `0x${string}`[] = []
      for (let i = 0; i < nodeCount; i++) {
        const start = nodeCountOff + 32 + i * 32
        nodeP2PIds.push(toHex(getDONBytes.slice(start, start + 32)))
      }
    
      if (nodeCount === 0) {
        const result = { f, signers: new Set<string>() }
        signerCache.set(donId, result)
        return result
      }
    
      // Step 2: getNodesByP2PIds(bytes32[]) — selector 0x05a51966
      // ABI-encode bytes32[]: [ptr=32][count][id0]...[idN]
      const ptrBytes = new Uint8Array(32)
      new DataView(ptrBytes.buffer).setUint32(28, 32, false)
      const countBytes = new Uint8Array(32)
      new DataView(countBytes.buffer).setUint32(28, nodeCount, false)
      const getNodesCalldata = concatHex(["0x05a51966", toHex(ptrBytes), toHex(countBytes), ...nodeP2PIds])
    
      const getNodesResult = await client.call({ to: CAPABILITY_REGISTRY, data: getNodesCalldata })
      if (!getNodesResult.data) throw new Error("getNodesByP2PIds returned empty response")
      const getNodesBytes = hexToBytes(getNodesResult.data)
      if (getNodesBytes.length < 64)
        throw new Error(`getNodesByP2PIds response too short: ${getNodesBytes.length} bytes`)
    
      // Response layout: [outerPtr][count][elem0-ptr][elem1-ptr]...[tuple0][tuple1]...
      // Each NodeInfo tuple (9 slots × 32 bytes): slot 3 = signer bytes32, first 20 bytes = address
      const outerPtr = readSlot(getNodesBytes, 0)
      const returnedCount = readSlot(getNodesBytes, outerPtr)
      const NODE_TUPLE_HEAD = 288 // 9 slots × 32 bytes
    
      const signers = new Set<string>()
      for (let i = 0; i < returnedCount; i++) {
        const elemPtr = readSlot(getNodesBytes, outerPtr + 32 + i * 32)
        const tupleBase = outerPtr + 32 + elemPtr
        if (tupleBase + NODE_TUPLE_HEAD > getNodesBytes.length) break
        const addrBytes = getNodesBytes.slice(tupleBase + 3 * 32, tupleBase + 3 * 32 + 20)
        signers.add(toHex(addrBytes).slice(2).toLowerCase()) // 40-char hex, no 0x prefix
      }
    
      const result = { f, signers }
      signerCache.set(donId, result)
      return result
    }
    
    /** Verify that ≥ f+1 signatures are from authorized DON signers. */
    async function verifyReport(
      rawReport: Uint8Array,
      signatures: Uint8Array[],
      reportContext: Uint8Array,
      client: PublicClient
    ): Promise<void> {
      const { donId } = parseHeader(rawReport)
      const { f, signers } = await fetchSigners(client, donId)
      const hash = reportHash(rawReport, reportContext)
      const required = f + 1
      let valid = 0
    
      for (const sig of signatures) {
        if (sig.length !== 65) continue
        const normalized = new Uint8Array(sig)
        if (normalized[64] >= 27) normalized[64] -= 27 // normalize recovery byte
        try {
          const recovered = await recoverAddress({ hash, signature: toHex(normalized) })
          if (signers.has(recovered.toLowerCase().slice(2))) valid++
        } catch {
          /* malformed signature — skip */
        }
        if (valid >= required) return
      }
      throw new Error(`insufficient valid signatures: ${valid}/${required}`)
    }
    
    // Usage — create the client once for your server, reuse it across requests:
    const client = createPublicClient({
      chain: mainnet,
      transport: http(process.env.ETH_MAINNET_RPC_URL!),
    })
    
    // For each incoming report POST:
    const rawReport = hexToBytes(`0x${payload.report}`)
    const reportContext = hexToBytes(`0x${payload.context}`)
    const signatures = payload.signatures.map((s: string) => hexToBytes(`0x${s}`))
    
    await verifyReport(rawReport, signatures, reportContext, client)
    // If it doesn't throw, the report is authentic.
    // Read the payload: parseHeader(rawReport).body
    
  3. Set your RPC URL and run the script:

    export ETH_MAINNET_RPC_URL=https://your-mainnet-rpc-endpoint
    npx tsx verify.ts
    
  4. Check the output.

    With a sim-signed report (from the submit guide sender simulation):

    Error: insufficient valid signatures: 0/4
    

    This error is the expected output for this example. The registry call succeeded and returned the real mainnet signer list (f=3, signers=10 for this DON), but the simulation generated its signatures with local test keys that don't appear on that list. The verification logic ran correctly and rejected the unrecognized signatures. When you switch to a production-signed report from a deployed sender, verifyReport will return without throwing.

    With a production-signed report from a deployed sender, verifyReport returns without throwing. Read the payload:

    const { body, workflowId, workflowOwner } = parseHeader(rawReport)
    // body is the ABI-encoded payload the sender embedded
    

CRE receiver workflow

Use this path if you want the receiver itself to be a CRE workflow with an HTTP trigger. Report.parse() handles Capability Registry reads and caching automatically.

Testing with simulation

If you ran the submit guide complete example, you already copied JSON from webhook.site. Use that payload here.

  1. Save the webhook JSON as test-report-payload.json inside your receiver workflow folder:

    verify-report-receiver/test-report-payload.json
    
  2. Create a verify-report-receiver/ folder in your CRE project with the following files.

    config.staging.json — enables wiring tests without mainnet signer validation:

    {
      "skipSignatureVerification": true
    }
    

    main.ts — uses an empty HTTP trigger (add authorizedKeys before deploying):

    // verify-report-receiver/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)
    }
    
    const run = async (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)
      }
    }
    
    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)
    }
    
  3. From the CRE project root, run the simulation:

    cre workflow simulate verify-report-receiver \
      --target staging-settings \
      --non-interactive \
      --trigger-index 0 \
      --http-payload verify-report-receiver/test-report-payload.json
    
  4. Check the output.

    A successful wiring run logs the decoded report metadata and returns { verified: true }:

    [USER LOG] Parsing report (141 bytes, 4 signatures)
    [USER LOG] Verified report workflowId=... executionId=... donId=1
    ✓ Workflow Simulation Result: { "verified": true }
    

    This confirms JSON decoding, hex parsing, and Report.parse() are wired correctly. Signatures are not verified against the mainnet registry in this mode — that requires a production-signed report from a deployed sender.

Deploying to production

The run handler you simulated is the same code you deploy. For production, make two configuration changes:

  1. Remove skipSignatureVerification from your target config (or omit it; the default is false). Reports from a deployed sender must pass real signature verification.
  2. Add authorizedKeys to your HTTP trigger — required for deployed workflows, not just simulation. See HTTP Trigger configuration.

Report payload format

When a sender POSTs a CRE report as JSON for offchain verification, receivers need three fields. The JSON key is context even though the SDK field is reportContext. See Payload contract in the submit guide for how the sender produces these fields.

JSON fieldSDK fieldDescription
reportrawReportHex-encoded bytes (metadata header + workflow payload), no 0x
contextreportContextHex-encoded config digest + sequence number
signaturessigsArray of hex-encoded 65-byte ECDSA signatures, no 0x

API reference

For full signatures, types, and ReportParseConfig options, see SDK Reference: Core: Report verification.

Report accessors

After a successful Report.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

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

Empty error after verify sim

  • Report.parse() may throw an AggregateError of multiple invalid signature errors. AggregateError.message is often empty, so the CLI prints Execution 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-payload path. Invoke cre from the project root and use a path such as verify-report-receiver/test-report-payload.json.

Receiver JSON parse error

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 ... (CRE receiver workflow only)

  • Registry read failed (RPC/network). Configure an ethereum-mainnet RPC in project.yaml — required for default Report.parse(), including during sim. Sepolia-only RPC is not sufficient.

raw report too short

  • rawReport is missing the 109-byte metadata header.

Learn more

Get the latest Chainlink content straight to your inbox.