diff --git a/README.md b/README.md index d380948d..564dee2c 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,18 @@ The backend exposes an interactive Swagger UI for exploring and testing API endp Both endpoints are gated to non-production environments (`NODE_ENV !== "production"`). +### Webhooks + +RemitLend supports real-time event notifications via webhooks. See the +[Webhook Integration Guide](docs/webhooks.md) for details on subscribing, +event payloads, retry semantics, circuit-breaker behavior, and HMAC signature +verification. + +- **Swagger UI**: [http://localhost:3001/docs](http://localhost:3001/docs) +- **OpenAPI JSON**: [http://localhost:3001/docs.json](http://localhost:3001/docs.json) + +Both endpoints are gated to non-production environments (`NODE_ENV !== "production"`). + ## 🛠 Tech Stack - **Blockchain**: [Stellar](https://stellar.org) (Soroban Smart Contracts) @@ -66,7 +78,7 @@ Both endpoints are gated to non-production environments (`NODE_ENV !== "producti 1. **Clone the repository:** ```bash - git clone https://github.com/your-username/remitlend.git + git clone https://github.com/LabsCrypt/remitlend.git cd remitlend ``` diff --git a/backend/migrations/1794000000000_add-action-url-to-notifications.js b/backend/migrations/1794000000000_add-action-url-to-notifications.js new file mode 100644 index 00000000..26e94c70 --- /dev/null +++ b/backend/migrations/1794000000000_add-action-url-to-notifications.js @@ -0,0 +1,26 @@ +/** + * @type {import('node-pg-migrate').ColumnDefinitions | undefined} + */ +export const shorthands = undefined; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @returns {Promise | void} + */ +export const up = (pgm) => { + pgm.addColumns("notifications", { + action_url: { + type: "varchar(500)", + notNull: false, + comment: "Deep-link URL to the relevant entity (loan, remittance, etc.)", + }, + }); +}; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @returns {Promise | void} + */ +export const down = (pgm) => { + pgm.dropColumns("notifications", ["action_url"]); +}; diff --git a/backend/src/__tests__/apiV1Mounts.test.ts b/backend/src/__tests__/apiV1Mounts.test.ts index 6d4f0dcd..3bf25395 100644 --- a/backend/src/__tests__/apiV1Mounts.test.ts +++ b/backend/src/__tests__/apiV1Mounts.test.ts @@ -20,6 +20,7 @@ jest.unstable_mockModule("../db/connection.js", () => ({ query: mockQuery, getClient: jest.fn(), closePool: jest.fn(), + withTransaction: jest.fn(), })); // ── notificationService mock ───────────────────────────────────────────────── diff --git a/backend/src/__tests__/eventIndexer.test.ts b/backend/src/__tests__/eventIndexer.test.ts index 87bd037b..4b57d8a4 100644 --- a/backend/src/__tests__/eventIndexer.test.ts +++ b/backend/src/__tests__/eventIndexer.test.ts @@ -35,6 +35,7 @@ const supportedWebhookEventTypes = [ "LoanRepaid", "LoanDefaulted", "CollateralLiquidated", + "LoanLiquidated", "Deposit", "Withdraw", "YieldDistributed", @@ -175,7 +176,11 @@ function makeRawEvent(params: { case "LoanRequested": return { ...base, - topic: [scSymbol("LoanRequested"), scAddress(borrower)], + topic: [ + scSymbol("LoanRequested"), + scU32(params.loanId ?? 1), + scAddress(borrower), + ], value: scI128(params.amount ?? 500), }; case "LoanApproved": diff --git a/backend/src/__tests__/notificationDigest.test.ts b/backend/src/__tests__/notificationDigest.test.ts index 3b044e04..ac1da4e8 100644 --- a/backend/src/__tests__/notificationDigest.test.ts +++ b/backend/src/__tests__/notificationDigest.test.ts @@ -112,7 +112,7 @@ describe("notification digest batching", () => { mockQuery .mockResolvedValueOnce({ rows: [{ digest_frequency: "daily" }] }) .mockResolvedValueOnce({ rows: [{ digest_frequency: "weekly" }] }) - .mockResolvedValueOnce({ rows: [{ digest_frequency: "off" }] }); + .mockResolvedValueOnce({ rows: [{ digest_frequency: "daily" }] }); const notifications = [ { userId: user1, message: "Loan 1 due", loanId: 1 }, @@ -125,7 +125,7 @@ describe("notification digest batching", () => { notifications, ); - expect(grouped.size).toBe(3); + expect(grouped.size).toBe(2); expect(grouped.get(`${user1}:daily`)).toHaveLength(2); expect(grouped.get(`${user2}:weekly`)).toHaveLength(1); }); diff --git a/backend/src/__tests__/remittanceService.test.ts b/backend/src/__tests__/remittanceService.test.ts index b213e805..883f7e15 100644 --- a/backend/src/__tests__/remittanceService.test.ts +++ b/backend/src/__tests__/remittanceService.test.ts @@ -104,15 +104,11 @@ describe("remittanceService.createRemittance", () => { }); describe("remittanceService.getRemittances with filters", () => { - let mockQuery: jest.MockedFunction; - beforeEach(() => { jest.clearAllMocks(); - mockQuery = jest.fn(); }); it("filters remittances by status", async () => { - const { query: queryModule } = await import("../db/connection.js"); mockQuery.mockResolvedValueOnce({ rows: [ { diff --git a/backend/src/config/swaggerSchemas.ts b/backend/src/config/swaggerSchemas.ts index 0e1fa37e..1641c177 100644 --- a/backend/src/config/swaggerSchemas.ts +++ b/backend/src/config/swaggerSchemas.ts @@ -415,12 +415,14 @@ export const swaggerSchemas = { "repayment_due", "repayment_confirmed", "loan_defaulted", + "loan_liquidated", "score_changed", ], }, title: { type: "string" }, message: { type: "string" }, loanId: { type: "integer" }, + actionUrl: { type: "string", nullable: true }, read: { type: "boolean" }, createdAt: { type: "string", format: "date-time" }, }, diff --git a/backend/src/controllers/remittanceController.ts b/backend/src/controllers/remittanceController.ts index 67a381e3..9aee934e 100644 --- a/backend/src/controllers/remittanceController.ts +++ b/backend/src/controllers/remittanceController.ts @@ -186,6 +186,7 @@ export const submitRemittanceTransaction = asyncHandler( type: "repayment_confirmed", title: "Remittance Sent", message: `Your remittance of ${remittance.amount} ${remittance.fromCurrency} was submitted successfully. Transaction: ${stellarResult.txHash}`, + actionUrl: `/remittances/${remittance.id}`, }); res.json({ diff --git a/backend/src/middleware/validation.ts b/backend/src/middleware/validation.ts index abd3606e..9352d211 100644 --- a/backend/src/middleware/validation.ts +++ b/backend/src/middleware/validation.ts @@ -12,7 +12,7 @@ const validateSource = (schema: ZodType, source: ValidationSource) => { : source === "query" ? req.query : req.params; - req[source] = schema.parse(data); + schema.parse(data); next(); } catch (error) { next(error); diff --git a/backend/src/services/__tests__/eventIndexer.test.ts b/backend/src/services/__tests__/eventIndexer.test.ts index 5109cfbb..eed21bf6 100644 --- a/backend/src/services/__tests__/eventIndexer.test.ts +++ b/backend/src/services/__tests__/eventIndexer.test.ts @@ -249,6 +249,7 @@ beforeAll(async () => { "LoanRepaid", "LoanDefaulted", "CollateralLiquidated", + "LoanLiquidated", "Deposit", "Withdraw", "YieldDistributed", diff --git a/backend/src/services/__tests__/notificationService.test.ts b/backend/src/services/__tests__/notificationService.test.ts new file mode 100644 index 00000000..c3451f14 --- /dev/null +++ b/backend/src/services/__tests__/notificationService.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; + +type QueryResult = { rows: Record[]; rowCount: number }; +const mockQuery = jest.fn< + (sql: string, params?: unknown[]) => Promise +>(); + +jest.unstable_mockModule("../../db/connection.js", () => ({ + query: mockQuery, +})); + +jest.unstable_mockModule("twilio", () => ({ + default: jest.fn(() => ({ messages: { create: jest.fn() } })), +})); + +jest.unstable_mockModule("@sendgrid/mail", () => ({ + default: { setApiKey: jest.fn(), send: jest.fn() }, +})); + +const { notificationService } = await import("../notificationService.js"); + +describe("notificationService", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("createNotification", () => { + it("sets actionUrl from loanId when not explicitly provided", async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 1, + user_id: "user1", + type: "loan_approved", + title: "Loan Approved", + message: "Your loan has been approved", + loan_id: 42, + action_url: "/loans/42", + read: false, + status: "unread", + created_at: new Date("2026-05-28T12:00:00.000Z"), + }, + ], + rowCount: 1, + }); + + mockQuery.mockResolvedValueOnce({ + rows: [{ email: null, phone: null, email_enabled: false, sms_enabled: false }], + rowCount: 1, + }); + + const notification = await notificationService.createNotification({ + userId: "user1", + type: "loan_approved", + title: "Loan Approved", + message: "Your loan has been approved", + loanId: 42, + }); + + expect(notification.actionUrl).toBe("/loans/42"); + const insertCall = mockQuery.mock.calls[0] as [string, unknown[]]; + expect(insertCall[1]).toContain("/loans/42"); + }); + + it("uses explicit actionUrl over loanId when provided", async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 2, + user_id: "user2", + type: "repayment_confirmed", + title: "Remittance Sent", + message: "Remittance submitted", + loan_id: null, + action_url: "/remittances/99", + read: false, + status: "unread", + created_at: new Date("2026-05-28T12:00:00.000Z"), + }, + ], + rowCount: 1, + }); + + mockQuery.mockResolvedValueOnce({ + rows: [{ email: null, phone: null, email_enabled: false, sms_enabled: false }], + rowCount: 1, + }); + + const notification = await notificationService.createNotification({ + userId: "user2", + type: "repayment_confirmed", + title: "Remittance Sent", + message: "Remittance submitted", + actionUrl: "/remittances/99", + }); + + expect(notification.actionUrl).toBe("/remittances/99"); + }); + + it("returns null actionUrl when neither loanId nor actionUrl provided", async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 3, + user_id: "user3", + type: "score_changed", + title: "Score Changed", + message: "Your score changed", + loan_id: null, + action_url: null, + read: false, + status: "unread", + created_at: new Date("2026-05-28T12:00:00.000Z"), + }, + ], + rowCount: 1, + }); + + mockQuery.mockResolvedValueOnce({ + rows: [{ email: null, phone: null, email_enabled: false, sms_enabled: false }], + rowCount: 1, + }); + + const notification = await notificationService.createNotification({ + userId: "user3", + type: "score_changed", + title: "Score Changed", + message: "Your score changed", + }); + + expect(notification.actionUrl).toBeUndefined(); + }); + }); +}); diff --git a/backend/src/services/__tests__/scoreDecayService.test.ts b/backend/src/services/__tests__/scoreDecayService.test.ts new file mode 100644 index 00000000..093297fa --- /dev/null +++ b/backend/src/services/__tests__/scoreDecayService.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; + +type QueryResult = { rows: unknown[]; rowCount: number }; +const mockQuery = jest.fn< + (sql: string, params?: unknown[]) => Promise +>(); + +jest.unstable_mockModule("../../db/connection.js", () => ({ + query: mockQuery, +})); + +const { applyScoreDecay } = await import("../scoreDecayService.js"); + +describe("scoreDecayService", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockQuery.mockResolvedValue({ rows: [], rowCount: 1 }); + }); + + describe("applyScoreDecay", () => { + it("decays inactive borrower with no repayment by configured amount", async () => { + const borrower = { id: "user1", score: 700, last_repayment: null }; + const newScore = await applyScoreDecay(borrower); + + // No last_repayment => monthsInactive = 1 => decay = 1 * 5 = 5 + expect(newScore).toBe(695); + expect(mockQuery).toHaveBeenCalledWith( + "UPDATE borrowers SET score = $1 WHERE id = $2", + [695, "user1"], + ); + }); + + it("decays borrower inactive for multiple months", async () => { + // 90 days = exactly 3 30-day months + const ninetyDaysAgo = new Date(); + ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90); + + const borrower = { + id: "user2", + score: 700, + last_repayment: ninetyDaysAgo.toISOString(), + }; + const newScore = await applyScoreDecay(borrower); + + // 90 days => floor(90/30) = 3 => max(1, 3) = 3 => decay = 3 * 5 = 15 + expect(newScore).toBe(685); + }); + + it("applies minimum decay of one month even with recent activity", async () => { + const yesterday = new Date(); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + + const borrower = { + id: "user3", + score: 700, + last_repayment: yesterday.toISOString(), + }; + const newScore = await applyScoreDecay(borrower); + + // 1 day => floor(1/30) = 0 => max(1, 0) = 1 => decay = 5 + expect(newScore).toBe(695); + }); + + it("floors score at minimum score", async () => { + const borrower = { id: "user4", score: 304, last_repayment: null }; + const newScore = await applyScoreDecay(borrower); + + // 304 - 5 = 299, floored to 300 + expect(newScore).toBe(300); + }); + + it("never drops score below minimum even if already below", async () => { + const borrower = { id: "user5", score: 200, last_repayment: null }; + const newScore = await applyScoreDecay(borrower); + + // max(300, 200 - 5) = 300 + expect(newScore).toBe(300); + }); + + it("is idempotent for identical borrower input", async () => { + const borrower = { id: "user6", score: 700, last_repayment: null }; + + const first = await applyScoreDecay(borrower); + const second = await applyScoreDecay(borrower); + + expect(first).toBe(695); + expect(second).toBe(695); + expect(mockQuery).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/backend/src/services/notificationService.ts b/backend/src/services/notificationService.ts index f12df30c..1209bd3b 100644 --- a/backend/src/services/notificationService.ts +++ b/backend/src/services/notificationService.ts @@ -1,8 +1,6 @@ import { query } from "../db/connection.js"; import logger from "../utils/logger.js"; import type { Response } from "express"; -import twilio from "twilio"; -import sgMail from "@sendgrid/mail"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -23,6 +21,7 @@ export interface Notification { title: string; message: string; loanId?: number | undefined; + actionUrl?: string | null; read: boolean; status: NotificationStatus; createdAt: Date; @@ -34,6 +33,7 @@ interface CreateNotificationParams { title: string; message: string; loanId?: number | undefined; + actionUrl?: string | undefined | null; } export interface NotificationPreferences { @@ -51,17 +51,28 @@ export interface NotificationPreferences { type SseClient = Response; const sseClients = new Map>(); -// Initialize Twilio client if credentials are provided -const twilioClient = - process.env.TWILIO_ACCOUNT_SID && - process.env.TWILIO_AUTH_TOKEN && - process.env.TWILIO_PHONE_NUMBER - ? twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN) - : null; - -// Configure SendGrid if API key is present -if (process.env.SENDGRID_API_KEY) { - sgMail.setApiKey(process.env.SENDGRID_API_KEY); +// Lazy-init Twilio client — dynamic import avoids ESM/CJS interop issues in tests +async function getTwilioClient() { + if ( + !process.env.TWILIO_ACCOUNT_SID || + !process.env.TWILIO_AUTH_TOKEN || + !process.env.TWILIO_PHONE_NUMBER + ) { + return null; + } + const { default: twilio } = await import("twilio"); + return twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN); +} + +// Lazy-init SendGrid — called once on first sendEmail +let _sgInitialized = false; +async function ensureSendGrid() { + if (_sgInitialized) return; + _sgInitialized = true; + if (process.env.SENDGRID_API_KEY) { + const sgMail = await import("@sendgrid/mail"); + sgMail.default.setApiKey(process.env.SENDGRID_API_KEY); + } } function buildEmailTemplate( @@ -106,7 +117,16 @@ async function sendEmail( ): Promise { const fromEmail = process.env.FROM_EMAIL; - if (!process.env.SENDGRID_API_KEY || !fromEmail) { + if (!fromEmail) { + logger.info( + `[Email] FROM_EMAIL not set. Would send to ${email}: ${message}`, + ); + return; + } + + await ensureSendGrid(); + + if (!process.env.SENDGRID_API_KEY) { logger.info( `[Email] SendGrid not configured. Would send to ${email}: ${message}`, ); @@ -118,7 +138,8 @@ async function sendEmail( : { subject: "Notification from RemitLend", html: `

