Submitting Reports via HTTP

This guide is for the sender side: a CRE workflow that creates a signed report and POSTs it to an HTTP endpoint.

What you'll learn:

  • How to use sendReport() to submit reports via HTTP
  • How to write transformation functions for different API formats
  • Best practices for report submission and deduplication

Where this guide fits

QuestionAnswer
What is the report?Output of runtime.report() after your workflow DON reaches consensus: encoded payload + metadata + signatures.
Where does it come from?Inside this workflow: after your logic runs (fetch data, compute, encode). There is no separate "get report" step.
What does this guide cover?Steps 3–4 below: runtime.report(), then sendReport() to your API. Steps 1–2 (trigger and your callback logic) are prerequisites, not the focus here.
Who verifies it?The receiver: your HTTP service or a separate CRE workflow. See Verifying CRE Reports Offchain.

Sender flow in one workflow execution:

  1. Trigger fires (cron, HTTP, …).
  2. Your callback runs (API calls, encoding, etc.).
  3. runtime.report(): DON produces a signed ReportResponse.
  4. sendReport(): format and POST to your URL.

Prerequisites

  • Familiarity with making POST requests
  • Familiarity with runtime.report() (covered below)
  • viem as a direct dependency in the workflow package.json (for ABI encoding in examples)
  • Protobuf HTTP/report types from @chainlink/cre-sdk/pb (SDK_PB.ReportResponse, HTTP_CLIENT_PB.RequestJson), not the main @chainlink/cre-sdk entry

Payload contract (if you verify offchain)

If a receiver uses Verifying CRE Reports Offchain, your HTTP body must expose three fields. The verify guide expects hex without 0x in JSON (field name context, not reportContext):

Your JSON fieldSDK field on ReportResponse
reportrawReport
contextreportContext
signaturessigs[].signature

Use Pattern 4 for offchain verification (hex) or the complete working example. Other patterns are for APIs with different formats, not the default verify examples.

Minimal example (binary)

This example POSTs raw report bytes (application/octet-stream). Use this if your API accepts raw binary. It is not compatible with the verify guide’s JSON receiver — for the sender → verify flow, use the complete working example instead.

Here’s the simplest workflow that generates and submits a report via HTTP:

import { ok, type HTTPSendRequester, type Report } from "@chainlink/cre-sdk"
import type { SDK_PB, HTTP_CLIENT_PB } from "@chainlink/cre-sdk/pb"

type ReportResponse = SDK_PB.ReportResponse
type RequestJson = HTTP_CLIENT_PB.RequestJson

const formatReportSimple = (r: ReportResponse): RequestJson => {
  return {
    url: "https://api.example.com/reports",
    method: "POST",
    body: Buffer.from(r.rawReport).toString("base64"), // Send the raw report bytes (base64-encoded)
    headers: {
      "Content-Type": "application/octet-stream",
    },
    cacheSettings: {
      store: true,
      maxAge: "60s",
    },
  }
}

const submitReport = (sendRequester: HTTPSendRequester, report: Report): { success: boolean } => {
  const response = sendRequester.sendReport(report, formatReportSimple).result()

  if (!ok(response)) {
    throw new Error(`API returned error: status=${response.statusCode}`)
  }

  return { success: true }
}

What's happening here:

  1. formatReportSimple transforms the report into an HTTP request that your API understands
  2. sendRequester.sendReport() calls your transformation function and sends the request
  3. The SDK handles consensus and returns the result

The rest of this guide explains how this works and shows different formatting patterns for various API requirements.

How it works

The report structure

When you call runtime.report(), the SDK creates a ReportResponse containing:

interface ReportResponse {
  rawReport: Uint8Array // Your encoded data + metadata
  reportContext: Uint8Array // Workflow execution context
  sigs: AttributedSignature[] // Cryptographic signatures from DON nodes
  configDigest: Uint8Array // DON configuration identifier
  seqNr: bigint // Sequence number
}

This structure contains everything your API might need:

  • rawReport: The actual report data (always required)
  • sigs: Cryptographic signatures from DON nodes (for verification)
  • reportContext: Metadata about the workflow execution
  • seqNr: Sequence number

The transformation function

