Webhooks
Webhooks allow you to receive real-time notifications when events occur in LoanSense. Instead of polling our API, you register an endpoint URL and we POST event data to it as things happen.
Getting Started
To start receiving webhooks, contact us at dev@myloansense.com with:
- Your endpoint URL — the HTTPS URL where you want to receive webhook POSTs
- Which event types you want to subscribe to
We’ll provision a subscription and provide you with your webhook signing secret, which you’ll use to verify that incoming requests are genuinely from LoanSense.
Security
Signature Verification
Every webhook request includes an X-Webhook-Signature header containing an HMAC-SHA256 signature of the
request body, signed with your webhook secret. You should always verify this signature before processing the payload.
import crypto from "crypto";
function verifyWebhookSignature(rawBody, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(signature, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}Always use a timing-safe comparison when verifying signatures. A standard === comparison
can leak information through response time differences.
Request Headers
Every webhook POST includes these headers:
| Header | Description |
|---|---|
X-Webhook-Signature | HMAC-SHA256 hex digest of the request body |
X-Webhook-Event | The event type (e.g. loan.results.batch) |
X-Lender-Id | Your lender identifier |
Content-Type | Always application/json |
Responding to Webhooks
Your endpoint should return a 200 status code to acknowledge receipt. If we receive a non-2xx response,
the delivery will be marked as failed.
Event Types
loan.results.batch
Sent when a batch of processed loan results is ready. If a file contains more records than your
configured batch size, results are split across multiple webhook deliveries. Use the batch_number
and total_batches fields to reassemble the full result set.
In addition to the standard headers, batch webhooks include:
| Header | Description |
|---|---|
X-Batch-Number | Current batch number (1-indexed) |
X-Total-Batches | Total number of batches for this file |
Payload
| Field | Type | Description |
|---|---|---|
| event | string | "loan.results.batch" |
| file_id | string | Unique identifier for the processed file |
| lender_id | string | Your lender identifier |
| batch_number | number | Current batch (1-indexed) |
| total_batches | number | Total batches for this file |
| total_records | number | Total records across all batches |
| results | array | Array of Loan Result objects |
| timestamp | string | ISO 8601 timestamp of when the batch was sent |
Loan Result Schema
Each object in the results array contains:
| Field | Type | Description |
|---|---|---|
| borrower_id | string | Unique borrower identifier (as provided by your organization) |
| loan_id | string | Loan number from the original file, used as the grouping key |
| current_student_loan_payment | number | Aggregated monthly payment across all federal loans (or forbearance estimate if $0) |
| new_student_loan_payment | number | Lowest available payment option (extended, IBR, forbearance, or current) |
| monthly_student_loan_payment_savings | number | Difference between current and new payment |
| current_dti | number | Debt-to-income ratio before LoanSense |
| new_dti | number | Estimated debt-to-income ratio after payment reduction |
| lo | string | Assigned loan officer name |
| lo_email | string | Assigned loan officer email (lowercased) |
| met_criteria | string | "yes" if monthly savings ≥ $50, otherwise "no" |
| has_private_loans | string | "yes" if borrower has private student loans, otherwise "no" |
| loansense_link | string | Personalized LoanSense link for the borrower |
| private_loan_link | string | null | Campus Door refinance link (only if borrower has private loans, otherwise null) |
| notes | string | "No income data" if income information is missing, otherwise empty string |
borrower.status.updated
Sent when a borrower’s status changes in the LoanSense workflow. Borrowers progress through a 4-step process from initial signup through DTI recalculation readiness.
Payload
| Field | Type | Description |
|---|---|---|
| event | string | "borrower.status.updated" |
| lender_id | string | Your lender identifier |
| borrower_id | string | Unique borrower identifier |
| loan_id | string | Loan number associated with the borrower |
| status | string | New status value (see Status Values below) |
| step | number | Step number in the workflow (1–4) |
| previous_status | string | Previous status value |
| previous_step | number | Previous step number |
| timestamp | string | ISO 8601 timestamp of the status change |
| metadata | object | Additional context about the status change |
Status Values
Borrowers progress through these statuses in order:
| Step | Status | Description |
|---|---|---|
| 1 | consumer_signup | Borrower has signed up with LoanSense |
| 2 | appointment_scheduled | Borrower has scheduled an appointment with a LoanSense counselor |
| 3 | paperwork_submitted | Borrower’s paperwork has been submitted to their loan servicer |
| 4 | ready_to_recalculate | Process complete — borrower is ready for DTI recalculation |
Statuses always progress forward (1 → 2 → 3 → 4). A borrower will not move backward to a previous step.
Example: Receiving Webhooks
A complete example of a webhook receiver in Node.js:
import crypto from "crypto";
import express from "express";
const app = express();
app.use(express.raw({ type: "application/json" }));
const WEBHOOK_SECRET = "{{WEBHOOK_SECRET}}";
app.post("/webhooks/loansense", (req, res) => {
const signature = req.headers["x-webhook-signature"];
const event = req.headers["x-webhook-event"];
// Verify signature
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const a = Buffer.from(signature, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("Invalid signature");
}
const payload = JSON.parse(req.body);
switch (event) {
case "loan.results.batch":
console.log(
`Received batch ${payload.batch_number}/${payload.total_batches}` +
` for file ${payload.file_id} (${payload.results.length} records)`
);
// Process loan results...
break;
case "borrower.status.updated":
console.log(
`Borrower ${payload.borrower_id} moved to` +
` ${payload.status} (step ${payload.step})`
);
// Update borrower status in your system...
break;
}
res.status(200).json({ received: true });
});Use express.raw() (not express.json()) so you have access to the raw request body bytes
for signature verification. Parse the JSON yourself after verifying.