${message}

` }; try { - await sgMail.send({ + const sgMail = await import("@sendgrid/mail"); + await sgMail.default.send({ to: email, from: fromEmail, subject: template.subject, @@ -134,6 +155,7 @@ async function sendEmail( } async function sendSMS(phone: string, message: string) { + const twilioClient = await getTwilioClient(); if (!twilioClient || !process.env.TWILIO_PHONE_NUMBER) { logger.warn( `[SMS] Twilio not configured. Would send to ${phone}: ${message}`, @@ -225,13 +247,16 @@ class NotificationService { async createNotification( params: CreateNotificationParams, ): Promise { - const { userId, type, title, message, loanId } = params; + const { userId, type, title, message, loanId, actionUrl } = params; + + const resolvedActionUrl = + actionUrl ?? (loanId != null ? `/loans/${loanId}` : null); const result = await query( - `INSERT INTO notifications (user_id, type, title, message, loan_id, status) - VALUES ($1, $2, $3, $4, $5, 'unread') - RETURNING id, user_id, type, title, message, loan_id, read, status, created_at`, - [userId, type, title, message, loanId ?? null], + `INSERT INTO notifications (user_id, type, title, message, loan_id, action_url, status) + VALUES ($1, $2, $3, $4, $5, $6, 'unread') + RETURNING id, user_id, type, title, message, loan_id, action_url, read, status, created_at`, + [userId, type, title, message, loanId ?? null, resolvedActionUrl], ); const notification = this.mapRow(result.rows[0]); @@ -373,7 +398,7 @@ class NotificationService { } const result = await query( - `SELECT id, user_id, type, title, message, loan_id, read, status, created_at + `SELECT id, user_id, type, title, message, loan_id, action_url, read, status, created_at FROM notifications WHERE ${whereClause} ORDER BY created_at DESC @@ -464,11 +489,12 @@ class NotificationService { for (const row of adminResult.rows) { const adminId = row.public_key as string; + const actionUrl = loanId != null ? `/loans/${loanId}` : null; const result = await query( - `INSERT INTO notifications (user_id, type, title, message, loan_id, status) - VALUES ($1, 'loan_defaulted', $2, $3, $4, 'unread') - RETURNING id, user_id, type, title, message, loan_id, read, status, created_at`, - [adminId, title, message, loanId ?? null], + `INSERT INTO notifications (user_id, type, title, message, loan_id, action_url, status) + VALUES ($1, 'loan_defaulted', $2, $3, $4, $5, 'unread') + RETURNING id, user_id, type, title, message, loan_id, action_url, read, status, created_at`, + [adminId, title, message, loanId ?? null, actionUrl], ); const notification = this.mapRow(result.rows[0]); this.broadcast(adminId, notification); @@ -599,12 +625,15 @@ class NotificationService { private mapRow(row: Record): Notification { const loanId = row.loan_id != null ? (row.loan_id as number) : undefined; + const actionUrl = + row.action_url != null ? (row.action_url as string) : undefined; const base = { id: row.id as number, userId: row.user_id as string, type: row.type as NotificationType, title: row.title as string, message: row.message as string, + actionUrl, read: row.read as boolean, status: (row.status as NotificationStatus) ?? (row.read ? "read" : "unread"), diff --git a/backend/src/tests/envValidation.test.ts b/backend/src/tests/envValidation.test.ts index 64260e79..1d99bc70 100644 --- a/backend/src/tests/envValidation.test.ts +++ b/backend/src/tests/envValidation.test.ts @@ -43,6 +43,8 @@ describe("Environment Variable Validation", () => { process.env.SCORE_DELTA_REPAY = "15"; process.env.SCORE_DELTA_DEFAULT = "50"; process.env.SCORE_DELTA_LATE = "5"; + process.env.REMITTANCE_NFT_CONTRACT_ID = "C3"; + process.env.MULTISIG_GOVERNANCE_CONTRACT_ID = "C4"; expect(() => validateEnvVars()).not.toThrow(); expect(mockExit).not.toHaveBeenCalled(); diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 00000000..7bfa8428 --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,299 @@ +# Webhook Integration Guide + +RemitLend can deliver real-time event notifications to external services via +webhooks. This guide covers everything an external integrator needs to +subscribe, receive, and verify webhook deliveries. + +--- + +## Table of Contents + +- [Creating a Subscription](#creating-a-subscription) +- [Supported Event Types](#supported-event-types) +- [Payload Examples](#payload-examples) +- [Delivery & Retry Semantics](#delivery--retry-semantics) +- [Circuit Breaker](#circuit-breaker) +- [Verifying HMAC Signatures](#verifying-hmac-signatures) +- [Subscriber Response Requirements](#subscriber-response-requirements) + +--- + +## Creating a Subscription + +**Endpoint:** `POST /api/webhooks/subscriptions` + +**Headers:** +``` +Content-Type: application/json +Authorization: Bearer +``` + +**Request body:** + +```json +{ + "url": "https://your-service.com/webhooks/remitlend", + "events": ["loan_approved", "repayment_confirmed", "loan_defaulted"], + "description": "My loan tracking service (optional)" +} +``` + +| Field | Type | Description | +|-------------|----------|------------------------------------------------------| +| `url` | string | HTTPS endpoint that will receive POST requests | +| `events` | string[] | Array of [event types](#supported-event-types) | +| `description` | string | Optional human-readable label | + +**Response (201):** + +```json +{ + "success": true, + "data": { + "id": "sub_abc123", + "url": "https://your-service.com/webhooks/remitlend", + "events": ["loan_approved", "repayment_confirmed", "loan_defaulted"], + "active": true, + "createdAt": "2026-05-28T12:00:00.000Z" + } +} +``` + +After creation the subscription is immediately active. No verification handshake +is required. + +### Managing Subscriptions + +| Method | Endpoint | Description | +|--------|---------------------------------------|------------------------| +| GET | `/api/webhooks/subscriptions` | List all subscriptions | +| GET | `/api/webhooks/subscriptions/:id` | Get a single subscription | +| PUT | `/api/webhooks/subscriptions/:id` | Update events / URL | +| DELETE | `/api/webhooks/subscriptions/:id` | Delete a subscription | + +--- + +## Supported Event Types + +| Event | Description | +|------------------------|-----------------------------------------------| +| `loan_approved` | A borrower's loan has been approved | +| `repayment_due` | A repayment is coming due soon | +| `repayment_confirmed` | A repayment was received and confirmed | +| `loan_defaulted` | A loan has been marked as defaulted | +| `loan_liquidated` | Collateral has been liquidated after default | +| `score_changed` | A borrower's credit score changed | + +--- + +## Payload Examples + +Every delivery is a JSON POST with the following envelope: + +```json +{ + "event": "", + "id": "", + "timestamp": "2026-05-28T12:00:00.000Z", + "data": { } +} +``` + +### `loan_approved` + +```json +{ + "event": "loan_approved", + "id": "evt_loan_42", + "timestamp": "2026-05-28T12:00:00.000Z", + "data": { + "loanId": 42, + "borrower": "GABCDEF...", + "amount": "5000", + "termMonths": 12 + } +} +``` + +### `repayment_confirmed` + +```json +{ + "event": "repayment_confirmed", + "id": "evt_repay_99", + "timestamp": "2026-05-28T12:05:00.000Z", + "data": { + "loanId": 42, + "borrower": "GABCDEF...", + "amount": "450", + "txHash": "a1b2c3d4..." + } +} +``` + +### `loan_defaulted` + +```json +{ + "event": "loan_defaulted", + "id": "evt_default_7", + "timestamp": "2026-05-28T12:10:00.000Z", + "data": { + "loanId": 42, + "borrower": "GABCDEF...", + "outstandingAmount": "3200" + } +} +``` + +### `loan_liquidated` + +```json +{ + "event": "loan_liquidated", + "id": "evt_liq_3", + "timestamp": "2026-05-28T12:15:00.000Z", + "data": { + "loanId": 42, + "borrower": "GABCDEF...", + "collateralSeized": true, + "borrowerRefund": "150" + } +} +``` + +### `repayment_due` + +```json +{ + "event": "repayment_due", + "id": "evt_due_21", + "timestamp": "2026-05-28T12:00:00.000Z", + "data": { + "loanId": 42, + "borrower": "GABCDEF...", + "dueDate": "2026-06-01", + "amount": "450" + } +} +``` + +### `score_changed` + +```json +{ + "event": "score_changed", + "id": "evt_score_15", + "timestamp": "2026-05-28T12:00:00.000Z", + "data": { + "userId": "GABCDEF...", + "previousScore": 650, + "newScore": 665, + "reason": "on-time repayment" + } +} +``` + +--- + +## Delivery & Retry Semantics + +1. **Delivery method:** HTTP POST to the subscriber URL. +2. **Timeout:** The endpoint must respond within **10 seconds**. +3. **Retry policy:** Deliveries are retried with exponential backoff: + - Retry 1: 10 seconds + - Retry 2: 30 seconds + - Retry 3: 1 minute + - Retry 4: 5 minutes + - Retry 5: 15 minutes + - Retry 6: 30 minutes + - Retry 7: 1 hour +4. **Max attempts:** 8 total (1 initial + 7 retries). +5. **Delivery window:** Events older than **24 hours** are not retried. +6. **Ordering:** Webhooks are delivered on a **best-effort** basis and may not + arrive in the exact order events occurred. + +--- + +## Circuit Breaker + +If a subscriber endpoint fails to respond with a 2xx status for **5 consecutive +deliveries**, the subscription is automatically **deactivated** to avoid +wasting resources. + +While deactivated: +- No further events are sent to the subscriber. +- The subscription status changes to `deactivated`. +- You can **re-activate** the subscription by calling + `PUT /api/webhooks/subscriptions/:id` with `{ "active": true }`. + +--- + +## Verifying HMAC Signatures + +Each delivery includes an `X-RemitLend-Signature` header containing an +HMAC-SHA256 signature of the raw request body. + +**Header format:** +``` +X-RemitLend-Signature: t=1745827200,v1=abc123def456... +``` + +- `t` — Unix timestamp of when the signature was generated +- `v1` — Hexadecimal HMAC-SHA256 digest + +### Verification snippet (Node.js) + +```typescript +import { createHmac, timingSafeEqual } from "node:crypto"; + +function verifyWebhookSignature( + body: string, + signatureHeader: string, + secret: string, +): boolean { + const parts = signatureHeader.split(","); + const timestamp = parts.find((p) => p.startsWith("t="))?.slice(2); + const digest = parts.find((p) => p.startsWith("v1="))?.slice(3); + + if (!timestamp || !digest) return false; + + const payload = `${timestamp}.${body}`; + const expected = createHmac("sha256", secret) + .update(payload) + .digest("hex"); + + if (expected.length !== digest.length) return false; + return timingSafeEqual(Buffer.from(expected), Buffer.from(digest)); +} +``` + +> ⚠️ **Important:** Always use `timingSafeEqual` (or your language's +> constant-time comparison) when verifying the signature to prevent timing +> attacks. + +### Obtaining your secret + +The signing secret is the `WEBHOOK_SIGNING_SECRET` environment variable +configured on the RemitLend server. Contact the RemitLend team to obtain +your shared secret. + +--- + +## Subscriber Response Requirements + +| Code | Meaning | +|---------|----------------------------------------------| +| 2xx | Delivery accepted — no retry | +| 4xx | Request rejected — permanent failure (no retry) | +| 5xx | Server error — will be retried | +| Timeout | Treated as a failure — will be retried | + +- **Respond within 10 seconds.** Slow responses are counted as failures. +- Returning any 2xx status (200, 201, 202, 204) acknowledges delivery. + +--- + +## Need Help? + +Contact the RemitLend team or open an issue on GitHub for integration support.