Loan-Event Furnishing
Push loan lifecycle events to Metro2 the moment they happen. Each event is deterministically translated into the right Metro 2 tradeline mutation so the next cycle file ships bureau-grade — no monthly diffing, no manual ops.
1. What it is
Loan-Event Furnishing is a webhook-in surface for your loan origination system (LOS), loan-servicing platform, BNPL backend, or rent-tech app. Instead of diffing your database once a month and uploading a bulk update, your platform POSTs each loan event to Metro2 the moment it happens — a payment posts, a borrower goes 60 days past due, an account charges off, a bankruptcy is filed — and we deterministically translate the event into the correct tradeline mutation (Account Status, Payment Rating, Payment History Profile roll, K1 segment, Consumer Information Indicator, Compliance Condition Code) on your metro2_records rows.
When the next scheduled cycle runs, the Metro 2 file ships with already-correct segments. No staging, no last-minute reconciliation, no emergency uploads.
Why webhook-in, not webhook-out?
2. When to use it
Loan-Event Furnishing is a fit when you have:
- High-velocity portfolios — daily payments, frequent status changes, or thousands of accounts where a monthly batch diff is fragile or slow.
- Real-time accuracy requirements — your compliance team wants Date-of-First-Delinquency stamped the day delinquency starts, not on the 1st of the following month.
- An existing LOS or servicing platform that emits webhooks — LoanPro, Peach, Mambu, in-house systems. You forward the events you already produce.
- Charge-offs, bankruptcies, or deceased flags that must be reported with the right CRRG codes — these are easy to get wrong in a manual upload and easy to get right with a typed event.
- Multi-environment setups — you want a production source for live data and a test-mode source for staging traffic, both pointing at the same Metro2 company.
If you only file one Metro 2 cycle a month from a single spreadsheet and your portfolio is stable, the monthly upload flow is simpler. Loan-Event Furnishing is an additive layer, not a replacement — accounts must already exist in metro2_records before events can target them (see §11 Error handling).
3. How it works
End-to-end pipeline:
+---------------------+
| Your LOS / | 1. Loan event happens
| servicing system | (payment posted, 60 dpd, charge-off, ...)
+----------+----------+
|
| 2. POST /api/v1/loan-events
| Headers: Authorization: Bearer <API_KEY>
| X-Metro2-Source-Id: <source_uuid>
| Metro2-Signature: t=<unix>,v1=<hex-sha256>
| Body: Stripe-style envelope (id, type, occurred_at,
| account, data)
v
+---------------------+
| Metro2 ingest | 3. Verify HMAC over raw body
| /api/v1/loan-events| 4. Insert into loan_events (status='queued')
| | 5. Return 202 { event_id, status: "queued" }
+----------+----------+ (duplicates return 409 with the original id)
|
| 6. Cron picks it up (every minute):
| /api/cron/process-loan-events
v
+---------------------+
| Processor | 7. Lock tradeline (SELECT FOR UPDATE SKIP LOCKED)
| | 8. applyEvent(snapshot, envelope) -> patch
| | 9. Write patch + supplemental segments
| | 10. Update last_event_at / lifecycle_state
| | 11. logRecordChange(source='loan_event', _meta)
| | 12. Mark loan_events row 'applied' / 'rejected'
+----------+----------+
|
| 13. Patch lives on metro2_records
v
+---------------------+
| Scheduled cycle | 14. Next cycle: /api/cron/run-scheduled-
| generation | submissions calls generate-file. The
| | already-correct segments ship to the bureaus.
+---------------------+Five things to internalize about this flow:
- 2xx fast, work async. The POST handler returns 202 as soon as the event is durably queued — it does not block on tradeline mutation. Your LOS should treat anything in the 2xx range as a successful delivery.
- Cron, not push. A serverless cron (configured in
vercel.json) drains theloan_eventsqueue at/api/cron/process-loan-events. Events typically apply within a minute of receipt. - Mapper is pure. The translation logic lives in
src/lib/loan-events/mappers.ts → applyEvent(snapshot, envelope). The dry-run endpoint, the live processor, and the unit tests all call the same function — there is one source of truth for "what does this event do?" - Cycle generation is unchanged. No new scheduler. Events accumulate as field updates on
metro2_records, and the existing scheduled-submissions cron picks them up on its normal schedule. - Every applied event is audited.
record_audit_loggets a row withsource='loan_event'and the event id inchanges._meta, so any field change is traceable to the envelope that caused it.
4. Event types
The seven v1 event types below are all backed by CRRG-defined codes from the Credit Reporting Resource Guide. The translation logic is encoded in src/lib/loan-events/mappers.ts and the lookup tables in src/lib/loan-events/cii-table.ts.
| event_type | data fields | Metro 2 fields touched | Account Status / CII / DOFD result |
|---|---|---|---|
payment.received | amount_cents, received_at, new_balance_cents? | account_status, date_last_payment, actual_payment_amount, current_balance, amount_past_due, payment_history_profile | Standard cure path: status → 11 (current), amount_past_due=0, DOFD cleared if returning to current. On a charged_off lifecycle: balance reduced but status stays 97 and original_charge_off_amount is preserved. |
payment.late | days_late, as_of, current_balance_cents?, amount_past_due_cents? | account_status, date_first_delinquency, current_balance, amount_past_due, payment_history_profile | CRRG bucketing on days_late: <30 → 11, 30–59 → 71, 60–89 → 78, 90–119 → 80, 120–149 → 82, 150–179 → 83, ≥180 → 84. DOFD stamped on first transition out of current; never overwritten. |
account.charged_off | charge_off_amount_cents, charge_off_date, original_creditor_classification? | account_status, payment_rating, original_charge_off_amount, date_first_delinquency, lifecycle_state, K1 segment | Status → 97, payment rating → L, lifecycle_state=charged_off. original_charge_off_amount is write-once. K1 segment emitted with the original creditor classification. DOFD stamped if not already present. |
account.closed | closed_at, reason (paid/refinanced/transferred/other) | account_status, date_closed, amount_past_due, lifecycle_state, payment_history_profile | date_closed stamped, lifecycle_state=closed. If reason="paid" and the prior status was current, status → 13. Otherwise the existing derogatory status is preserved (CRRG: closure does not erase history). |
account.disputed | dispute_opened_at, dispute_reason? | compliance_condition_code | CCC → XB (FCRA §623 dispute open). Stays set until cleared by a follow-up event. |
account.bankruptcy_filed | chapter (7/11/13), filed_at, case_number?, filer? (consumer/joint) | consumer_info_indicator, compliance_condition_code, lifecycle_state | CII set per (chapter, filer): 7 consumer→A, 7 joint→B, 11 consumer→D, 11 joint→F, 13 consumer→H, 13 joint→I. CCC → XH. lifecycle_state=bankruptcy. |
account.deceased | deceased_at | consumer_info_indicator, lifecycle_state | CII → 89 (deceased). lifecycle_state=deceased. This is a terminal state — further payment.late / account.charged_off events on the same record will be rejected with tradeline_terminal. We intentionally do not write date_closed; CRRG distinguishes "deceased" (CII) from "closed" (status 13). |
Payment History Profile (PHP) rolls automatically
computePaymentHistoryProfileFromRecord. You never need to send a PHP value in the envelope — the mapper derives it from the post-event account_status and date_first_delinquency.Days-late → Account Status reference
| days_late range | account_status | Meaning |
|---|---|---|
< 30 | 11 | Current |
30–59 | 71 | 30 days past due |
60–89 | 78 | 60 days past due |
90–119 | 80 | 90 days past due |
120–149 | 82 | 120 days past due |
150–179 | 83 | 150 days past due |
≥ 180 | 84 | 180+ days past due |
5. Setting up a source
Every inbound integration is represented by a source — one row in loan_event_sources per upstream system. A source owns its own signing secret, its tolerance window, and which column on metro2_records the envelope's account.id resolves against. Create one source per production system you forward from, and a separate test-mode source for staging.
Step-by-step in the dashboard
- Go to /dashboard/loan-events and click New source.
- Give the source a recognizable name (e.g. "LoanPro production", "Peach staging"). The name appears in the event log and audit trail, so be specific.
- Choose the account ID field. This is the column on
metro2_recordsthat your envelope'saccount.idwill match against — eitherconsumer_account_number(default) oraccount_number. You cannot freely change this later without re-keying historical events, so pick the one your LOS uses as its stable identifier. - Pick a timestamp tolerance in seconds (default
300, range30–3600). Events whose signature timestamp falls outside this window are rejected astimestamp_skew. The default mirrors Stripe and is appropriate for almost all integrations. - Optionally flag the source as test mode. Test-mode sources accept events but never mutate
metro2_records— useful for shaking down a new integration without polluting live data. You can toggle test mode at any time. - Click Create. The next screen shows your signing secret exactly once. Copy it into your LOS immediately — once you leave this screen, the secret cannot be retrieved, only rotated.
- Also visible on this screen: the canonical webhook URL (
https://metro2.switchlabs.dev/api/v1/loan-events) and your source ID (X-Metro2-Source-Idheader value). Both are needed for every POST.
Rotating the signing secret
Use Rotate secret on the source detail page (or POST to /api/v1/loan-event-sources/{id}/rotate-secret) when the current secret is exposed or on a recurring rotation schedule. Rotation is Stripe-style:
- The current secret is copied to
signing_secret_previous. - A fresh 32-byte secret is generated and returned once.
signing_secret_rotated_atis stamped so the UI can warn you after the cutover window expires.
24-hour dual-key window
Disabling or deleting a source
Toggle Enabled off to stop accepting events (POSTs return 403 source_disabled with no row written). Deleting a source cascades to its loan_events history; if you only want to pause, disable instead of delete.
6. HMAC signature verification
Every POST to /api/v1/loan-events must include a Metro2-Signature header so we can prove the request originated from your system and was not tampered with in transit. The format mirrors Stripe's exactly:
Metro2-Signature: t=1747555800,v1=4e3b2a...e9c1tis the unix timestamp (seconds) at which you signed.v1is the lowercase hex SHA-256 HMAC of the string<t>.<rawBody>, keyed by your source's signing secret.- Hex only — base64 signatures are not accepted (this prevents base64-vs-hex confusion attacks).
- The body in the HMAC is the raw bytes of the JSON you POSTed, not the re-serialized parsed object. Whitespace and key ordering matter.
Verifier behavior
| Check | Failure code | HTTP status |
|---|---|---|
| No Metro2-Signature header | missing_header | 401 |
| Header could not be parsed | malformed_header | 401 |
| t value missing or non-numeric | missing_timestamp | 401 |
| No v1 entries | missing_signature | 401 |
| abs(now - t) > tolerance_seconds | timestamp_skew | 400 |
| Hex digest did not match current or previous secret | signature_mismatch | 401 |
Comparison uses Node's crypto.timingSafeEqual, so timing side channels are not exploitable. On a successful match the verifier records whether the current or previous secret matched, which is surfaced in the event log for visibility during a rotation window.
Computing the signature: Node.js
import crypto from "node:crypto";
function signMetro2Event(rawBody, signingSecret) {
const timestamp = Math.floor(Date.now() / 1000);
const v1 = crypto
.createHmac("sha256", signingSecret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return `t=${timestamp},v1=${v1}`;
}
// IMPORTANT: sign the EXACT bytes you will send. If you JSON.stringify the
// envelope, store the result and POST that — do not re-stringify later.
const envelope = {
id: "evt_AbcD123",
type: "payment.received",
occurred_at: "2026-05-18T10:30:00Z",
account: { id: "ACCT-7842" },
data: {
amount_cents: 42500,
received_at: "2026-05-18T10:30:00Z",
new_balance_cents: 1247500,
},
};
const rawBody = JSON.stringify(envelope);
const signature = signMetro2Event(rawBody, process.env.METRO2_SIGNING_SECRET);
await fetch("https://metro2.switchlabs.dev/api/v1/loan-events", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.METRO2_API_KEY}`,
"Content-Type": "application/json",
"X-Metro2-Source-Id": process.env.METRO2_SOURCE_ID,
"Metro2-Signature": signature,
},
body: rawBody,
});Computing the signature: Python
import hmac
import hashlib
import json
import os
import time
import requests
def sign_metro2_event(raw_body: str, signing_secret: str) -> str:
timestamp = int(time.time())
v1 = hmac.new(
signing_secret.encode("utf-8"),
f"{timestamp}.{raw_body}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
return f"t={timestamp},v1={v1}"
envelope = {
"id": "evt_AbcD123",
"type": "payment.received",
"occurred_at": "2026-05-18T10:30:00Z",
"account": {"id": "ACCT-7842"},
"data": {
"amount_cents": 42500,
"received_at": "2026-05-18T10:30:00Z",
"new_balance_cents": 1247500,
},
}
# Sign the exact bytes you POST. separators=(",", ":") is optional but keeps
# the body compact and stable.
raw_body = json.dumps(envelope, separators=(",", ":"))
signature = sign_metro2_event(raw_body, os.environ["METRO2_SIGNING_SECRET"])
requests.post(
"https://metro2.switchlabs.dev/api/v1/loan-events",
headers={
"Authorization": f"Bearer {os.environ['METRO2_API_KEY']}",
"Content-Type": "application/json",
"X-Metro2-Source-Id": os.environ["METRO2_SOURCE_ID"],
"Metro2-Signature": signature,
},
data=raw_body, # NOTE: data=, not json=, so the exact bytes go on the wire
)Always send the bytes you signed
signature_mismatch rejection. Pin your raw body once, sign it, and send it.7. Sending events
Every event is one POST to /api/v1/loan-events. Auth is via API key (Bearer), source identification via the X-Metro2-Source-Id header, integrity via the Metro2-Signature header.
curl example
# 1. Build the body.
BODY='{"id":"evt_AbcD123","type":"payment.received","occurred_at":"2026-05-18T10:30:00Z","account":{"id":"ACCT-7842"},"data":{"amount_cents":42500,"received_at":"2026-05-18T10:30:00Z","new_balance_cents":1247500}}'
# 2. Sign it.
T=$(date +%s)
V1=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$METRO2_SIGNING_SECRET" -hex | awk '{print $2}')
# 3. POST.
curl -X POST https://metro2.switchlabs.dev/api/v1/loan-events \
-H "Authorization: Bearer $METRO2_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Metro2-Source-Id: $METRO2_SOURCE_ID" \
-H "Metro2-Signature: t=$T,v1=$V1" \
--data-raw "$BODY"Request body shape
All events share the same Stripe-style envelope. Per-event data is a strict discriminated union.
type LoanEventEnvelope = {
id: string; // YOUR id. UUID/ULID. Primary idempotency key.
type:
| "payment.received"
| "payment.late"
| "account.charged_off"
| "account.closed"
| "account.disputed"
| "account.bankruptcy_filed"
| "account.deceased";
occurred_at: string; // ISO-8601 UTC. When the loan-side fact happened.
account: {
id: string; // Matches source.account_id_field on metro2_records.
consumer_account_number?: string;
portfolio_type?: string;
};
data: object; // Shape depends on `type` — see §4.
idempotency_key?: string; // Defaults to `id`.
metadata?: Record<string, unknown>;
};Quick reference for each data shape:
// payment.received
{ "amount_cents": 42500, "received_at": "2026-05-18T10:30:00Z",
"new_balance_cents": 1247500 }
// payment.late
{ "days_late": 47, "as_of": "2026-05-18",
"current_balance_cents": 1250000, "amount_past_due_cents": 42500 }
// account.charged_off
{ "charge_off_amount_cents": 1247500, "charge_off_date": "2026-05-18",
"original_creditor_classification": "08" }
// account.closed
{ "closed_at": "2026-05-18", "reason": "paid" }
// account.disputed
{ "dispute_opened_at": "2026-05-18T10:30:00Z",
"dispute_reason": "consumer claims account not theirs" }
// account.bankruptcy_filed
{ "chapter": "7", "filed_at": "2026-05-18",
"case_number": "24-12345", "filer": "consumer" }
// account.deceased
{ "deceased_at": "2026-05-18" }Money fields are CENTS
*_cents field is an integer count of cents, matching how metro2_records.current_balance and the other monetary columns are stored in the database. Send 42500 for $425.00, not 425 and not 425.00.Successful response
On a successful enqueue, you get 202 Accepted with the Metro2-side event id and the queued status:
{
"success": true,
"event_id": "f23c9b71-c4e8-4e8f-a3a5-7c2b5b1d9e44",
"status": "queued",
"received_at": "2026-05-18T10:30:00.213Z"
}event_id is our internal UUID for the loan_events row. Persist it on your side if you want to poll GET /api/v1/loan-events/{id} for processing status.
Duplicate response
If you POST the same id twice from the same source, you'll get 409 Conflict with the original Metro2-side event id — see §8 Idempotency.
{
"error": "duplicate_event_id",
"code": "duplicate_event_id",
"event_id": "f23c9b71-c4e8-4e8f-a3a5-7c2b5b1d9e44",
"status": "applied"
}Error responses
| HTTP | code | Meaning |
|---|---|---|
| 400 | missing_body | Empty request body |
| 400 | invalid_json | Body was not valid JSON |
| 400 | missing_source_id | No X-Metro2-Source-Id header and no metadata.source_id in envelope |
| 400 | missing_event_id | envelope.id was missing |
| 400 | invalid_event_type | envelope.type not in the supported set |
| 400 | invalid_occurred_at | envelope.occurred_at not a parseable ISO-8601 |
| 400 | missing_account_id | envelope.account.id was missing |
| 400 | timestamp_skew | Signature timestamp outside the tolerance window |
| 401 | missing_header | No Metro2-Signature header |
| 401 | malformed_header | Metro2-Signature header could not be parsed |
| 401 | signature_mismatch | HMAC did not match current or previous secret |
| 403 | source_disabled | The source exists but is disabled |
| 404 | source_not_found | X-Metro2-Source-Id does not exist for this company |
| 409 | duplicate_event_id | Idempotency hit on (source_id, envelope.id) |
| 500 | — | Internal error; safe to retry with the same envelope id |
8. Idempotency
Idempotency is enforced at the database level by a unique constraint on (source_id, external_event_id). The external_event_id is whatever you send as envelope.id.
- Use stable, unique ids on your side. A UUID v4 or ULID per event from your LOS works perfectly. Do not reuse ids across event types or accounts.
- Retries are safe. If the first POST succeeds but your network drops before you see the 202, retrying with the same envelope returns
409 duplicate_event_idand the original Metro2 event id — no double-application. - Scope is per source. If you have two sources (e.g. production and staging) and both happen to use the same id, those are independent events. Idempotency does not collide across sources.
- Idempotency is at ingest, not at application. The unique constraint blocks duplicate rows; the per-record ordering described in §10 ensures duplicate events don't apply a patch twice even in edge cases (e.g. an event that was somehow re-queued).
Recommended retry policy on your side
9. Dry-run mode
POST /api/v1/loan-events/dry-run takes the exact same envelope, headers, and signature as the live endpoint, but never mutates. It resolves the target tradeline, runs applyEvent against the live snapshot, and returns the would-be patch. Use it for:
- Local integration testing against your real Metro2 data without changing it.
- CI smoke tests — verify your envelope shape and signing logic match the production verifier before promoting.
- Customer-success debugging — preview exactly what an event would do to a tradeline before sending it for real.
curl -X POST https://metro2.switchlabs.dev/api/v1/loan-events/dry-run \
-H "Authorization: Bearer $METRO2_API_KEY" \
-H "X-Metro2-Source-Id: $METRO2_SOURCE_ID" \
-H "Content-Type: application/json" \
-H "Metro2-Signature: t=$T,v1=$V1" \
--data-raw "$BODY"Response when the tradeline exists and the event applies cleanly:
{
"success": true,
"would_apply": {
"patch": {
"account_status": "71",
"current_balance": 1250000,
"amount_past_due": 42500,
"date_first_delinquency": "2026-04-18",
"payment_history_profile": "B11111111111111111111111"
},
"segments": [],
"notes": []
},
"rejection": null,
"resolved_record_id": "5a1c7d22-9f3a-4f8b-9c11-1ee0d8a3a212"
}Response when the tradeline does not exist:
{
"success": true,
"would_apply": null,
"rejection": {
"reason": "unknown_account",
"message": "no metro2_records row matched consumer_account_number=ACCT-7842"
},
"resolved_record_id": null
}Response when the mapper itself refuses to apply (e.g. terminal state):
{
"success": true,
"would_apply": null,
"rejection": {
"reason": "tradeline_terminal",
"message": "Tradeline is charged_off; late events cannot mutate account status."
},
"resolved_record_id": "5a1c7d22-9f3a-4f8b-9c11-1ee0d8a3a212"
}Dry-run shares the production code path
applyEvent function the live processor uses. If dry-run says "would apply this patch", the live processor will apply exactly that patch — there is no separate dry-run reimplementation to drift.10. Out-of-order events
Webhooks arrive in the order the network delivers them, not in the order they happened. Two protections keep you safe:
Terminal-state guards
The mapper enforces lifecycle terminality even when events arrive out of order. A payment.late that lands after an account.charged_off on the same record is rejected with tradeline_terminal — it does not un-charge-off the account.
| Lifecycle state | Event | Result |
|---|---|---|
charged_off | payment.late | Rejected (tradeline_terminal) |
charged_off | payment.received | Accepted. Balance reduced; status stays 97; original_charge_off_amount preserved (CRRG charge-off math). |
charged_off | account.charged_off | Accepted; original_charge_off_amount is write-once so the value is not changed. |
deceased | any event | Rejected (tradeline_terminal). Deceased is fully terminal. |
bankruptcy | any event | Accepted; the CCC stays XH until a follow-up event changes it. |
Serial-per-record ordering
The cron processor pulls queued events in oldest-first order and locks each affected tradeline with SELECT FOR UPDATE SKIP LOCKED before applying the patch. Two events targeting the same record always serialize; events targeting different records run in parallel. You do not need to throttle your sender — bursts of correlated events for a single account will still apply one-at-a-time on our side.
Date-of-First-Delinquency (DOFD) persistence
DOFD is stamped the first time a record transitions out of status 11 and never overwritten until the record cures (returns to 11 with zero past-due via payment.received). An out-of-order late event arriving after a more-recent late event will not move DOFD backwards or forwards.
11. Error handling
Retries and backoff
Once an event is queued, the cron processor retries up to 5 attempts with exponential backoff. If processing fails (e.g. a transient DB error), the event moves to status='failed' with last_error set and is re-tried on the next cron tick until the attempt budget is exhausted. After the 5th failure the event stays in failed and surfaces in the event log for manual replay.
Unknown accounts
v1 is strict: the target tradeline must already exist in metro2_records. If the resolver cannot find a row where <source.account_id_field> = envelope.account.id, the event is rejected with rejection_reason='unknown_account' and a status='rejected'.
Create the record first, then send events
Rejection reasons
When an event is rejected (either at ingest or by the mapper), loan_events.rejection_reason is set to one of:
| Reason | When |
|---|---|
duplicate | Same (source_id, external_event_id) already exists |
tradeline_terminal | Record is charged_off or deceased and the event cannot mutate it |
unknown_account | No metro2_records row matched the source's account_id_field |
produces_invalid_record | Patch would produce a record that fails post-write validation |
out_of_order | An event with a newer occurred_at has already applied; this older event is superseded |
unsupported_event_type | envelope.type is not in the v1 set |
missing_required_field | data field required for this event_type was absent or null |
invalid_payload | Catch-all for shape errors not in the above buckets |
12. Monitoring
Event log UI
The dashboard at /dashboard/loan-events lists every event for your company, newest first. Filters: source, event type, status (queued, processing, applied, skipped, rejected, failed, superseded), date range. Click any row to open a drawer with the full payload, the signature we accepted, the resolved tradeline, and the field-level diff that was applied.
Per-source detail page
Click a source name to see its configuration (account ID field, tolerance, enabled/disabled, test mode), the secret rotation timestamp, and a focused event log scoped to just that source. This is where you rotate or retire the signing secret.
last_event_at on each record
Every metro2_records row carries last_event_at (timestamp of most recent applied event) and last_event_id (FK back to loan_events). The records UI surfaces these so you can ask "when did this account last change?" and click straight through to the event that caused it.
Audit trail
Every field change driven by an event is logged to record_audit_log with source='loan_event' and a _meta block containing the event id, event type, and external event id. In the record-history UI, an entry looks like:
account_status: "11" -> "71"
amount_past_due: 0 -> 42500
date_first_delinquency: null -> "2026-04-18"
source: loan_event
event_id: f23c9b71-c4e8-4e8f-a3a5-7c2b5b1d9e44
event_type: payment.late
external_event_id: evt_AbcD123 (from "LoanPro production")
applied_at: 2026-05-18T10:30:00.420Z13. Common questions
Can I match on a composite key (e.g. account number + portfolio type)?
Not in v1. Each source resolves on a single column of metro2_records — consumer_account_number or account_number. Pick whichever your LOS uses as a stable, unique identifier within your portfolio. If you genuinely need a composite key, file a feature request; we'll consider it for v2.
Can I backfill historical events?
Sort of. The endpoint accepts events with any occurred_at timestamp, so you can replay older events. But: the strict unknown_account rule still applies — every targeted account must already exist in metro2_records. If you're onboarding, do the records import first, then start the event stream from a defined point in time. Don't send events for accounts you haven't loaded yet; they'll be rejected and won't auto-resolve later.
Can I trigger a Metro 2 file immediately after an event?
Not in v1. Event-applied changes ride the next scheduled cycle (configured per company in settings). Mid-month fast cycles are out of scope; if you need within-the-hour delivery to a bureau, this isn't the right product surface yet.
What happens if the same account gets two different events at the same instant?
They serialize. The processor takes a per-record advisory lock with SELECT FOR UPDATE SKIP LOCKED — whichever transaction wins applies its patch first, the other waits, then applies its patch against the post-first-event snapshot. The end state matches the order of receipt (ties broken by loan_events.id).
Do you support dispute.resolved?
The type is accepted by the envelope validator and the mapper has a branch for it (clears CCC XB back to blank), but dispute.resolved is Phase 3 in the roadmap and not part of the v1 marketed event set. Use account.disputed for the open side; reach out if you need closure semantics today.
What about chargeback-on-charge-off (post-CO payments)?
Send a normal payment.received. On a charged-off tradeline the mapper reduces current_balance but keeps account_status=97 and leaves original_charge_off_amount untouched. This matches CRRG's charge-off math.
Can the same event id be reused across sources?
Yes. Idempotency is scoped to (source_id, external_event_id). Two distinct sources can each have an event with id="evt_AbcD123" with no collision.
What's the rate limit?
Standard v1 API rate limits apply (see api_rate_limits on your account). Event ingest is designed for high volume — if you expect sustained bursts above the default, request a custom limit from support.
How do I rotate a leaked secret immediately?
POST /api/v1/loan-event-sources/{id}/rotate-secret (or click Rotate secret on the source page) to generate a new secret. Deploy the new secret to your LOS. Once your sender is fully on the new secret, you can either rotate a second time (which evicts the leaked secret from signing_secret_previous) or wait for operational retirement. If you suspect active abuse, disable the source while you rotate.
14. API reference
All routes are under /api/v1, require API-key auth via Authorization: Bearer <API_KEY>, and are rate-limited per company.
Event endpoints
| Method | Path | Purpose | Auth & headers |
|---|---|---|---|
POST | /api/v1/loan-events | Primary ingest. Verifies HMAC, persists raw event, returns 202 with the event id. Duplicates return 409. | Authorization, X-Metro2-Source-Id, Metro2-Signature |
GET | /api/v1/loan-events | List events for the authenticated company. Filters: status, event_type, source_id, from, to, limit. | Authorization |
GET | /api/v1/loan-events/{id} | Single event, the resolved tradeline (if any), and the applied diff. Useful for debugging. | Authorization |
POST | /api/v1/loan-events/dry-run | Same payload and signature as ingest, but never mutates. Returns the would-be patch. | Authorization, X-Metro2-Source-Id, Metro2-Signature |
Source endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/loan-event-sources | List configured sources (secrets are never returned in list responses; only a boolean signing_secret_set). |
POST | /api/v1/loan-event-sources | Create a new source. Returns the signing secret exactly once in the create response. |
GET | /api/v1/loan-event-sources/{id} | Fetch a single source (no secret). |
PATCH | /api/v1/loan-event-sources/{id} | Edit name, enabled, test_mode, tolerance_seconds, account_id_field, default_portfolio_type, notes. |
DELETE | /api/v1/loan-event-sources/{id} | Delete a source. Cascades to loan_events history — prefer disabling if you only want to pause. |
POST | /api/v1/loan-event-sources/{id}/rotate-secret | Generate a new signing secret. The previous one stays valid in the dual-key window. |
Required headers at a glance
| Header | Required for | Notes |
|---|---|---|
Authorization: Bearer <API_KEY> | All v1 routes | Identifies the company. Manage keys at /dashboard/api-keys. |
X-Metro2-Source-Id | POST /api/v1/loan-events and POST /api/v1/loan-events/dry-run | Identifies which source's signing key to verify against. Falls back to envelope.metadata.source_id. |
Metro2-Signature: t=<unix>,v1=<hex> | POST /api/v1/loan-events and POST /api/v1/loan-events/dry-run | Hex SHA-256 HMAC over `<t>.<rawBody>`. Hex only; base64 is rejected. |
Content-Type: application/json | POST routes | Body is parsed as JSON after the raw body is captured for signature verification. |
15. Related guides
- AI Error Explanations — when an event patch produces an invalid Metro 2 record (
rejection_reason=produces_invalid_record), the validation error attached to the event gets the same plain-English explanation treatment. - Payment History Grid — visualize the 24-month PHP string the mapper rolls forward on every applied event, and spot patterns before submission.
- Account Status Fundamentals — the CRRG background for the days-late → status mapping, charge-off rules, and DOFD persistence enforced by the mapper.
Still stuck? Contact support or browse the Help Center.