Your transformation function tells the SDK how to format the report for your API:

;(reportResponse: ReportResponse) => RequestJson

The SDK calls this function internally:

  1. You pass your transformation function to sendReport()
  2. The SDK calls it with the generated ReportResponse
  3. Your function returns a RequestJson formatted for your API
  4. The SDK sends the request and handles consensus

Why is this needed? Different APIs expect different formats:

  • Some want raw binary data
  • Some want JSON with base64-encoded fields
  • Some want signatures in headers, others in the body

The transformation function gives you complete control over the format.

Formatting patterns

Here are common patterns for formatting reports. Choose the one that matches your API's requirements.

All patterns below use protobuf types and caching settings:

import type { SDK_PB, HTTP_CLIENT_PB } from "@chainlink/cre-sdk/pb"

type ReportResponse = SDK_PB.ReportResponse
type RequestJson = HTTP_CLIENT_PB.RequestJson

Choosing the right pattern

PatternWhen to use
Pattern 1: Report in bodyYour API accepts raw binary data and handles decoding
Pattern 2: Report + signatures in bodyYour API needs everything concatenated in one binary blob
Pattern 3: Report in body, signatures in headersYour API needs signatures separated for easier parsing
Pattern 4: JSON-formatted reportYour API only accepts JSON payloads

Pattern 1: Report in body (simplest)

Use this when your API accepts raw binary data:

const formatReportSimple = (r: ReportResponse): RequestJson => {
  return {
    url: "https://api.example.com/reports",
    method: "POST",
    body: Buffer.from(r.rawReport).toString("base64"), // Just send the report (base64-encoded)
    headers: {
      "Content-Type": "application/octet-stream",
    },
    cacheSettings: {
      store: true,
      maxAge: "60s",
    },
  }
}

Pattern 2: Report + signatures in body

Use this when your API needs everything concatenated in one payload:

const formatReportWithSignatures = (r: ReportResponse): RequestJson => {
  // Concatenate report, context, and all signatures
  const reportBytes = new Uint8Array(r.rawReport)
  const contextBytes = new Uint8Array(r.reportContext)

  let totalLength = reportBytes.length + contextBytes.length
  for (const sig of r.sigs) {
    totalLength += sig.signature.length
  }

  const body = new Uint8Array(totalLength)
  let offset = 0

  // Copy report
  body.set(reportBytes, offset)
  offset += reportBytes.length

  // Copy context
  body.set(contextBytes, offset)
  offset += contextBytes.length

  // Copy all signatures
  for (const sig of r.sigs) {
    body.set(new Uint8Array(sig.signature), offset)
    offset += sig.signature.length
  }

  return {
    url: "https://api.example.com/reports",
    method: "POST",
    body: Buffer.from(body).toString("base64"),
    headers: {
      "Content-Type": "application/octet-stream",
    },
    cacheSettings: {
      store: true,
      maxAge: "60s",
    },
  }
}

Pattern 3: Report in body, signatures in headers

Use this when your API needs signatures separated for easier parsing:

const formatReportWithHeaderSigs = (r: ReportResponse): RequestJson => {
  const headers: { [key: string]: string } = {
    "Content-Type": "application/octet-stream",
  }

  // Add signatures to headers
  r.sigs.forEach((sig, i) => {
    const sigKey = `X-Signature-${i}`
    const signerKey = `X-Signer-ID-${i}`

    headers[sigKey] = Buffer.from(sig.signature).toString("base64")
    headers[signerKey] = sig.signerId.toString()
  })

  return {
    url: "https://api.example.com/reports",
    method: "POST",
    body: Buffer.from(r.rawReport).toString("base64"),
    headers,
    cacheSettings: {
      store: true,
      maxAge: "60s",
    },
  }
}

Pattern 4: JSON-formatted report

Use this when your API only accepts JSON payloads:

interface ReportPayload {
  report: string
  context: string
  signatures: string[]
  configDigest: string
  seqNr: string
}

