Integration Guide
This guide walks through building a production-ready integration with ezylegal. We'll cover architecture, implementation patterns, and common pitfalls.
Overview
A complete ezylegal integration typically involves:
- Session Management - Creating and tracking sessions
- Webhook Processing - Handling real-time events
- Data Storage - Persisting form data
- Error Handling - Graceful failure recovery
Architecture
Recommended Architecture
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Your App │───▶│ ezylegal │───▶│ Webhook │
│ (Sessions) │ │ API │ │ Endpoint │
└─────────────────┘ └─────────────────┘ └────────┬────────┘
│ │
│ ▼
│ ┌─────────────────┐
│ │ Job Queue │
│ │ (Processing) │
│ └────────┬────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Database │
│ (Sessions, Form Data, Webhook Events) │
└─────────────────────────────────────────────────────────────┘
Key Principles
- Asynchronous Processing - Use a job queue for webhook processing
- Idempotent Handlers - Handle duplicate webhooks gracefully
- Reliable Storage - Persist data before acknowledging webhooks
- Monitoring - Track webhook delivery and processing
Database Schema
Sessions Table
CREATE TABLE ezyfamily_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id VARCHAR(255) UNIQUE NOT NULL, -- ezylegal session ID
reference_id VARCHAR(255), -- Your internal reference
form_type VARCHAR(50) NOT NULL,
customer_email VARCHAR(255) NOT NULL,
customer_name VARCHAR(255),
status VARCHAR(50) NOT NULL DEFAULT 'pending',
session_url TEXT,
webhook_url TEXT,
progress_percentage INTEGER DEFAULT 0,
accessed_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
-- Indexes
INDEX idx_external_id (external_id),
INDEX idx_reference_id (reference_id),
INDEX idx_status (status)
);
Form Data Table
CREATE TABLE ezyfamily_form_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID REFERENCES ezyfamily_sessions(id),
form_data JSONB NOT NULL,
submitted_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_session_id (session_id)
);
Webhook Events Table
CREATE TABLE ezyfamily_webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
webhook_id VARCHAR(255) UNIQUE NOT NULL, -- X-Webhook-ID header
event_type VARCHAR(100) NOT NULL,
session_id VARCHAR(255),
payload JSONB NOT NULL,
processed BOOLEAN DEFAULT FALSE,
processed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_webhook_id (webhook_id),
INDEX idx_session_id (session_id),
INDEX idx_processed (processed)
);
Session Lifecycle
Creating a Session
async function createEzylegalSession(data) {
// 1. Create in your database first (for reference)
const localSession = await db.sessions.create({
referenceId: data.caseId,
customerEmail: data.email,
customerName: data.name,
status: 'creating',
});
try {
// 2. Create session via API
const response = await fetch('https://api.ezy-forms.com.au/v1/sessions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.EZYCOLLECT_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': localSession.id, // Prevents duplicates
},
body: JSON.stringify({
form_type: 'financial_statement',
customer_email: data.email,
customer_name: data.name,
reference_id: data.caseId,
webhook_url: `${process.env.APP_URL}/webhooks/ezyfamily`,
}),
});
const session = await response.json();
// 3. Update local record
await localSession.update({
externalId: session.id,
sessionUrl: session.session_url,
status: 'pending',
});
return session;
} catch (error) {
await localSession.update({ status: 'failed' });
throw error;
}
}
Tracking Status
async function syncSessionStatus(externalId) {
const response = await fetch(
`https://api.ezy-forms.com.au/v1/sessions/${externalId}`,
{
headers: {
'Authorization': `Bearer ${process.env.EZYCOLLECT_API_KEY}`,
},
}
);
const session = await response.json();
await db.sessions.update(
{ externalId },
{
status: session.status,
progressPercentage: session.progress?.percentage || 0,
accessedAt: session.accessed_at,
completedAt: session.completed_at,
}
);
return session;
}
Webhook Processing
Reliable Webhook Handler
// 1. Acknowledge quickly, process asynchronously
app.post('/webhooks/ezyfamily',
express.raw({ type: 'application/json' }),
async (req, res) => {
const webhookId = req.headers['x-webhook-id'];
try {
// Verify signature
if (!verifySignature(req)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// Store event for processing
await db.webhookEvents.create({
webhookId,
eventType: event.event,
sessionId: event.session_id,
payload: event,
processed: false,
});
// Queue for processing
await queue.add('process-webhook', { webhookId });
res.status(200).send('OK');
} catch (error) {
console.error('Webhook error:', error);
res.status(400).send('Error');
}
}
);
// 2. Process from queue
queue.process('process-webhook', async (job) => {
const { webhookId } = job.data;
const event = await db.webhookEvents.findOne({ webhookId });
// Skip if already processed (idempotency)
if (event.processed) {
return { skipped: true };
}
try {
await processWebhookEvent(event);
await event.update({
processed: true,
processedAt: new Date(),
});
return { success: true };
} catch (error) {
console.error('Processing error:', error);
throw error; // Retry
}
});
// 3. Event-specific processing
async function processWebhookEvent(event) {
const payload = event.payload;
switch (payload.event) {
case 'session.accessed':
await db.sessions.update(
{ externalId: payload.session_id },
{
status: 'in_progress',
accessedAt: payload.data.accessed_at,
}
);
break;
case 'session.section_completed':
await db.sessions.update(
{ externalId: payload.session_id },
{ progressPercentage: payload.data.progress_percentage }
);
break;
case 'session.completed':
await handleSessionCompleted(payload);
break;
case 'session.pdf_ready':
await downloadAndStorePdf(payload.session_id);
break;
}
}
Handling Completion
async function handleSessionCompleted(payload) {
const { session_id } = payload;
// 1. Update session status
await db.sessions.update(
{ externalId: session_id },
{
status: 'completed',
completedAt: payload.data.completed_at,
}
);
// 2. Fetch and store form data
const response = await fetch(
`https://api.ezy-forms.com.au/v1/sessions/${session_id}/data`,
{
headers: {
'Authorization': `Bearer ${process.env.EZYCOLLECT_API_KEY}`,
},
}
);
const formData = await response.json();
const session = await db.sessions.findOne({ externalId: session_id });
await db.formData.create({
sessionId: session.id,
formData: formData.data,
submittedAt: formData.submitted_at,
});
// 3. Notify your team
await notifications.send({
type: 'session_completed',
caseId: session.referenceId,
customerName: session.customerName,
});
}
Error Handling
Retry Logic
async function fetchWithRetry(url, options, maxRetries = 3) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, options);
// Handle rate limiting
if (response.status === 429) {
const retryAfter = parseInt(
response.headers.get('Retry-After') || '30'
);
await sleep(retryAfter * 1000);
continue;
}
// Handle server errors with backoff
if (response.status >= 500) {
await sleep(Math.pow(2, attempt) * 1000);
continue;
}
return response;
} catch (error) {
lastError = error;
await sleep(Math.pow(2, attempt) * 1000);
}
}
throw lastError || new Error('Max retries exceeded');
}
Circuit Breaker
class CircuitBreaker {
constructor(options = {}) {
this.failureThreshold = options.failureThreshold || 5;
this.resetTimeout = options.resetTimeout || 60000;
this.failures = 0;
this.state = 'closed';
this.nextAttempt = 0;
}
async execute(fn) {
if (this.state === 'open') {
if (Date.now() < this.nextAttempt) {
throw new Error('Circuit breaker is open');
}
this.state = 'half-open';
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
this.failures = 0;
this.state = 'closed';
}
onFailure() {
this.failures++;
if (this.failures >= this.failureThreshold) {
this.state = 'open';
this.nextAttempt = Date.now() + this.resetTimeout;
}
}
}
const apiCircuit = new CircuitBreaker();
async function createSession(data) {
return apiCircuit.execute(() =>
fetch('https://api.ezy-forms.com.au/v1/sessions', {
method: 'POST',
// ...
})
);
}
Testing
Unit Testing Webhook Handlers
describe('Webhook Handler', () => {
it('handles session.completed event', async () => {
const event = {
event: 'session.completed',
session_id: 'sess_test123',
data: {
completed_at: '2024-01-15T10:00:00Z',
},
};
await processWebhookEvent({ payload: event });
const session = await db.sessions.findOne({
externalId: 'sess_test123',
});
expect(session.status).toBe('completed');
});
});
Integration Testing
describe('ezylegal Integration', () => {
it('creates session and handles completion', async () => {
// Create session
const session = await createEzylegalSession({
email: '[email protected]',
name: 'Test User',
caseId: 'case-123',
});
expect(session.id).toBeDefined();
expect(session.session_url).toBeDefined();
// Simulate webhook
await request(app)
.post('/webhooks/ezyfamily')
.set('X-Webhook-Signature', generateTestSignature(payload))
.send(payload)
.expect(200);
// Verify processing
const updated = await db.sessions.findOne({
externalId: session.id,
});
expect(updated.status).toBe('completed');
});
});
Next Steps
- Review Best Practices for production tips
- Set up webhook security
- Explore the API Reference