# Verifying CRE Reports Offchain
Source: https://docs.chain.link/cre/guides/workflow/using-http-client/verifying-reports-offchain-ts
Last Updated: 2026-05-20

> For the complete documentation index, see [llms.txt](/llms.txt).

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.

> **NOTE: Not your workflow deployment registry**
>
> This guide uses the **Capability Registry** (DON signers), not the **workflow registry** where you deploy (`private` or `onchain:ethereum-mainnet`). If you deployed with the [private registry](/cre/guides/operations/deploying-to-private-registry-ts), `Report.parse` still works the same way. For an HTTP-triggered receiver, use the [enterprise gateway URL](/cre/guides/operations/deploying-to-private-registry-ts#http-triggers-with-the-private-registry) when triggering deployed workflows. Local simulation may still need an `ethereum-mainnet` RPC in `project.yaml` for those registry reads, even though private deploy does not.

> **NOTE: Onchain verification is different**
>
> When you submit reports onchain through the `KeystoneForwarder`, the forwarder contract verifies signatures before calling your consumer's `onReport`. This guide covers **offchain** verification for HTTP and custom ingest paths. See [Submitting Reports Onchain](/cre/guides/workflow/using-evm-client/onchain-write/submitting-reports-onchain).

## Where this guide fits

| Question                     | Answer                                                                                                                                                                                      |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| What is the report?          | Same CRE report the **sender** created with `runtime.report()`. See [Submitting Reports via HTTP](/cre/guides/workflow/using-http-client/submitting-reports-http-ts#where-this-guide-fits). |
| 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.

Pair this guide with [Submitting Reports via HTTP](/cre/guides/workflow/using-http-client/submitting-reports-http-ts) on the sender side. For local simulation, start with [Testing locally with simulation](#testing-locally-with-simulation) before the [deploy example](#complete-example-http-receiver-workflow) below.

## 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-sdk` v1.8.0 or later (report verification support)
- Familiarity with [Submitting Reports via HTTP](/cre/guides/workflow/using-http-client/submitting-reports-http-ts) (report structure and JSON payload patterns)
- For HTTP-triggered receivers: [HTTP Trigger configuration](/cre/guides/workflow/using-triggers/http-trigger/configuration-ts)

## 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

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 (deploy)

This workflow is for **deployed** receivers with `authorizedKeys`. For **local simulation**, use [Testing locally with simulation](#testing-locally-with-simulation) instead.

It accepts JSON with hex `report`, `context`, and `signatures` (from the submit guide’s [complete working example](/cre/guides/workflow/using-http-client/submitting-reports-http-ts#complete-working-example) or [Pattern 4 for offchain verification (hex)](/cre/guides/workflow/using-http-client/submitting-reports-http-ts#pattern-4-for-offchain-verification-hex)), not base64 [Pattern 4](/cre/guides/workflow/using-http-client/submitting-reports-http-ts#pattern-4-json-formatted-report) unless you change decoding.

```typescript
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()
  // For local simulation, use handler(http.trigger({}), run) — see Testing locally with simulation above.
  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.

> **CAUTION: Hex encoding**
>
> The example expects **hex strings without a `0x` prefix** in JSON. Adjust decoding if your API sends `0x`-prefixed values or base64 instead. Pattern 4 in the [submit guide](/cre/guides/workflow/using-http-client/submitting-reports-http-ts#pattern-4-json-formatted-report) uses base64 by default; use [Pattern 4 for offchain verification (hex)](/cre/guides/workflow/using-http-client/submitting-reports-http-ts#pattern-4-for-offchain-verification-hex) when testing this receiver.

## Testing locally with simulation

After you run the [submit guide complete example](/cre/guides/workflow/using-http-client/submitting-reports-http-ts#complete-working-example) and copy JSON from webhook.site, use this section to exercise a receiver workflow in simulation.

1. Save the webhook JSON as `test-report-payload.json` in your receiver workflow folder.
2. Use the minimal receiver below with an **empty HTTP trigger config** (no `authorizedKeys` until deploy).
3. From the **CRE project root**, run `cre workflow simulate` with `--http-payload verify-report-receiver/test-report-payload.json` (path relative to where you invoke `cre`).

### 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`:

```json
{
  "skipSignatureVerification": true
}
```

```typescript
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()
  // Simulation: http.trigger({}). Deploy: add authorizedKeys — see complete example below.
  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**:

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

## 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](/cre/reference/sdk/core-ts#report-verification) for full signatures, types, and configuration.

### `Report.parse()`

```typescript
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`

```typescript
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

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](/cre/guides/workflow/using-http-client/submitting-reports-http-ts#understanding-cachesettings-for-reports)).
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**

- You copied a **binary/octet-stream** webhook body instead of Pattern 4 JSON. Use [Pattern 4 for offchain verification (hex)](/cre/guides/workflow/using-http-client/submitting-reports-http-ts#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-mainnet` RPC** in `project.yaml` (required for default verify, including sim). Sepolia-only RPC is not sufficient for default `Report.parse()`.

**`raw report too short`**

- `rawReport` is missing the 109-byte metadata header.

## Learn more

- **[Submitting Reports via HTTP](/cre/guides/workflow/using-http-client/submitting-reports-http-ts):** sender workflow; create and POST the report
- **[SDK Reference: Core: Report verification](/cre/reference/sdk/core-ts#report-verification):** `Report.parse`, accessors, and `ReportParseConfig`
- **[HTTP Trigger Overview](/cre/guides/workflow/using-triggers/http-trigger/overview-ts):** trigger deployed receiver workflows
- **[Submitting Reports Onchain](/cre/guides/workflow/using-evm-client/onchain-write/submitting-reports-onchain):** onchain forwarder verification path
- **[Building Consumer Contracts](/cre/guides/workflow/using-evm-client/onchain-write/building-consumer-contracts):** permissioning `onReport` with workflow metadata