const formatReportAsJSON = (r: ReportResponse): RequestJson => {
  // Extract signatures
  const sigs = r.sigs.map((sig) => Buffer.from(sig.signature).toString("base64"))

  // Create JSON payload
  const payload: ReportPayload = {
    report: Buffer.from(r.rawReport).toString("base64"),
    context: Buffer.from(r.reportContext).toString("base64"),
    signatures: sigs,
    configDigest: Buffer.from(r.configDigest).toString("base64"),
    seqNr: r.seqNr.toString(),
  }

  const bodyBytes = new TextEncoder().encode(JSON.stringify(payload))

  return {
    url: "https://api.example.com/reports",
    method: "POST",
    body: Buffer.from(bodyBytes).toString("base64"),
    headers: {
      "Content-Type": "application/json",
    },
    cacheSettings: {
      store: true,
      maxAge: "60s",
    },
  }
}

Pattern 4 for offchain verification (hex)

Use this variant when testing the Verifying CRE Reports Offchain receiver in simulation. The verify examples decode hex without a 0x prefix; the receiver adds 0x when calling hexToBytes.

Pattern 4 in the block above uses base64 fields. Base64 sender output does not match the verify guide’s hex decoder without changes.

import type { SDK_PB, HTTP_CLIENT_PB } from "@chainlink/cre-sdk/pb"
import { bytesToHex } from "viem"

type ReportResponse = SDK_PB.ReportResponse
type RequestJson = HTTP_CLIENT_PB.RequestJson

const formatReportAsJSONHex =
  (config: { apiUrl: string }) =>
  (r: ReportResponse): RequestJson => {
    const payload = {
      report: bytesToHex(r.rawReport).slice(2),
      context: bytesToHex(r.reportContext).slice(2),
      signatures: r.sigs.map((sig) => bytesToHex(sig.signature).slice(2)),
    }
    const bodyBytes = new TextEncoder().encode(JSON.stringify(payload))

    return {
      url: config.apiUrl,
      method: "POST",
      body: Buffer.from(bodyBytes).toString("base64"),
      headers: {
        "Content-Type": "application/json",
      },
      cacheSettings: {
        store: true,
        maxAge: "60s",
      },
    }
  }

The body field is the UTF-8 JSON string, base64-encoded for the protobuf bytes field (same pattern as other JSON POST bodies in TypeScript workflows).

Understanding cacheSettings for reports

You'll notice that all the patterns above include cacheSettings. This is critical for report submissions, just like it is for POST requests.

For a complete explanation of how cacheSettings works in general, see Understanding CacheSettings behavior in the HTTP Client reference.

Why use cacheSettings?

When a workflow executes, all nodes in the DON attempt to send the report to your API. Without caching, your API would receive multiple identical submissions (one from each node). cacheSettings prevents this by having the first node cache the response, which other nodes can reuse.

Why are cache hits limited for reports?

Unlike regular POST requests where caching can be very effective, reports have a more limited cache effectiveness due to signature variance:

  1. Each DON node generates its own unique cryptographic signature for the report
  2. These signatures are part of the ReportResponse structure
  3. When nodes construct the HTTP request body (whether concatenating signatures or including them in headers), the signatures differ

In practice: Even though cache hits are limited, you should still include cacheSettings to prevent worst-case scenarios where all nodes hit your API simultaneously.

The real solution: API-side deduplication

Because caching alone cannot prevent all duplicate submissions, your receiving API must implement its own deduplication logic:

  • Use the hash of the report (keccak256(rawReport)) as the unique identifier
  • Store this hash when processing a report
  • Reject any subsequent submissions with the same hash

This approach is reliable because the rawReport is identical across all nodes—only the signatures vary.

Generating reports for HTTP submission

Before you can submit a report via HTTP, you need to generate it using runtime.report(). This creates a cryptographically signed report from your encoded data.

Basic pattern:

import { hexToBase64, type Runtime } from "@chainlink/cre-sdk"
import { encodeAbiParameters, parseAbiParameters } from "viem"

// Step 1: Encode your data using Viem
const encodedValue = encodeAbiParameters(parseAbiParameters("uint256 value"), [123456789n])

// Step 2: Generate the signed report
const report = runtime
  .report({
    encodedPayload: hexToBase64(encodedValue),
    encoderName: "evm",
    signingAlgo: "ecdsa",
    hashingAlgo: "keccak256",
  })
  .result()

