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:

  1. Session Management - Creating and tracking sessions
  2. Webhook Processing - Handling real-time events
  3. Data Storage - Persisting form data
  4. 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

  1. Asynchronous Processing - Use a job queue for webhook processing
  2. Idempotent Handlers - Handle duplicate webhooks gracefully
  3. Reliable Storage - Persist data before acknowledging webhooks
  4. 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