Designing APIs for Lending Platforms
A comprehensive guide to designing robust, secure, and developer-friendly APIs for lending platforms, covering RESTful resource modeling, webhook architectures, idempotency, versioning, and partner integration patterns in TypeScript.
The API layer is the primary interface through which lending platforms interact with the world. Borrower-facing applications, partner integrations, internal tools, and third-party services all communicate through APIs. The quality of API design directly affects developer productivity, integration reliability, partner onboarding speed, and ultimately the platform's ability to scale its business.
Lending APIs carry particular design challenges: financial transactions demand idempotency guarantees, regulatory requirements impose strict data handling rules, long-running workflows need asynchronous patterns, and the sensitivity of financial data requires robust authentication and authorization. This article walks through the key design decisions for building lending platform APIs in TypeScript.
Resource Modeling
The first step in API design is defining the resource model. In a lending platform, the core resources map to the domain entities: borrowers, applications, loans, payments, documents, and offers. Each resource should have a clear lifecycle, a consistent identifier scheme, and well-defined relationships to other resources.
// Resource type definitions for the API layer
interface BorrowerResource {
id: string;
type: "borrower";
attributes: {
firstName: string;
lastName: string;
email: string;
phone: string;
dateOfBirth: string; // ISO 8601 date
address: AddressResource;
createdAt: string; // ISO 8601 datetime
updatedAt: string;
};
relationships: {
applications: ResourceLink[];
loans: ResourceLink[];
};
}
interface ApplicationResource {
id: string;
type: "application";
attributes: {
status: string;
productType: string;
requestedAmount: number;
requestedTermMonths: number;
purpose: string;
decision?: DecisionResource;
createdAt: string;
updatedAt: string;
};
relationships: {
borrower: ResourceLink;
documents: ResourceLink[];
offer?: ResourceLink;
};
}
interface LoanResource {
id: string;
type: "loan";
attributes: {
status: string;
principal: number;
interestRate: number;
termMonths: number;
monthlyPayment: number;
outstandingBalance: number;
nextPaymentDueDate: string;
disbursementDate: string;
maturityDate: string;
createdAt: string;
updatedAt: string;
};
relationships: {
borrower: ResourceLink;
application: ResourceLink;
payments: ResourceLink[];
schedule: ResourceLink;
};
}
interface ResourceLink {
id: string;
type: string;
href: string;
}A practical tip: always return ISO 8601 formatted strings for dates and datetimes. Avoid returning raw Date objects or Unix timestamps. ISO 8601 is unambiguous, human-readable, and universally supported across programming languages.
RESTful Endpoint Design
With resources defined, the endpoint structure follows naturally. Each resource gets a standard set of CRUD operations, and domain-specific actions are modeled as either sub-resources or explicit action endpoints.
import { Router, Request, Response, NextFunction } from "express";
class ApplicationRouter {
private router: Router;
constructor(
private readonly applicationService: ApplicationService,
private readonly authMiddleware: AuthMiddleware
) {
this.router = Router();
this.setupRoutes();
}
private setupRoutes(): void {
// Collection endpoints
this.router.post(
"/v1/applications",
this.authMiddleware.requireScope("applications:write"),
this.idempotencyMiddleware,
this.createApplication.bind(this)
);
this.router.get(
"/v1/applications",
this.authMiddleware.requireScope("applications:read"),
this.listApplications.bind(this)
);
// Instance endpoints
this.router.get(
"/v1/applications/:id",
this.authMiddleware.requireScope("applications:read"),
this.getApplication.bind(this)
);
this.router.patch(
"/v1/applications/:id",
this.authMiddleware.requireScope("applications:write"),
this.updateApplication.bind(this)
);
// Action endpoints
this.router.post(
"/v1/applications/:id/submit",
this.authMiddleware.requireScope("applications:write"),
this.idempotencyMiddleware,
this.submitApplication.bind(this)
);
this.router.post(
"/v1/applications/:id/withdraw",
this.authMiddleware.requireScope("applications:write"),
this.idempotencyMiddleware,
this.withdrawApplication.bind(this)
);
// Sub-resource endpoints
this.router.post(
"/v1/applications/:id/documents",
this.authMiddleware.requireScope("documents:write"),
this.uploadDocument.bind(this)
);
this.router.get(
"/v1/applications/:id/documents",
this.authMiddleware.requireScope("documents:read"),
this.listDocuments.bind(this)
);
}
private async createApplication(req: Request, res: Response): Promise<void> {
try {
const application = await this.applicationService.create(
req.body,
req.context.partnerId
);
res.status(201).json({
data: this.serialize(application),
});
} catch (error) {
this.handleError(res, error);
}
}
private async listApplications(req: Request, res: Response): Promise<void> {
const { page, pageSize, status, createdAfter, createdBefore } = req.query;
const result = await this.applicationService.list({
partnerId: req.context.partnerId,
page: Number(page) || 1,
pageSize: Math.min(Number(pageSize) || 25, 100),
status: status as string,
createdAfter: createdAfter ? new Date(createdAfter as string) : undefined,
createdBefore: createdBefore ? new Date(createdBefore as string) : undefined,
});
res.json({
data: result.items.map(this.serialize),
pagination: {
page: result.page,
pageSize: result.pageSize,
totalItems: result.totalItems,
totalPages: result.totalPages,
hasNextPage: result.hasNextPage,
hasPreviousPage: result.hasPreviousPage,
},
});
}
private serialize(application: Application): ApplicationResource {
// Transform internal domain model to API resource
return {} as ApplicationResource; // Simplified
}
private handleError(res: Response, error: unknown): void {
// Centralized error handling
}
private idempotencyMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
// Handled by IdempotencyService
next();
}
}State-changing actions like "submit" and "withdraw" are modeled as POST operations on action sub-endpoints rather than PATCH operations on the application resource. This makes the API's intentions explicit and allows each action to have its own validation logic, side effects, and idempotency behavior.
Idempotency for Financial Operations
In a lending platform, duplicate API calls can have catastrophic consequences: double disbursement, duplicate payments, or multiple applications for the same borrower. Idempotency keys prevent these issues by ensuring that retried requests produce the same result as the original.
interface IdempotencyRecord {
key: string;
method: string;
path: string;
requestHash: string;
responseStatus: number;
responseBody: string;
createdAt: Date;
expiresAt: Date;
}
class IdempotencyService {
constructor(private readonly store: IdempotencyStore) {}
async processRequest(
idempotencyKey: string,
method: string,
path: string,
requestBody: unknown,
handler: () => Promise<{ status: number; body: unknown }>
): Promise<{ status: number; body: unknown; cached: boolean }> {
const requestHash = this.hashRequest(method, path, requestBody);
// Check for existing record
const existing = await this.store.find(idempotencyKey);
if (existing) {
// Verify the request matches the original
if (existing.requestHash !== requestHash) {
throw new IdempotencyKeyReusedError(
"Idempotency key was already used for a different request"
);
}
return {
status: existing.responseStatus,
body: JSON.parse(existing.responseBody),
cached: true,
};
}
// Lock the key to prevent concurrent processing
const acquired = await this.store.acquireLock(idempotencyKey);
if (!acquired) {
throw new IdempotencyConflictError(
"Request with this idempotency key is currently being processed"
);
}
try {
const result = await handler();
// Store the result
const record: IdempotencyRecord = {
key: idempotencyKey,
method,
path,
requestHash,
responseStatus: result.status,
responseBody: JSON.stringify(result.body),
createdAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
};
await this.store.save(record);
return { ...result, cached: false };
} finally {
await this.store.releaseLock(idempotencyKey);
}
}
private hashRequest(
method: string,
path: string,
body: unknown
): string {
const content = `${method}:${path}:${JSON.stringify(body)}`;
// Use a proper hash function in production
let hash = 0;
for (let i = 0; i < content.length; i++) {
const char = content.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0;
}
return hash.toString(16);
}
}The idempotency implementation locks the key during processing to prevent race conditions from concurrent retries. The response is cached for 24 hours, which is long enough to cover any reasonable retry window. Importantly, the service validates that a reused idempotency key is accompanied by the same request body---using a key from one request on a different request is an error, not an idempotent replay.
Webhook Architecture for Asynchronous Events
Lending workflows are inherently asynchronous. An application submission triggers underwriting that may take seconds or minutes. A payment is initiated but may take days to settle. Partners and integrators need to be notified when these events occur, and polling is neither efficient nor reliable at scale.
interface WebhookSubscription {
id: string;
partnerId: string;
url: string;
events: WebhookEventType[];
secret: string;
isActive: boolean;
createdAt: Date;
}
type WebhookEventType =
| "application.submitted"
| "application.approved"
| "application.declined"
| "loan.funded"
| "loan.payment_received"
| "loan.delinquent"
| "loan.paid_off"
| "document.verified"
| "document.rejected";
interface WebhookPayload {
id: string;
type: WebhookEventType;
createdAt: string;
data: {
resourceType: string;
resourceId: string;
attributes: Record<string, unknown>;
};
}
class WebhookDeliveryService {
constructor(
private readonly subscriptionRepo: WebhookSubscriptionRepository,
private readonly deliveryLog: WebhookDeliveryLogRepository,
private readonly queue: MessageQueue
) {}
async dispatch(event: WebhookPayload): Promise<void> {
const subscriptions = await this.subscriptionRepo.findByEventType(
event.type
);
for (const subscription of subscriptions) {
if (!subscription.isActive) continue;
await this.queue.enqueue("webhook_delivery", {
subscriptionId: subscription.id,
payload: event,
attempt: 1,
maxAttempts: 5,
});
}
}
async deliver(
subscription: WebhookSubscription,
payload: WebhookPayload,
attempt: number
): Promise<void> {
const body = JSON.stringify(payload);
const signature = this.sign(body, subscription.secret);
const startTime = Date.now();
try {
const response = await fetch(subscription.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Signature": signature,
"X-Webhook-Id": payload.id,
"X-Webhook-Timestamp": new Date().toISOString(),
},
body,
signal: AbortSignal.timeout(10000), // 10 second timeout
});
await this.deliveryLog.record({
subscriptionId: subscription.id,
webhookId: payload.id,
eventType: payload.type,
attempt,
responseStatus: response.status,
responseTimeMs: Date.now() - startTime,
success: response.status >= 200 && response.status < 300,
deliveredAt: new Date(),
});
if (response.status >= 500 || response.status === 429) {
throw new WebhookDeliveryError(
`Server returned ${response.status}`
);
}
} catch (error) {
await this.deliveryLog.record({
subscriptionId: subscription.id,
webhookId: payload.id,
eventType: payload.type,
attempt,
responseStatus: 0,
responseTimeMs: Date.now() - startTime,
success: false,
error: String(error),
deliveredAt: new Date(),
});
if (attempt < 5) {
const delayMs = this.exponentialBackoff(attempt);
await this.queue.enqueueDelayed("webhook_delivery", {
subscriptionId: subscription.id,
payload,
attempt: attempt + 1,
maxAttempts: 5,
}, delayMs);
}
}
}
private sign(body: string, secret: string): string {
// HMAC-SHA256 signature
// In production, use crypto.createHmac('sha256', secret).update(body).digest('hex')
return `sha256=${secret}:${body.length}`;
}
private exponentialBackoff(attempt: number): number {
const baseDelay = 60 * 1000; // 1 minute
return baseDelay * Math.pow(2, attempt - 1); // 1, 2, 4, 8, 16 minutes
}
}The webhook signature allows recipients to verify that the payload genuinely originated from the lending platform and was not tampered with in transit. Including the webhook ID in the header enables recipients to deduplicate deliveries if the retry mechanism sends the same event twice.
API Versioning Strategy
Lending APIs must evolve without breaking existing integrations. A well-chosen versioning strategy balances the need for backward compatibility with the ability to introduce new features and deprecate old patterns.
interface APIVersionConfig {
version: string;
status: "current" | "deprecated" | "sunset";
deprecatedAt?: Date;
sunsetAt?: Date;
changelog: string;
}
class VersionRouter {
private versions: Map<string, Router> = new Map();
registerVersion(version: string, router: Router): void {
this.versions.set(version, router);
}
route(req: Request, res: Response, next: NextFunction): void {
// Extract version from URL path: /v1/applications, /v2/applications
const versionMatch = req.path.match(/^\/(v\d+)\//);
if (!versionMatch) {
res.status(400).json({
error: {
code: "MISSING_VERSION",
message: "API version must be specified in the URL path",
},
});
return;
}
const version = versionMatch[1];
const router = this.versions.get(version);
if (!router) {
res.status(400).json({
error: {
code: "UNSUPPORTED_VERSION",
message: `API version ${version} is not supported`,
supportedVersions: Array.from(this.versions.keys()),
},
});
return;
}
// Add deprecation headers if applicable
const config = this.getVersionConfig(version);
if (config?.status === "deprecated") {
res.setHeader("Deprecation", config.deprecatedAt?.toISOString() ?? "true");
res.setHeader("Sunset", config.sunsetAt?.toISOString() ?? "");
res.setHeader(
"Link",
`</v${this.getLatestVersion()}/docs>; rel="successor-version"`
);
}
router(req, res, next);
}
private getVersionConfig(version: string): APIVersionConfig | undefined {
return undefined; // Simplified
}
private getLatestVersion(): number {
return 2;
}
}URL-based versioning (e.g., /v1/applications) is the most explicit and debuggable approach. When a version is deprecated, set the standard Deprecation and Sunset HTTP headers to inform consumers and give them time to migrate.
Error Response Design
Consistent, informative error responses are essential for developer experience. Every error should include a machine-readable code, a human-readable message, and enough context for the consumer to understand and resolve the issue.
interface APIError {
error: {
code: string;
message: string;
details?: ErrorDetail[];
requestId: string;
documentationUrl?: string;
};
}
interface ErrorDetail {
field?: string;
code: string;
message: string;
}
class ErrorHandler {
handle(error: unknown, requestId: string): { status: number; body: APIError } {
if (error instanceof ValidationError) {
return {
status: 400,
body: {
error: {
code: "VALIDATION_ERROR",
message: "The request body contains invalid fields",
details: error.fieldErrors.map((fe) => ({
field: fe.field,
code: fe.code,
message: fe.message,
})),
requestId,
documentationUrl: "https://docs.lendingplatform.com/errors/validation",
},
},
};
}
if (error instanceof InsufficientFundsError) {
return {
status: 422,
body: {
error: {
code: "INSUFFICIENT_FUNDS",
message: "The payment amount exceeds the available balance",
requestId,
},
},
};
}
if (error instanceof ResourceNotFoundError) {
return {
status: 404,
body: {
error: {
code: "RESOURCE_NOT_FOUND",
message: `${error.resourceType} with ID ${error.resourceId} was not found`,
requestId,
},
},
};
}
if (error instanceof IdempotencyKeyReusedError) {
return {
status: 422,
body: {
error: {
code: "IDEMPOTENCY_KEY_REUSED",
message: error.message,
requestId,
},
},
};
}
// Unexpected errors
console.error(`Unhandled error [${requestId}]:`, error);
return {
status: 500,
body: {
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred. Please try again or contact support.",
requestId,
},
},
};
}
}A practical tip: always include a requestId in error responses. When a partner contacts support about a failed request, the request ID allows engineers to locate the exact request in logs and trace it through the system. This single field eliminates hours of back-and-forth debugging.
Rate Limiting and Partner Tiers
Lending APIs must protect against abuse and ensure fair access across partners. A tiered rate limiting strategy allows high-volume partners to operate at scale while preventing any single consumer from overwhelming the system.
interface RateLimitConfig {
partnerId: string;
tier: "standard" | "premium" | "enterprise";
limits: {
requestsPerMinute: number;
requestsPerHour: number;
requestsPerDay: number;
};
}
class RateLimiter {
constructor(private readonly store: RateLimitStore) {}
async checkLimit(
partnerId: string,
config: RateLimitConfig
): Promise<RateLimitResult> {
const now = Date.now();
const minuteKey = `${partnerId}:minute:${Math.floor(now / 60000)}`;
const hourKey = `${partnerId}:hour:${Math.floor(now / 3600000)}`;
const dayKey = `${partnerId}:day:${Math.floor(now / 86400000)}`;
const [minuteCount, hourCount, dayCount] = await Promise.all([
this.store.increment(minuteKey, 60),
this.store.increment(hourKey, 3600),
this.store.increment(dayKey, 86400),
]);
const limited =
minuteCount > config.limits.requestsPerMinute ||
hourCount > config.limits.requestsPerHour ||
dayCount > config.limits.requestsPerDay;
return {
allowed: !limited,
remaining: {
perMinute: Math.max(0, config.limits.requestsPerMinute - minuteCount),
perHour: Math.max(0, config.limits.requestsPerHour - hourCount),
perDay: Math.max(0, config.limits.requestsPerDay - dayCount),
},
retryAfterSeconds: limited ? 60 : 0,
};
}
}
interface RateLimitResult {
allowed: boolean;
remaining: {
perMinute: number;
perHour: number;
perDay: number;
};
retryAfterSeconds: number;
}Return rate limit information in response headers (X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After) so that well-behaved clients can throttle themselves before hitting limits.
Conclusion
API design for lending platforms requires attention to both general API best practices and domain-specific concerns. Idempotency is non-negotiable for financial operations. Webhooks with HMAC signatures and exponential backoff retries provide reliable asynchronous notifications. URL-based versioning with deprecation headers enables graceful evolution. Structured error responses with request IDs streamline debugging and support.
The overarching principle is that the API should be predictable, safe, and self-documenting. Every endpoint should behave consistently, every mutation should be idempotent when provided with an idempotency key, and every response should give the consumer enough information to understand what happened and what to do next. A lending API built on these foundations enables partners to integrate confidently and the platform to scale its ecosystem of integrations without proportional growth in support burden.
Related Articles
Risk Management in Lending: Architecture and Strategy
A strategic guide to building a comprehensive risk management framework for lending platforms, covering credit risk, portfolio management, stress testing, concentration limits, and loss forecasting.
Digital Lending Trends: What's Next for Fintech
A business-focused analysis of the trends shaping digital lending, including embedded finance, alternative data, real-time decisioning, open banking, and the evolution of lending-as-a-service platforms.
Fraud Detection Patterns in Lending Systems
An exploration of fraud detection techniques for lending platforms, covering application fraud, identity fraud, synthetic identity detection, velocity checks, and anomaly detection patterns in TypeScript.