// Step 3: Submit via HTTP (covered in next section)

The runtime.report() method works the same way whether you're encoding a single value or a struct—just use Viem's encodeAbiParameters() with the appropriate ABI types. For detailed examples on encoding single values, structs, and complex types, see the Writing Data Onchain guide.

Use the high-level httpClient.sendRequest() pattern with sendRequester.sendReport():

import {
  HTTPClient,
  consensusIdenticalAggregation,
  ok,
  type Runtime,
  type HTTPSendRequester,
  type Report,
} from "@chainlink/cre-sdk"

interface SubmitResponse {
  success: boolean
}

const submitReportViaHTTP = (
  runtime: Runtime<Config>,
  sendRequester: HTTPSendRequester,
  report: Report
): SubmitResponse => {
  const response = sendRequester.sendReport(report, formatReportSimple).result()

  if (!ok(response)) {
    throw new Error(`API returned error: status=${response.statusCode}`)
  }

  runtime.log(`Report submitted successfully, status: ${response.statusCode}`)
  return { success: true }
}

// In your trigger callback
const onCronTrigger = (runtime: Runtime<Config>): MyResult => {
  const httpClient = new HTTPClient()

  // Assume 'report' was generated earlier in your workflow

  // Call the submission function
  const result = httpClient
    .sendRequest(
      runtime,
      (sendRequester: HTTPSendRequester) => submitReportViaHTTP(runtime, sendRequester, report),
      consensusIdenticalAggregation<SubmitResponse>()
    )()
    .result()

  return {}
}

Complete working example

This example shows a workflow that:

  1. Generates a report from a single value
  2. Submits it to an HTTP API as Pattern 4 JSON with hex fields (compatible with the verify guide’s receiver sim loop)
  3. Uses config.apiUrl from your target config file

Add viem to package.json. Register handlers with handler() from @chainlink/cre-sdk (not cron.handler()).

import {
  CronCapability,
  HTTPClient,
  Runner,
  handler,
  consensusIdenticalAggregation,
  hexToBase64,
  ok,
  type Runtime,
  type CronPayload,
  type HTTPSendRequester,
  type Report,
} from "@chainlink/cre-sdk"
import type { SDK_PB, HTTP_CLIENT_PB } from "@chainlink/cre-sdk/pb"
import { encodeAbiParameters, parseAbiParameters, bytesToHex } from "viem"

type ReportResponse = SDK_PB.ReportResponse
type RequestJson = HTTP_CLIENT_PB.RequestJson

interface Config {
  apiUrl: string
  schedule: string
}

interface SubmitResponse {
  success: boolean
}

type MyResult = Record<string, never>

const initWorkflow = (config: Config) => {
  const cron = new CronCapability()
  return [handler(cron.trigger({ schedule: config.schedule }), onCronTrigger)]
}

const formatReportForMyAPI =
  (config: Config) =>
  (r: ReportResponse): RequestJson => {
    const payload = {
      report: bytesToHex(r.rawReport).slice(2),
      context: bytesToHex(r.reportContext).slice(2),
      signatures: r.sigs.map((sig) => bytesToHex(sig.signature).slice(2)),
    }
    const bodyBytes = new TextEncoder().encode(JSON.stringify(payload))

    return {
      url: config.apiUrl,
      method: "POST",
      body: Buffer.from(bodyBytes).toString("base64"),
      headers: {
        "Content-Type": "application/json",
        "X-Report-SeqNr": r.seqNr.toString(), // optional metadata for your API
      },
      cacheSettings: {
        store: true,
        maxAge: "60s",
      },
    }
  }

const submitReportViaHTTP = (
  runtime: Runtime<Config>,
  sendRequester: HTTPSendRequester,
  report: Report,
  config: Config
): SubmitResponse => {
  runtime.log(`Submitting report to API: ${config.apiUrl}`)

  const response = sendRequester.sendReport(report, formatReportForMyAPI(config)).result()

  runtime.log(`Report submitted - status: ${response.statusCode}, bodyLength: ${response.body.length}`)

  if (!ok(response)) {
    const bodyText = new TextDecoder().decode(response.body)
    throw new Error(`API error: status=${response.statusCode}, body=${bodyText}`)
  }

  return { success: true }
}

