Document analysis
Upload a PDF, wait for the memory.updated webhook, then extract typed data points with MemorySearch.
This example uploads a PDF as a FILE memory, waits until Fency has indexed it, then extracts user-defined data points. Each data point is a MemorySearch query over that one document. A StructuredChatCompletion task then merges the excerpts into typed JSON (string, string list, boolean, or number).
- Live demo: react.fency.ai/document-analysis
- GitHub folder: fency-react-examples/app/document-analysis
Prerequisites
You need a publishable key for the React SDK and a secret key for your server routes. This example uses sessions to authenticate the client.
The example uses Clerk for user authentication. That is part of the app shell and not specific to Fency.
This example also requires a PostgreSQL database to persist uploaded documents and the SEMANTIC memory type id. After each upload, Fency indexes the PDF asynchronously and sends a memory.updated webhook. Register the webhook in the dashboard and store the generated secret as DOCUMENT_ANALYSIS_WEBHOOK_SECRET. Fency cannot reach localhost, so expose the app with a tunnel when you run locally.
API routes
Each route under api/ maps to one operation in the API reference, except the webhook and document-status routes which persist Fency events in Postgres.
app/document-analysis/api/
create-stream-session/ POST /v1/sessions (createStream)
create-memory-search-agent-task-session/ POST /v1/sessions (createAgentTask MEMORY_SEARCH)
create-structured-agent-task-session/ POST /v1/sessions (createAgentTask STRUCTURED_CHAT_COMPLETION)
upload-document/ POST /v1/memories + POST /v1/memories/{id}/uploads + S3
fency-webhook/ user.test + memory.updated (signature verification)
get-document-status/ app status check (Drizzle)
list-documents/ app document list (Drizzle)Upload a PDF as a FILE memory
The upload route creates a SEMANTIC memory type if needed, creates an empty FILE memory, asks Fency for a presigned S3 POST, and uploads the PDF. See Uploading files for memories.
Create a SEMANTIC memory type at api/upload-document/createFencyMemoryType.ts:
import 'server-only'
import { z } from 'zod'
import { fencyJson } from './fencyJson'
const createdMemoryTypeSchema = z.object({
id: z.string().optional(),
error: z.object({ message: z.string().optional() }).optional(),
})
export async function createFencyMemoryType(body: {
name: string
description: string
type: 'SEMANTIC'
}) {
return fencyJson('/v1/memory-types', createdMemoryTypeSchema, {
method: 'POST',
body: JSON.stringify(body),
})
}Create the empty FILE memory at api/upload-document/createFencyFileMemory.ts. The response field is id:
import 'server-only'
import { z } from 'zod'
import { fencyJson } from './fencyJson'
const createdMemorySchema = z.object({
id: z.string(),
})
export async function createFencyFileMemory(body: {
memoryTypeId: string
title: string
metadata: Record<string, string>
}) {
const { ok, status, data } = await fencyJson(
'/v1/memories',
createdMemorySchema,
{
method: 'POST',
body: JSON.stringify({
memoryTypeId: body.memoryTypeId,
sourceType: 'FILE',
title: body.title,
metadata: body.metadata,
}),
},
)
if (!ok) {
throw new Error(`Failed to create file memory (${status}).`)
}
return data
}Get the presigned upload at api/upload-document/createFencyMemoryUpload.ts:
import 'server-only'
import { z } from 'zod'
import { fencyJson } from './fencyJson'
const awsS3PostRequestSchema = z.object({
amzDate: z.string(),
amzSignature: z.string(),
amzAlgorithm: z.string(),
amzCredential: z.string(),
policy: z.string(),
key: z.string(),
uploadUrl: z.string(),
sessionToken: z.string(),
})
const createdUploadSchema = z.object({
awsS3PostRequest: awsS3PostRequestSchema,
})
export async function createFencyMemoryUpload(
memoryId: string,
body: {
fileName: string
fileSize: number
mimeType: string
},
) {
const { ok, status, data } = await fencyJson(
`/v1/memories/${memoryId}/uploads`,
createdUploadSchema,
{
method: 'POST',
body: JSON.stringify(body),
},
)
if (!ok) {
throw new Error(`Failed to create memory upload (${status}).`)
}
return data.awsS3PostRequest
}Post the file to S3 at api/upload-document/uploadFileToS3.ts. The file field must be last:
import 'server-only'
export async function uploadFileToS3(
awsS3PostRequest: {
key: string
policy: string
amzAlgorithm: string
amzCredential: string
amzDate: string
amzSignature: string
sessionToken: string
uploadUrl: string
},
file: File,
) {
const formData = new FormData()
formData.append('key', awsS3PostRequest.key)
formData.append('policy', awsS3PostRequest.policy)
formData.append('x-amz-algorithm', awsS3PostRequest.amzAlgorithm)
formData.append('x-amz-credential', awsS3PostRequest.amzCredential)
formData.append('x-amz-date', awsS3PostRequest.amzDate)
formData.append('x-amz-signature', awsS3PostRequest.amzSignature)
formData.append('x-amz-security-token', awsS3PostRequest.sessionToken)
formData.append('file', file)
const response = await fetch(awsS3PostRequest.uploadUrl, {
method: 'POST',
body: formData,
})
if (!response.ok) {
throw new Error(`Failed to upload file to S3 (${response.status}).`)
}
}The upload route at api/upload-document/route.ts wires the three calls together and stores the document as EMPTY until the webhook arrives:
import { NextResponse } from 'next/server'
import { getAuthorizedUserId } from '../../../auth'
import {
DOCUMENT_TAG_KEY,
DOCUMENT_TAG_VALUE,
} from '../../documentAnalysisConstants'
import { documentRepository } from '../../db/documentRepository'
import { createFencyFileMemory } from './createFencyFileMemory'
import { createFencyMemoryUpload } from './createFencyMemoryUpload'
import { ensureDocumentMemoryType } from './ensureDocumentMemoryType'
import { uploadFileToS3 } from './uploadFileToS3'
export async function POST(request: Request) {
const userId = await getAuthorizedUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const formData = await request.formData()
const file = formData.get('file')
if (!(file instanceof File)) {
throw new Error('Expected a file field named file.')
}
if (file.type !== 'application/pdf') {
return NextResponse.json(
{ error: 'Upload a PDF file.' },
{ status: 400 },
)
}
const memoryTypeId = await ensureDocumentMemoryType()
const memory = await createFencyFileMemory({
memoryTypeId,
title: file.name,
metadata: {
userId,
[DOCUMENT_TAG_KEY]: DOCUMENT_TAG_VALUE,
},
})
const awsS3PostRequest = await createFencyMemoryUpload(memory.id, {
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
})
await uploadFileToS3(awsS3PostRequest, file)
const document = await documentRepository.insert({
userId,
fencyMemoryId: memory.id,
fileName: file.name,
contentStatus: 'EMPTY',
})
return NextResponse.json({
id: document.id,
fileName: document.fileName,
contentStatus: document.contentStatus,
contentParts: document.contentParts,
})
}Webhook: wait for SYNCHRONIZED
There is no memory.synchronized event. After Fency indexes the file, it sends memory.updated. The entity.contentStatus field is EMPTY, SYNCHRONIZING, SYNCHRONIZED, or SYNCHRONIZATION_ERROR.
Verify the x-fency-signature header as described in Webhooks. Read the raw body first. The helper lives at api/fency-webhook/verifyFencySignature.ts:
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyFencySignature(payload: string, header: string | null) {
const secret = process.env.DOCUMENT_ANALYSIS_WEBHOOK_SECRET
if (!secret) {
throw new Error('DOCUMENT_ANALYSIS_WEBHOOK_SECRET is not defined.')
}
if (!header) {
throw new Error('Missing x-fency-signature header.')
}
const expected = Buffer.from(
`sha256=${createHmac('sha256', secret).update(payload).digest('hex')}`,
'utf8',
)
const received = Buffer.from(header, 'utf8')
if (
expected.length !== received.length ||
!timingSafeEqual(expected, received)
) {
throw new Error('Signature invalid')
}
}The webhook route at api/fency-webhook/route.ts is public. A dashboard test send is a user.test event (message: "Hello world!") with no entity; acknowledge it and return 200. On memory.updated it writes contentStatus for the matching memory. Unknown memory ids are ignored:
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { contentStatusSchema } from '../../analyzedDocument'
import { documentRepository } from '../../db/documentRepository'
import { verifyFencySignature } from './verifyFencySignature'
const eventTypeSchema = z.object({
type: z.string(),
})
const userTestEventSchema = z.object({
type: z.literal('user.test'),
message: z.string(),
})
const memoryUpdatedEventSchema = z.object({
type: z.literal('memory.updated'),
entity: z.object({
id: z.string(),
contentStatus: contentStatusSchema,
contentParts: z.number().nullable().optional(),
}),
})
export async function POST(request: Request) {
const raw = await request.text()
verifyFencySignature(raw, request.headers.get('x-fency-signature'))
const body: unknown = JSON.parse(raw)
const { type } = eventTypeSchema.parse(body)
if (type === 'user.test') {
const event = userTestEventSchema.parse(body)
return NextResponse.json({ ok: true, message: event.message })
}
if (type !== 'memory.updated') {
return NextResponse.json({ ok: true })
}
const event = memoryUpdatedEventSchema.parse(body)
await documentRepository.updateContentStatus(
event.entity.id,
event.entity.contentStatus,
event.entity.contentParts ?? null,
)
return NextResponse.json({ ok: true })
}The browser polls api/get-document-status until contentStatus is SYNCHRONIZED. Analysis is blocked until then.
Server session routes
The example defines three session routes. Each route is standalone so you can read a single file and see the full POST /v1/sessions call.
Create the stream session route at api/create-stream-session/route.ts:
import { NextResponse } from 'next/server'
import { getAuthorizedUserId } from '../../../auth'
export async function POST() {
const userId = await getAuthorizedUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const secretKey = process.env.FENCY_SECRET_KEY
if (!secretKey) {
throw new Error('FENCY_SECRET_KEY is not defined.')
}
const response = await fetch('https://api.fency.ai/v1/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${secretKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ createStream: {} }),
})
const data = await response.json()
return NextResponse.json(data, { status: response.status })
}Create the MemorySearch session at api/create-memory-search-agent-task-session/route.ts. Guard rails pin the task to the one uploaded memory:
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getAuthorizedUserId } from '../../../auth'
import { DOCUMENT_MEMORY_TYPE_NAME } from '../../documentAnalysisConstants'
import { documentRepository } from '../../db/documentRepository'
import { memoryTypeRepository } from '../../db/memoryTypeRepository'
import { buildDocumentGuardRails } from './buildDocumentGuardRails'
const bodySchema = z.object({
documentId: z.string(),
})
export async function POST(request: Request) {
const userId = await getAuthorizedUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { documentId } = bodySchema.parse(await request.json())
const document = await documentRepository.findById(documentId)
if (!document) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
if (document.userId !== userId) {
return NextResponse.json(
{ error: 'Document does not belong to this user' },
{ status: 403 },
)
}
if (document.contentStatus !== 'SYNCHRONIZED') {
return NextResponse.json(
{ error: 'Document is not synchronized yet.' },
{ status: 409 },
)
}
const memoryType = await memoryTypeRepository.findByName(
DOCUMENT_MEMORY_TYPE_NAME,
)
if (!memoryType) {
throw new Error('AnalyzedDocument memory type is not set up.')
}
const secretKey = process.env.FENCY_SECRET_KEY
if (!secretKey) {
throw new Error('FENCY_SECRET_KEY is not defined.')
}
const response = await fetch('https://api.fency.ai/v1/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${secretKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
createAgentTask: {
taskType: 'MEMORY_SEARCH',
metadata: { userId },
guardRails: buildDocumentGuardRails(
memoryType.fencyMemoryTypeId,
document.fencyMemoryId,
),
},
}),
})
const data = await response.json()
return NextResponse.json(data, { status: response.status })
}The guard rails helper at api/create-memory-search-agent-task-session/buildDocumentGuardRails.ts:
export function buildDocumentGuardRails(
memoryTypeId: string,
memoryId: string,
) {
return {
memoryTypes: [
{
memoryTypeId,
memoryIds: [memoryId],
},
],
}
}Create the structured extraction session at api/create-structured-agent-task-session/route.ts:
import { NextResponse } from 'next/server'
import { getAuthorizedUserId } from '../../../auth'
export async function POST() {
const userId = await getAuthorizedUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const secretKey = process.env.FENCY_SECRET_KEY
if (!secretKey) {
throw new Error('FENCY_SECRET_KEY is not defined.')
}
const response = await fetch('https://api.fency.ai/v1/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${secretKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
createAgentTask: {
taskType: 'STRUCTURED_CHAT_COMPLETION',
metadata: { userId },
},
}),
})
const data = await response.json()
return NextResponse.json(data, { status: response.status })
}Data points and JSON schema
MEMORY_SEARCH returns ranked chunks, not typed JSON. The UI lets the user add rows of label, type, and description. Those rows become a Zod object, then a JSON Schema string for StructuredChatCompletion.
The data point types live in dataPoint.ts:
export const dataPointTypes = [
'string',
'stringList',
'boolean',
'number',
] as const
export type DataPointType = (typeof dataPointTypes)[number]
export type DataPoint = {
key: string
label: string
type: DataPointType
description: string
}
export const defaultDataPoints: DataPoint[] = [
{
key: 'title',
label: 'Title',
type: 'string',
description: 'The document title or heading',
},
{
key: 'parties',
label: 'Parties',
type: 'stringList',
description: 'Named people or organizations in the document',
},
{
key: 'isSigned',
label: 'Is signed',
type: 'boolean',
description: 'Whether the document appears signed',
},
{
key: 'amount',
label: 'Amount',
type: 'number',
description: 'A primary monetary amount if present',
},
]Build the schema at runtime in dataPointJsonSchema.ts:
import { z } from 'zod'
import type { DataPoint } from './dataPoint'
function fieldForDataPoint(dataPoint: DataPoint) {
const description = dataPoint.description || dataPoint.label
switch (dataPoint.type) {
case 'string':
return z.string().nullish().describe(description)
case 'stringList':
return z.array(z.string()).nullish().describe(description)
case 'boolean':
return z.boolean().nullish().describe(description)
case 'number':
return z.number().nullish().describe(description)
}
}
export function dataPointZodSchema(dataPoints: DataPoint[]) {
const shape: Record<string, ReturnType<typeof fieldForDataPoint>> = {}
for (const dataPoint of dataPoints) {
shape[dataPoint.key] = fieldForDataPoint(dataPoint)
}
return z.object(shape)
}
export function dataPointJsonSchema(dataPoints: DataPoint[]) {
return JSON.stringify(z.toJSONSchema(dataPointZodSchema(dataPoints)))
}Client provider and analysis pipeline
Both session routes respond with a client token. Parse that response with a
Zod schema in sessionClientTokenSchema.ts so a bad shape throws:
import { z } from 'zod'
export const sessionClientTokenSchema = z.object({
clientToken: z.string(),
})Load the Fency client and wrap your component tree with FencyProvider in page.tsx:
'use client'
import { loadFency } from '@fencyai/js'
import { FencyProvider } from '@fencyai/react'
import { DocumentAnalyzer } from './components/DocumentAnalyzer'
import { sessionClientTokenSchema } from './sessionClientTokenSchema'
const fency = loadFency({
publishableKey: process.env.NEXT_PUBLIC_FENCY_PUBLISHABLE_KEY!,
})
async function fetchCreateStreamClientToken() {
const res = await fetch('/document-analysis/api/create-stream-session', {
method: 'POST',
})
if (!res.ok) {
throw new Error('Failed to create stream session')
}
const { clientToken } = sessionClientTokenSchema.parse(await res.json())
return { clientToken }
}
export default function DocumentAnalysisPage() {
return (
<FencyProvider
fency={fency}
fetchCreateStreamClientToken={fetchCreateStreamClientToken}
>
<DocumentAnalyzer />
</FencyProvider>
)
}components/DocumentUploader.tsx uses an Uppy drop field so you can drop or pick a PDF. Uppy posts file to api/upload-document.
hooks/useDocumentAnalysis.ts runs one MemorySearch per data point, then one StructuredChatCompletion. Each MemorySearch session is scoped to the selected document. The analysis list is one row per data point; click a row to open AgentTaskProgress for that search in components/DataPointSearchProgressModal.tsx. While StructuredChatCompletion runs, AgentTaskProgress also renders under the Analyze button.
const searchResult = await createAgentTask(
{
type: 'MemorySearch',
query,
model: ANALYSIS_MODEL,
language: 'en',
chunkLimit: 5,
contextExpansion: { before: 1, after: 1 },
},
{
fetchCreateAgentTaskClientToken: () =>
fetchCreateMemorySearchClientToken(documentId),
onTaskRegistered: (task) => {
setDataPointSearches((prev) => [
...prev,
{
taskKey: task.taskKey,
label: dataPoint.label,
query,
},
])
},
},
)const structResult = await createAgentTask(
{
type: 'StructuredChatCompletion',
messages: [
{
role: 'SYSTEM',
content:
'Merge facts from the labelled document search excerpts into the JSON schema. Use only information clearly supported by the excerpts. Leave a field null when it cannot be inferred.',
},
{
role: 'USER',
content: aggregated.join('\n\n---\n\n'),
},
],
jsonSchema,
model: ANALYSIS_MODEL,
temperature: 0.1,
},
{
fetchCreateAgentTaskClientToken: fetchCreateStructuredClientToken,
onTaskRegistered: (task) => {
setStructuringTaskKey(task.taskKey)
},
},
)The completed MemorySearch event includes items with matchingChunk and surrounding chunks (page numbers and MATCH / CONTEXT_BEFORE / CONTEXT_AFTER). Those excerpts become the user message for structured extraction. Parse event.response.jsonResponse with the same Zod schema.
Running locally
Clone the repository and install dependencies:
git clone https://github.com/fencyai/fency-react-examples.git
cd fency-react-examples
npm installCopy .env.example to .env.local and add your Fency keys:
cp .env.example .env.localSet the keys in .env.local:
FENCY_SECRET_KEY=sk_...
NEXT_PUBLIC_FENCY_PUBLISHABLE_KEY=pk_...
DATABASE_URL=postgresql://fency:fency@127.0.0.1:5433/fency_react_examples
DOCUMENT_ANALYSIS_WEBHOOK_SECRET=whsec_...Start Postgres and apply migrations:
docker compose up -d
npm run db:migrateInitialize Clerk authentication (provisions a development app):
npx -y clerk@latest init --keyless -yStart the development server and a public tunnel so Fency can deliver webhooks:
npm run dev
cloudflared tunnel --url http://localhost:3000Create a webhook in the Fency dashboard pointing at https://<tunnel-host>/document-analysis/api/fency-webhook. Copy the generated secret into DOCUMENT_ANALYSIS_WEBHOOK_SECRET.
Open http://localhost:3000/document-analysis. Sign up from the header, upload a PDF, wait until the document is ready, then run analysis.