AppFolio SDK Patterns
Overview
Production-ready patterns for the AppFolio property management REST API. AppFolio uses HTTP Basic Auth with client credentials and returns JSON responses for properties, tenants, leases, and work orders. A structured singleton client prevents credential sprawl, enforces consistent error handling, and centralizes pagination logic across all property management endpoints.
Prerequisites
- A provider-verified base URL, auth method, endpoint scope, and managed secret injection path for the target portfolio.
- Schema validation, request timeouts, endpoint-specific rate limits, and idempotency keys for all mutation-capable service methods.
- Synthetic fixtures for unit testing; production tenant, lease, and payment payloads must not become default mock, log, or error-message content.
Instructions
- Construct one contract-bound client per runtime and reject missing or invalid configuration before requests begin.
- Validate query bounds and response shapes at the service boundary, then pass only minimized typed fields to callers.
- Return a classified
429or unknown-write failure to the caller; retry only idempotent operations after the caller records the cursor/key and delay. - Keep write workflows behind explicit authorization, audit, and reconciliation controls rather than embedding them in generic SDK convenience helpers.
Singleton Client
import axios, { AxiosInstance } from 'axios';
let _client: AxiosInstance | null = null;
export function getClient(): AxiosInstance {
if (!_client) {
const clientId = process.env.APPFOLIO_CLIENT_ID;
const clientSecret = process.env.APPFOLIO_CLIENT_SECRET;
const baseURL = process.env.APPFOLIO_BASE_URL;
if (!clientId || !clientSecret || !baseURL) throw new Error('APPFOLIO_CLIENT_ID, SECRET, and BASE_URL required');
_client = axios.create({ baseURL, auth: { username: clientId, password: clientSecret }, timeout: 30000 });
}
return _client;
}
Error Wrapper
export class AppFolioError extends Error {
constructor(public status: number, public code: string, message: string) { super(message); }
}
export async function safeCall<T>(operation: string, fn: () => Promise<T>): Promise<T> {
try { return await fn(); }
catch (err: any) {
const status = err.response?.status ?? 0;
if (status === 429) {
const retryAfter = err.response?.headers?.['retry-after'];
throw new AppFolioError(429, 'RATE_LIMIT', `Retry after ${retryAfter ?? 'provider-directed delay'}; do not replay an unknown write automatically`);
}
if (status === 401) throw new AppFolioError(401, 'AUTH', 'Invalid APPFOLIO_CLIENT_ID or SECRET');
throw new AppFolioError(status, 'API_ERROR', `${operation} failed [${status}]: ${err.message}`);
}
}
Request Builder
class AppFolioQuery {
private params: Record<string, string> = {};
status(s: 'active' | 'past' | 'future') { this.params.status = s; return this; }
propertyId(id: string) { this.params.property_id = id; return this; }
page(n: number) { this.params.page = String(n); return this; }
perPage(n: number) { this.params.per_page = String(Math.min(n, 200)); return this; }
since(date: string) { this.params.updated_since = date; return this; }
build() { return this.params; }
}
// Usage: new AppFolioQuery().status('active').perPage(50).build();
Response Types
interface Property {
id: string; name: string; property_type: 'residential' | 'commercial' | 'mixed';
address: { street: string; city: string; state: string; zip: string };
unit_count: number; status: string;
}
interface Tenant {
id: string; first_name: string; last_name: string;
email: string; phone: string; unit_id: string; lease_id: string;
}
interface Lease {
id: string; unit_id: string; tenant_id: string;
start_date: string; end_date: string; rent_amount: number; status: 'active' | 'expired' | 'future';
}
interface WorkOrder {
id: string; property_id: string; unit_id: string;
description: string; priority: 'low' | 'medium' | 'high' | 'emergency';
status: 'open' | 'in_progress' | 'completed';
}
Testing Utilities
export function mockProperty(overrides: Partial<Property> = {}): Property {
return { id: 'prop-001', name: 'Maple Ridge Apts', property_type: 'residential',
address: { street: '100 Main St', city: 'Austin', state: 'TX', zip: '78701' },
unit_count: 24, status: 'active', ...overrides };
}
export function mockLease(overrides: Partial<Lease> = {}): Lease {
return { id: 'lease-001', unit_id: 'unit-001', tenant_id: 'ten-001',
start_date: '2025-01-01', end_date: '2026-01-01', rent_amount: 1500, status: 'active', ...overrides };
}
Error Handling
| Pattern | When to Use | Example |
|---|---|---|
safeCall wrapper |
All API calls | Prevents uncaught 4xx/5xx from crashing flows |
| Caller-owned retry on 429 | Rate-limited idempotent batch reads | Preserve cursor/key, honor Retry-After, then retry deliberately |
| Auth validation | Client init | Throws early if credentials are missing |
| Pagination loop | Listing properties/tenants | Increment page until empty response |
Output
- One validated, contract-bound API client with centralized timeout and error classification behavior
- Bounded query parameters and minimized typed responses for service callers
- Explicit rate-limit and unknown-write outcomes that require caller-owned cursor/idempotency/reconciliation decisions
Examples
For a property-list read, build a query with a capped page size, execute it
through safeCall, and verify the returned property IDs and count against a
synthetic fixture. For a mutation, persist an idempotency key before dispatch
and treat a timeout or 429 as a pause/reconcile condition rather than calling
the function again. If client configuration, response schema, or write outcome
is unverified, stop the workflow and surface a redacted error to the owner.
Resources
Next Steps
Apply patterns in appfolio-core-workflow-a.