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 cre.ParseReport() 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.GenerateReport(). See Submitting Reports via HTTP.
Where does it come from?Another workflow (or system) already ran sender steps: logic → GenerateReport() → HTTP POST. You receive rawReport, context, and signatures in the request body.
What does this guide cover?Step 3 below: cre.ParseReport() 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. cre.ParseReport(): verify signatures and read metadata.
  4. 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 cre.ParseReport() 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 (cre.ParseReport)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 (cre.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 *cre.Report with accessors for workflow ID, owner, execution ID, body, and more.

If verification fails, cre.ParseReport() returns an error (for example, ErrUnknownSigner, ErrWrongSignatureCount, 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.

  1. Save the webhook JSON as test-report-payload.json in your receiver workflow folder.
  2. Register http.Trigger(&http.Config{}) (empty config) for simulation.
  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).

Minimal receiver for simulation

Use an empty HTTP trigger for sim. Set SkipSignatureVerification: true in staging config (or pass it to ParseReportWithConfig). The CLI delivers --http-payload file contents as payload.Input bytes.

config.staging.json:

{
  "skipSignatureVerification": true
}

main.go:

//go:build wasip1

package main

import (
	"encoding/hex"
	"encoding/json"
	"log/slog"

	"github.com/smartcontractkit/cre-sdk-go/capabilities/networking/http"
	"github.com/smartcontractkit/cre-sdk-go/cre"
	"github.com/smartcontractkit/cre-sdk-go/cre/wasm"
)

type Config struct {
	SkipSignatureVerification bool `json:"skipSignatureVerification"`
}

type parsedPayload struct {
	Report     string   `json:"report"`
	Context    string   `json:"context"`
	Signatures []string `json:"signatures"`
}

func InitWorkflow(_ *Config, _ *slog.Logger, _ cre.SecretsProvider) (cre.Workflow[*Config], error) {
	return cre.Workflow[*Config]{
		cre.Handler(http.Trigger(&http.Config{}), run),
	}, nil
}

func run(cfg *Config, runtime cre.Runtime, payload *http.Payload) (bool, error) {
	var parsed parsedPayload
	if err := json.Unmarshal(payload.Input, &parsed); err != nil {
		return false, err
	}

	rawReport, err := hex.DecodeString(parsed.Report)
	if err != nil {
		return false, err
	}
	reportContext, err := hex.DecodeString(parsed.Context)
	if err != nil {
		return false, err
	}
	sigs := make([][]byte, len(parsed.Signatures))
	for i, sigHex := range parsed.Signatures {
		sigs[i], err = hex.DecodeString(sigHex)
		if err != nil {
			return false, err
		}
	}

	report, err := cre.ParseReportWithConfig(runtime, rawReport, sigs, reportContext, cre.ReportParseConfig{
		SkipSignatureVerification: cfg.SkipSignatureVerification,
	})
	if err != nil {
		return false, err
	}

	runtime.Logger().Info("Verified report",
		"workflowId", report.WorkflowID(),
		"executionId", report.ExecutionID(),
		"donId", report.DONID(),
	)

	_ = report.Body()
	return true, nil
}

func main() {
	wasm.NewRunner(cre.ParseJSON[Config]).Run(InitWorkflow)
}

Sim wiring vs full verify

ModeConfigWhat it validates
Wiring / decodeSkipSignatureVerification: true in ParseReportWithConfigJSON + hex decode, metadata accessors, Body()
Full crypto verifyDefault cre.ParseReport() (production registry)Reports from a deployed/production DON. Sim-signed reports fail default verification.
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 in ParseReportWithConfig: logs show metadata and a successful handler return.
  • Full crypto verify: default ParseReport with a production-signed report (not typical for sender-sim → receiver-sim alone).

project.yaml needs ethereum-mainnet RPC for default verify (registry reads).

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.

//go:build wasip1

package main

import (
	"encoding/hex"
	"encoding/json"
	"log/slog"

	"github.com/smartcontractkit/cre-sdk-go/capabilities/networking/http"
	"github.com/smartcontractkit/cre-sdk-go/cre"
	"github.com/smartcontractkit/cre-sdk-go/cre/wasm"
)

type Config struct {
	AuthorizedKey string `json:"authorized_key"`
}

func InitWorkflow(cfg *Config, _ *slog.Logger, _ cre.SecretsProvider) (cre.Workflow[*Config], error) {
	return cre.Workflow[*Config]{
		cre.Handler(http.Trigger(&http.Config{AuthorizedKeys: []*http.AuthorizedKey{{PublicKey: cfg.AuthorizedKey}}}), run),
	}, nil
}

type ParsedPayload struct {
	Report  string   `json:"report"`
	Context string   `json:"context"`
	Sigs    []string `json:"signatures"`
}

func (p *ParsedPayload) Decode() (*DecodedReport, error) {
	report := &DecodedReport{}
	var err error

	if report.Report, err = hex.DecodeString(p.Report); err != nil {
		return nil, err
	}
	if report.Context, err = hex.DecodeString(p.Context); err != nil {
		return nil, err
	}

	report.Sigs = make([][]byte, len(p.Sigs))
	for i, sigHex := range p.Sigs {
		report.Sigs[i], err = hex.DecodeString(sigHex)
		if err != nil {
			return nil, err
		}
	}

	return report, nil
}

type DecodedReport struct {
	Report  []byte
	Context []byte
	Sigs    [][]byte
}

func run(_ *Config, runtime cre.Runtime, payload *http.Payload) (bool, error) {
	parsed := &ParsedPayload{}
	if err := json.Unmarshal(payload.Input, parsed); err != nil {
		return false, err
	}

	decoded, err := parsed.Decode()
	if err != nil {
		return false, err
	}

	report, err := cre.ParseReport(runtime, decoded.Report, decoded.Sigs, decoded.Context)
	if err != nil {
		return false, err
	}

	runtime.Logger().Info("Verified report",
		"workflowId", report.WorkflowID(),
		"executionId", report.ExecutionID(),
	)

	// Use report.Body() for your application logic (ABI-encoded payload from the sender workflow)
	_ = report.Body()

	return true, nil
}

func main() {
	wasm.NewRunner(cre.ParseJSON[Config]).Run(InitWorkflow)
}

What's happening:

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

Report payload format

Receivers need three JSON fields. The JSON key is context even though the SDK field is ReportContext:

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

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

cre.ParseReport()

func ParseReport(runtime Runtime, rawReport []byte, signatures [][]byte, reportContext []byte) (*Report, error)

Parses and verifies a report against the production CRE environment. Use ParseReportWithConfig for custom environments or zones.

*cre.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

cre.ReportParseConfig

config := cre.ReportParseConfig{
    AcceptedZones: []cre.Zone{
        cre.ZoneFromEnvironment(cre.ProductionEnvironment(), 1),
    },
    AcceptedEnvironments: []cre.Environment{cre.ProductionEnvironment()},
    SkipSignatureVerification: false,
}
report, err := cre.ParseReportWithConfig(runtime, rawReport, sigs, reportContext, config)
FieldDescription
AcceptedEnvironmentsRegistry environments to check (defaults to production)
AcceptedZonesRestrict to specific DON IDs within an environment
SkipSignatureVerificationParse header only; call report.VerifySignatures() or VerifySignaturesWithConfig() afterward when ready

Deferred verification

This is a different pattern from the simulation testing use of SkipSignatureVerification. In testing, you skip verification permanently. Here, you parse the header first to inspect metadata (such as WorkflowID() or DONID() for filtering), then call VerifySignatures in a separate step — useful when you want to gate registry reads on workflow identity checks.

If you set SkipSignatureVerification: true in ParseReportWithConfig, parse the header first, then verify:

report, err := cre.ParseReportWithConfig(runtime, rawReport, sigs, reportContext, cre.ReportParseConfig{
    SkipSignatureVerification: true,
})
if err != nil {
    return false, err
}

// Optional: inspect report.WorkflowID(), report.DONID(), etc. before registry reads

if err := report.VerifySignatures(runtime); err != nil {
    return false, err
}

Best practices

  1. Verify before side effects: Call cre.ParseReport() 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

ErrUnknownSigner / invalid signature in sim with fresh webhook JSON

  • Expected when using default ParseReport on a sim-signed report: simulator DON keys do not match mainnet registry signers.
  • For local wiring tests, use SkipSignatureVerification: true. For real crypto verify, use a deployed sender or production-signed reports.

ErrUnknownSigner (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.

Wrong --http-payload path

  • Invoke cre from the project root. Use verify-report-receiver/test-report-payload.json, not a bare filename unless your cwd matches.

Receiver JSON / hex decode error

  • You copied a binary webhook body instead of Pattern 4 JSON with hex fields.

ErrWrongSignatureCount

  • At least f+1 valid signatures are required.

could not read from chain ...

  • Registry read failed (RPC/network). Configure ethereum-mainnet RPC in project.yaml (required for default verify, including sim).

ErrRawReportTooShort

  • rawReport is missing the 109-byte metadata header.

Learn more

Get the latest Chainlink content straight to your inbox.