compliance.
Core Solution
The implementation strategy replaces multi-stage OCR pipelines with a single synchronous REST call. The architecture prioritizes type safety, payload simplicity, and immediate business logic integration.
Step 1: Payload Preparation
Document intelligence endpoints typically accept base64-encoded files to avoid multipart form complexity, which simplifies serverless deployment and cross-platform compatibility. The payload requires three core parameters: the encoded file, the MIME type, and the fiscal year. Language specification is optional but recommended for bilingual or Quebec-specific documents.
import { readFileSync } from 'fs';
import { join } from 'path';
function prepareDocumentPayload(filePath: string): { base64: string; mimeType: string } {
const fileBuffer = readFileSync(join(process.cwd(), filePath));
return {
base64: fileBuffer.toString('base64'),
mimeType: 'application/pdf'
};
}
Step 2: API Integration & Request Execution
We wrap the HTTP call in a dedicated service class. This isolates network logic, enforces environment configuration, and provides a clean interface for downstream consumers. The request includes standard authorization headers and a structured JSON body.
const DOC_INTELLIGENCE_BASE = 'https://docusense.stackapi.dev/api/v1/documents';
interface T4RequestPayload {
fileBase64: string;
mimeType: string;
taxYear: number;
language?: 'en' | 'fr';
}
interface T4ResponseData {
taxYear: number;
employerName: string;
payrollAccountNumber: string;
employeeName: string;
socialInsuranceNumber: string;
province: string;
boxes: Record<string, number | null>;
}
export class PayrollExtractor {
private readonly apiKey: string;
constructor(apiKey: string) {
if (!apiKey) throw new Error('API key is required for document extraction');
this.apiKey = apiKey;
}
async extractT4(payload: T4RequestPayload): Promise<T4ResponseData> {
const response = await fetch(`${DOC_INTELLIGENCE_BASE}/t4`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new Error(`Extraction failed: ${response.status} - ${errorBody.message || 'Unknown error'}`);
}
const result = await response.json();
return result.data as T4ResponseData;
}
}
Step 3: Response Validation & Business Logic Integration
Raw API responses should never be consumed directly in business logic. We apply schema validation to guarantee type safety and handle nullable fields explicitly. The following example demonstrates a mortgage pre-qualification workflow that consumes the extracted T4 data.
import { z } from 'zod';
const T4BoxSchema = z.object({
box14: z.number().nullable(),
box16: z.number().nullable(),
box17: z.number().nullable(),
box18: z.number().nullable(),
box22: z.number().nullable(),
box24: z.number().nullable(),
box26: z.number().nullable()
});
const T4FullSchema = z.object({
taxYear: z.number(),
employerName: z.string(),
employeeName: z.string(),
province: z.string(),
boxes: T4BoxSchema
});
async function evaluateIncomeEligibility(
extractor: PayrollExtractor,
filePath: string,
targetLoanAmount: number,
monthlyDebtObligations: number
) {
const { base64, mimeType } = prepareDocumentPayload(filePath);
const rawT4 = await extractor.extractT4({
fileBase64: base64,
mimeType,
taxYear: 2024,
language: 'en'
});
const validatedT4 = T4FullSchema.parse(rawT4);
const annualIncome = validatedT4.boxes.box14;
if (!annualIncome) {
throw new Error('Box 14 (Employment Income) is missing or null');
}
const monthlyIncome = annualIncome / 12;
const gdsRatio = ((monthlyDebtObligations / monthlyIncome) * 100).toFixed(2);
return {
applicant: validatedT4.employeeName,
employer: validatedT4.employerName,
province: validatedT4.province,
annualIncome,
gdsRatio: parseFloat(gdsRatio),
approved: parseFloat(gdsRatio) < 32
};
}
Architecture Decisions & Rationale
- Base64 over Multipart: Multipart uploads require boundary parsing, streaming logic, and often complicate serverless function deployments. Base64 encoding keeps the payload as a single JSON object, simplifying serialization, logging, and retry mechanisms.
- Synchronous REST: Mortgage and payroll workflows typically require immediate feedback. A synchronous call eliminates the complexity of webhook routing, polling loops, and state management required by async document processing pipelines.
- Schema Validation (Zod): API contracts drift. Wrapping responses in a validation layer catches missing fields, type mismatches, or unexpected nulls before they propagate into financial calculations.
- Service Class Encapsulation: Isolating the HTTP client, headers, and error handling into a dedicated class prevents configuration leakage and makes unit testing straightforward.
Pitfall Guide
1. Binary vs Base64 Mismatch
Explanation: Sending raw binary data or forgetting to encode the file results in malformed JSON payloads or API rejection.
Fix: Always convert file buffers to base64 strings before serialization. Validate the encoding step with a simple length check or checksum if processing large batches.
2. Unhandled Nullable Fields
Explanation: Not all T4 boxes are populated for every employee. Assuming numeric values without null checks causes runtime errors in financial calculations.
Fix: Use optional chaining or explicit null guards. Validate against a schema that explicitly allows null for optional boxes like Box 16 or Box 44.
3. Tax Year Drift
Explanation: Hardcoding the tax year in the request payload causes extraction failures when processing historical documents or future-dated slips.
Fix: Dynamically resolve the tax year from file metadata, user input, or a fallback heuristic. Pass it explicitly in the request payload.
4. Concurrency & Rate Limiting
Explanation: Bursting hundreds of extraction requests without backoff triggers HTTP 429 responses and degrades pipeline reliability.
Fix: Implement exponential backoff with jitter. Use a queue-based processor (BullMQ, AWS SQS) for batch operations, and respect the provider's documented rate limits.
5. Cross-Jurisdiction Confusion
Explanation: Quebec employees receive RL-1 slips instead of T4s. Sending an RL-1 to the T4 endpoint yields incomplete or misaligned data.
Fix: Detect province or document type before routing. Use the dedicated /rl1 endpoint for Quebec documents, which returns case-specific fields (A, B, C, E, G, H/I).
6. PII Exposure in Logs
Explanation: Logging full API responses for debugging inadvertently stores Social Insurance Numbers (SIN) in plaintext log aggregators.
Fix: Never log raw responses. Use structured logging with PII redaction middleware. The API masks SINs automatically, but ensure your application layer does not re-expose them.
7. Ignoring Response Schema Evolution
Explanation: API providers occasionally add new fields or adjust numeric precision. Direct object destructuring breaks silently.
Fix: Always validate responses against a versioned schema. Pin API versions if supported, and implement contract tests in your CI/CD pipeline.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-volume mortgage underwriting (>1k docs/month) | Document Intelligence API | Zero maintenance, schema-validated, handles scanned docs natively | $19/month (PRO) + usage |
| Internal payroll reconciliation (<100 docs/month) | Document Intelligence API | Free tier covers testing; rapid integration | $0 (Free tier) |
| Legacy system with strict air-gapped compliance | Custom OCR + On-prem Parser | No external network calls; full data sovereignty | High engineering cost, ongoing maintenance |
| Quebec-specific HR platform | RL-1 API endpoint | Native support for Revenu Québec case fields (A, B, C, E, G, H/I) | $19/month (PRO) |
| Ad-hoc manual processing | Manual entry or lightweight OCR | No infrastructure required; acceptable for low volume | Labor cost scales linearly |
Configuration Template
// src/config/env.ts
import { z } from 'zod';
const envSchema = z.object({
DOC_INTELLIGENCE_API_KEY: z.string().min(1, 'API key is required'),
DOC_INTELLIGENCE_BASE_URL: z.string().url().default('https://docusense.stackapi.dev/api/v1/documents'),
MAX_FILE_SIZE_MB: z.coerce.number().default(10),
REQUEST_TIMEOUT_MS: z.coerce.number().default(15000),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info')
});
export const env = envSchema.parse(process.env);
// src/services/payroll-extractor.ts
import { PayrollExtractor } from './payroll-extractor';
import { env } from '../config/env';
export const payrollExtractor = new PayrollExtractor(env.DOC_INTELLIGENCE_API_KEY);
Quick Start Guide
- Install dependencies:
npm install zod (TypeScript/Node.js environment)
- Set environment variables: Export
DOC_INTELLIGENCE_API_KEY with your provisioned key
- Prepare a test document: Place a T4 PDF in your project root directory
- Run the extraction: Execute the
evaluateIncomeEligibility function with the file path and target parameters
- Verify output: Confirm the returned JSON matches expected box values and GDS ratio calculations
This architecture eliminates the OCR normalization bottleneck, delivers schema-validated payroll data in under ten minutes of setup, and scales predictably across mortgage, accounting, and HR workflows. By treating document extraction as a managed service rather than an infrastructure problem, engineering teams reclaim cycles for core business logic and compliance validation.