← All recipes
IntegrationIntermediate
Sign streaming AI responses
Streaming UI looks great but creates a chicken-and-egg for provenance: you want to sign the final output, but it doesn't exist until the stream ends. This recipe buffers chunks, signs the final assembly, and returns the receipt alongside.
The two questions to answer first
- When do you sign? Answer: after the stream completes. Signing per-chunk creates fragmented receipts that don't represent the output the user actually saw.
- How does the client get the receipt ID? Answer: append it as a final SSE event after the content stream ends, or as a trailer/footer in a custom protocol.
Anthropic SDK (Next.js route handler)
import Anthropic from '@anthropic-ai/sdk'
import { CertNode } from '@certnode/sdk'
const claude = new Anthropic()
const cert = new CertNode({ apiKey: process.env.CERTNODE_API_KEY! })
export async function POST(req: Request) {
const { messages } = await req.json()
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const claudeStream = await claude.messages.stream({
model: 'claude-opus-4-7',
max_tokens: 4096,
messages,
})
// Buffer chunks as they arrive, but ALSO stream them to the client
let assembled = ''
for await (const chunk of claudeStream) {
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
assembled += chunk.delta.text
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'text', text: chunk.delta.text })}\n\n`))
}
}
// Stream is done — sign the assembled output, then send the receipt
const signed = await cert.signAIOutput({
output: assembled,
model: 'claude-opus-4-7',
provider: 'anthropic',
})
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'receipt',
receiptId: signed.receiptId,
verifyUrl: signed.verifyUrl,
})}\n\n`))
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream' },
})
}OpenAI SDK (same pattern)
import OpenAI from 'openai'
import { CertNode } from '@certnode/sdk'
const openai = new OpenAI()
const cert = new CertNode({ apiKey: process.env.CERTNODE_API_KEY! })
export async function POST(req: Request) {
const { messages } = await req.json()
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
messages,
stream: true,
})
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
let assembled = ''
for await (const chunk of completion) {
const delta = chunk.choices[0]?.delta?.content
if (delta) {
assembled += delta
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'text', text: delta })}\n\n`))
}
}
const signed = await cert.signAIOutput({
output: assembled,
model: 'gpt-4o',
provider: 'openai',
})
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'receipt',
receiptId: signed.receiptId,
verifyUrl: signed.verifyUrl,
})}\n\n`))
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream' },
})
}Client-side: capturing the receipt
const response = await fetch('/api/stream-with-receipt', {
method: 'POST',
body: JSON.stringify({ messages }),
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let receiptId: string | null = null
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value)
for (const line of text.split('\n')) {
if (!line.startsWith('data: ')) continue
const payload = line.slice(6)
if (payload === '[DONE]') continue
const event = JSON.parse(payload)
if (event.type === 'text') {
// Render the streaming chunk to the UI
appendToOutput(event.text)
} else if (event.type === 'receipt') {
// Capture the receipt at end of stream
receiptId = event.receiptId
showVerifyLink(event.verifyUrl)
}
}
}Failure modes to handle
- — Stream aborts mid-flight: catch the exception, decide whether to sign the partial output (and label it
contentType: 'ai_output'with a metadata flag) or skip signing entirely. Signing partials produces receipts that may not represent any output the user saw. - — CertNode API down (rare, but plan for it): queue the signing for retry rather than failing the user-facing request. The streamed output is already delivered; signing is async observability.
- — Cap hit mid-stream: catch the 402 from CertNode and surface a soft warning in the receipt event (
{ type: "receipt", error: "cap_exceeded" }). Don't fail the user-visible stream.