Event reference
Every event — delivered by webhook or returned by the poll API — uses the same envelope. The webhook body and the poll-API item are byte-for-byte the same shape.
The envelope
{
"eventId": "evt_66a1f0c2d3e4f5a6b7c8d9e0",
"type": "payment.collected",
"occurredAt": "2026-08-17T09:31:00.000Z",
"version": 1,
"lenderCode": "YOURORG",
"sourceLoanNumber": "LN-2026-0001",
"sequence": 4,
"payload": { }
}
| Field | Type | Meaning |
|---|---|---|
eventId | string | evt_ + 24 hex chars (a ping uses evt_ping_ + 16 hex). Globally unique — your dedupe key. |
type | string | One of the types below. Also sent as the X-Fieldproof-Event header. |
occurredAt | ISO 8601 datetime | When the change happened. On payment.collected (and a case.closed it triggers) this is the payment time — the same value as payload.collectedAt — so it can be days earlier than the event's arrival. |
version | number | Envelope version. Currently 1. |
lenderCode | string | Your organisation code. |
sourceLoanNumber | string | null | Your loan number as pushed. null only on ping. |
sequence | integer | null | Per-case counter, starts at 1 for every new case, counts all event types (including ones you are not subscribed to). null only on ping. |
payload | object | Type-specific, below. |
All money values are whole INR rupees (numbers, no paise).
Subscription
The default subscription is case.received, payment.collected,
case.closed. case.assigned and visit.completed are opt-in — ask our
integration team to enable them (CRM Integrations tab); there is no self-serve
subscription API yet. Two consequences:
sequencestill counts the events you don't receive, so a default subscriber sees gaps (1 → 4 → 5). That is normal — see Ordering.- The poll API returns all types regardless of your webhook subscription.
:::note Some changes emit no event
Your own updates to an active case — a re-push, or an edit on your
organisation dashboard — emit nothing. Nor
does a case going back to pending (lease lapsed, timeouts, unassignment, a
rejected payment proof…). GET /api/v1/partner/cases/{sourceLoanNumber}
always shows the current state — see
returns to pending.
:::
case.received
Your pushed row is a live case in the network. Also the first event of a
fresh case when a loan whose previous case had closed is re-pushed — even
in a batch byte-identical to one you sent before — the caseId changes and
sequence restarts at 1.
{ "sourceLoanNumber": "LN-2026-0001", "caseId": "CC-2026-2388ED42", "status": "pending" }
| Field | Value |
|---|---|
status | always "pending" |
case.assigned (opt-in)
A partner now holds the case: a field partner accepted it, or Fieldproof
operations or your organisation dashboard
assigned or reassigned one. It fires again on every reassignment. External
status becomes in_progress. Unassigning emits nothing — the case silently
returns to pending.
{ "sourceLoanNumber": "LN-2026-0001", "caseId": "CC-2026-2388ED42", "assignedAt": "2026-08-17T06:10:11.000Z" }
visit.completed (opt-in)
A partner submitted a visit, whatever the outcome. The case stays
in_progress unless a case.closed follows.
{
"sourceLoanNumber": "LN-2026-0001",
"caseId": "CC-2026-2388ED42",
"outcome": "delivered_acknowledged",
"visitAt": "2026-08-17T08:45:02.000Z",
"ptpDate": "2026-08-20",
"notes": "Will pay after salary credit"
}
| Field | Type | Notes |
|---|---|---|
outcome | enum | See table below. |
visitAt | ISO datetime | When the visit was submitted (partner check-out). |
ptpDate | string | null | YYYY-MM-DD (IST). Set when the borrower promised to pay by that date (outcome: "delivered_acknowledged"), null otherwise. |
notes | string | null | Partner's free-text note, truncated to 500 characters; may be null. |
outcome values:
outcome | Meaning |
|---|---|
delivered_acknowledged | Payment notice delivered and acknowledged by the borrower. With ptpDate set, this is a promise to pay by that date |
collected | Money collected in full during the visit |
partial | Part of the outstanding collected |
rtp | Refuse to pay — the borrower declined to pay |
refused | Borrower refused (also a case.closed reason when the case ends this way) |
not_available | Borrower not found at the address this visit |
wrong_address | Address does not lead to the borrower — push a corrected row |
shifted | Borrower has moved — push a corrected row |
There is no ptp outcome: it is no longer sent. A promise to pay is
delivered_acknowledged with ptpDate.
A collected or partial outcome is the partner's claim; money is only
announced by payment.collected once it is verified/booked (below). A
Mode A visit that submits a payment proof emits no
visit.completed at all — the payment.collected after verification is its
signal (and a rejected proof emits nothing).
payment.collected
Money received on your rails and booked against the case. Fires per payment, with both the incremental amount and the absolute case totals.
{
"sourceLoanNumber": "LN-2026-0001",
"caseId": "CC-2026-2388ED42",
"amount": 3000,
"mode": "upi",
"reference": "UTR2026081712345",
"totalCollected": 3000,
"outstandingAfter": 9500,
"collectedAt": "2026-08-17T09:30:12.000Z"
}
| Field | Type | Notes |
|---|---|---|
amount | number (INR) | Incremental — this payment only. |
mode | enum | upi | card | netbanking | wallet | online | link | neft | nach | cheque | razorpay | other. Never cash: partners cannot take cash on your cases, and a cash or IMPS payment our collection team records is sent as other. |
reference | string | null | UTR / gateway payment id. null when no reference could be captured. |
totalCollected | number (INR) | Absolute total collected on this case after this payment. |
outstandingAfter | number (INR) | Absolute outstanding on this case after this payment. |
collectedAt | ISO datetime | When the payment happened according to its source — not when we booked it. Mode A: the partner's visit check-out time. Mode B: the paidAt you sent (or when we received your confirm, if you omitted it). Recorded by our collection team: the payment date they entered (or when they recorded it, if none). Direct close: when our team recorded it. |
When it fires and what mode / reference carry:
- Mode A — static QR: after our operations team
verifies the partner's payment proof.
amountis the operator-confirmed figure — it may differ from what the partner claimed; trust the event.modeis the partner-recorded mode (usuallyupi);referenceis the UTR where extracted. Rejected proofs emit nothing. - Mode B — your gateway: the event mirrors your
own
POST /cases/{loan}/payments/confirm.modeechoes the mode you sent (defaultlink);referenceis the gateway payment id you sent. - Booked by Fieldproof's collection team (a payment taken outside the
partner flow). Record payment:
modeis the mode they recorded when it is in the list above, otherwise"other"(e.g. cash, IMPS);referenceis the UTR / reference they entered, ornull. If that payment completes the case, acase.closedfollows. Direct close:modeis"other"andreferenceisnull, sent only if they recorded an amount; acase.closedalways follows. In both, thepayment.collectedhas the lowersequence.
Money is never announced before it is booked. Use the absolute values to set
your loan record; use amount for your ledger row.
case.closed
The case is closed as of now. Normally it is the last event of a case,
but Fieldproof operations can re-open a closed_unrecovered case under the
same caseId (resolving an admin review, resetting visit attempts or
correcting the address), and more events can follow. Re-opening itself sends
no event. If the case then closes again, you receive a new case.closed
(new eventId, higher sequence) when it closes a different way or when any
other event was recorded on the case since the last closure; a closure that
repeats the previous one with nothing in between is treated as a retry. A
closed_unrecovered case can also get a second case.closed without being
re-opened, when Fieldproof later writes it off or hands it over for legal
action (new reason, higher sequence). Apply any later event for the same
caseId (higher sequence) like any other: it supersedes the closure.
{
"sourceLoanNumber": "LN-2026-0001",
"caseId": "CC-2026-2388ED42",
"status": "collected",
"reason": "full_settlement",
"totalCollected": 12500
}
| Field | Type | Notes |
|---|---|---|
status | enum | collected | closed_unrecovered |
reason | enum | full_settlement | closed_by_operations | recalled | not_available | refused | written_off | legal_handover |
totalCollected | number (INR) | Absolute total collected on this case at closure. |
reason | status | Trigger |
|---|---|---|
full_settlement | collected | Payments reached full clearance (≥ 99 % of the remaining outstanding — 1 % tolerance), including a payment our collection team recorded, or a direct close, that cleared the balance. Preceded by a payment.collected. |
closed_by_operations | collected | Fieldproof's collection team closed the case with a balance left (collected outside the partner flow): a direct close with a balance left or no amount recorded, or a recorded payment they closed short of full clearance. Preceded by a payment.collected only if an amount was recorded. Any remaining balance is not reported as collected — reconcile it against your own records. On a direct close, outstandingAfter (and the snapshot's outstandingAmount) still shows the balance left; on a recorded payment the team closed short, both are 0. No case fee is billed for it. |
recalled | closed_unrecovered | You called POST /cases/{loan}/recall. |
not_available | closed_unrecovered | The visit limit was reached and the last visit could not find the borrower. |
refused | closed_unrecovered | The visit limit was reached and the last visit found the borrower refusing. |
written_off | closed_unrecovered | Written off — an admin action on the Fieldproof side. |
legal_handover | closed_unrecovered | Handed over for legal action — an admin action on the Fieldproof side. |
A closed loan can be re-pushed (even in a byte-identical batch): that opens a
new case with a new caseId and sequence restarting at 1 (you will see
case.received again).
verification.completed
:::note Address-verification product only
verification.completed belongs to the address-verification product. It is
not emitted for collection cases — if you only run collections you will
never receive it, and you can ignore the type. Its payload is documented with
that product.
:::
ping
Sent only by the webhook self-test (POST /api/v1/partner/webhooks/test).
The envelope is the same shape, but sourceLoanNumber and sequence are
null and the eventId is evt_ping_ + 16 hex chars:
{
"eventId": "evt_ping_9c4e21a7b3d05f18",
"type": "ping",
"occurredAt": "2026-08-17T09:00:00.000Z",
"version": 1,
"lenderCode": "YOURORG",
"sourceLoanNumber": null,
"sequence": null,
"payload": { "message": "<free text>" }
}
Verify the signature, return 2xx, apply nothing. Your JSON model must accept
the nulls.
Ready to consume these? Updating your LMS from our events has the complete, transactional apply handler with SQL.