const onCronTrigger = (runtime: Runtime<Config>, _payload: CronPayload): MyResult => {
  const myValue = 123456789n
  runtime.log(`Generating report with value: ${myValue}`)

  const encodedValue = encodeAbiParameters(parseAbiParameters("uint256 value"), [myValue])

  const report = runtime
    .report({
      encodedPayload: hexToBase64(encodedValue),
      encoderName: "evm",
      signingAlgo: "ecdsa",
      hashingAlgo: "keccak256",
    })
    .result()

  runtime.log("Report generated successfully")

  const httpClient = new HTTPClient()

  const submitResult = httpClient
    .sendRequest(
      runtime,
      (sendRequester: HTTPSendRequester) => submitReportViaHTTP(runtime, sendRequester, report, runtime.config),
      consensusIdenticalAggregation<SubmitResponse>()
    )()
    .result()

  runtime.log(`Workflow completed successfully, submitted: ${submitResult.success}`)
  return {}
}

export async function main() {
  const runner = await Runner.newRunner<Config>()
  await runner.run(initWorkflow)
}

Configuration file (config.json)

{
  "apiUrl": "https://webhook.site/your-unique-id",
  "schedule": "0 * * * *"
}

Testing with webhook.site

  1. Go to webhook.site and get a unique URL
  2. Update config.json (or config.staging.json) with your webhook URL in apiUrl
  3. From the CRE project root, run the simulation:
    cre workflow simulate my-workflow --target staging-settings
    
  4. On webhook.site, open the request Content tab. You should see JSON with report, context, and signatures (hex strings). Use that JSON to test a receiver workflow in Verifying CRE Reports Offchain.

Next step: verify on the receiver

The sender does not validate the report for the receiver. After submission, the ingesting side must verify signatures before trusting the payload. See Verifying CRE Reports Offchain.

Advanced: Low-level pattern

For complex scenarios where you need more control, use clientCapability.sendReport() with runtime.runInNodeMode():

import {
  HTTPClient,
  consensusIdenticalAggregation,
  ok,
  type Runtime,
  type NodeRuntime,
  type Report,
} from "@chainlink/cre-sdk"

const onCronTrigger = (runtime: Runtime<Config>): MyResult => {
  // Assume 'report' was generated earlier

  const result = runtime
    .runInNodeMode((nodeRuntime: NodeRuntime<Config>) => {
      const httpClient = new HTTPClient()

      const response = httpClient.sendReport(nodeRuntime, report, formatReportSimple).result()

      if (!ok(response)) {
        throw new Error(`API error: ${response.statusCode}`)
      }

      return { success: true }
    }, consensusIdenticalAggregation<SubmitResponse>())()
    .result()

  return {}
}

Best practices

  1. Always use cacheSettings: Include caching in every transformation function to prevent worst-case duplicate submission scenarios
  2. Implement API-side deduplication: Your receiving API must implement deduplication using the hash of the report (keccak256(rawReport)) to detect and reject duplicate submissions
  3. Verify on the receiver: The sender does not validate the report; your API or a receiver CRE workflow must verify before trusting payload data
  4. Match your API's format exactly: Study your API's documentation to understand the expected format (binary, JSON, headers, etc.)
  5. Handle errors gracefully: Check HTTP status codes and provide meaningful error messages

Troubleshooting

"failed to send report" error

  • Verify your API URL is correct and accessible
  • Check that your transformation function returns a valid RequestJson
  • Ensure your API can handle binary data if you're sending raw bytes (base64-encoded)

API returns 400/422 errors

  • Your report format likely doesn't match what your API expects
  • Check if your API expects base64 encoding, JSON wrapping, or specific headers

TypeScript compile errors on the complete example

  • Use handler(cron.trigger(...), fn) from @chainlink/cre-sdk, not cron.handler()
  • Import ReportResponse / RequestJson from @chainlink/cre-sdk/pb
  • Pass runtime from the trigger callback into helper functions (no global runtime)
  • Add viem as a direct workflow dependency

Learn more

Get the latest Chainlink content straight to your inbox.