POST /api/public/v1/lead-results
Reports what your sales team did with a lead after the call: they could not reach the person, spoke to them, booked an appointment, sent a quote, made the sale, lost it, or saw a sale cancelled. Each result is tracked back to the call that produced the lead, so the dashboard can show which campaigns, agents and scripts lead to sales rather than only to "interested".
| Property | Value |
|---|---|
| Method | POST |
| Path | /api/public/v1/lead-results |
| Scopes | lead_result:write |
| Org kind | Seller organizations only |
| Cost | 10 rate-limit tokens |
| Results | 1–500 per request |
| Retries | Safe without a key; a re-sent result is a duplicate |
Results are appended, never edited. Send a new result each time a deal moves; the latest one, by occurred_at, is its current stage. There is no route that changes or removes one.
Matching a result to its call
Every call_outcome.submitted delivery carries call.id. Store it on the lead in your CRM and send it back as call_id — that is an exact match, and nothing else about the row is consulted.
If you do not have it, send phone instead: the number we dialled. The result is then matched to the latest lead call to that number — a call filed as interested, or put through to you as transferred — made between 90 days before occurred_at and one day after it. Send campaign_id as well to look only at that campaign's calls.
A result that no call matches is still recorded, unmatched, and answered as such. It is kept so it can be placed later; it counts in no report until then.
Request
json
{
"results": [
{
"call_id": "5c9d3a70-1e84-4b2f-9c6a-8d0f1e3b7a25",
"deal_id": "HS-88213",
"stage": "won",
"value": 1499.0,
"occurred_at": "2026-10-20T14:30:00+02:00"
},
{
"phone": "082 123 4567",
"stage": "lost",
"reason": "unreachable",
"occurred_at": "2026-10-21"
}
]
}| Field | Type | Required | Notes |
|---|---|---|---|
call_id | string (uuid) | Or phone | call.id from the webhook. An id that is not one of your organization's calls is refused. |
phone | string | Or call_id | The number we dialled, ≤ 40 characters. A national number is read as South African. Ignored when call_id is sent. |
campaign_id | string (uuid) | No | Narrows a phone match to one campaign. |
deal_id | string | No | Your CRM's id for the deal or opportunity, ≤ 255 characters. Keep it the same as the deal moves through stages. See below. |
stage | string | Yes | See stages. |
reason | string | No | Why there was no sale, or why it was cancelled. Only on the stages listed against it in reasons. |
value | number | No | The sale's value in rand, at most two decimal places, up to 10 000 000. Only on won. |
occurred_at | string | No | When it happened in your CRM: ISO 8601 with an offset (2026-10-20T14:30:00+02:00, …Z), or a date (2026-10-20, read as midnight in South Africa). Defaults to when we received it. |
null is the same as leaving a field out.
Send occurred_at. It decides which result is a deal's current stage when they arrive out of order, and which call a phone match looks for. A time without an offset is refused, since it would be read in a zone you did not choose. A time more than five minutes in the future is refused per row.
Stages
stage | Meaning | Closed |
|---|---|---|
attempting | Your team has tried and not yet spoken to them. | No |
contacted | Your team has spoken to them; nothing is decided. | No |
appointment | A meeting, visit or follow-up call is booked. | No |
quoted | They have a quote or proposal. | No |
won | They bought. The only stage that carries a value. | Yes |
lost | Closed without a sale, including never reaching them. | Yes |
cancelled | A sale that was reversed: cooled off, never paid, refunded. | Yes |
The dashboard's close rate is won over the leads whose latest result is closed, so a deal still being worked does not count against it.
Reasons
reason | Meaning | On |
|---|---|---|
unreachable | Couldn't reach them | lost |
wrong_number | Wrong or dead number | lost |
no_recollection | Didn't ask to be contacted | lost |
not_interested | Not interested | lost, cancelled |
not_qualified | Didn't qualify | lost, cancelled |
price | Price | lost, cancelled |
competitor | Went with someone else | lost, cancelled |
timing | Not now | lost |
existing_customer | Already a customer | lost |
cooling_off | Cancelled within the cooling-off period | cancelled |
not_taken_up | Not taken up: the first payment never cleared | cancelled |
other | Other | lost, cancelled |
Deals
A call usually produces one deal, and you can leave deal_id out. When one call leads to two sales — two policies, two products — send each under its own deal_id: each keeps its own current stage, and both values count. Results without a deal_id are one deal per call.
Response
200, with one answer per result, in the order you sent them:
json
{
"data": {
"results": [
{
"index": 0,
"status": "recorded",
"call_id": "5c9d3a70-1e84-4b2f-9c6a-8d0f1e3b7a25",
"matched_by": "call",
"error": null
},
{
"index": 1,
"status": "recorded",
"call_id": null,
"matched_by": null,
"error": null
}
],
"counts": { "matched": 1, "unmatched": 1, "duplicate": 0, "refused": 0 }
}
}| Field | Type | Meaning |
|---|---|---|
index | number | The result's position in your results array, from 0. |
status | string | recorded, duplicate (an identical result is already held; nothing written) or refused. |
call_id | string | null | The call it was matched to. null on a recorded result means it is held unmatched. |
matched_by | string | null | call (your call_id) or phone (the fallback). null when unmatched or refused. |
error | object | null | On refused only: { code, message }. Branch on code. |
error.code | When |
|---|---|
CALL_NOT_FOUND | call_id is not one of your organization's calls. |
INVALID_PHONE | No call_id, and phone is not a valid number. |
FUTURE_TIME | occurred_at is more than five minutes ahead. Check its offset. |
A refused result writes nothing; the rest of the request is recorded.
Re-sending is safe. A result is a duplicate when everything it says — the call, deal_id, stage, reason, value and occurred_at as you sent it — matches one already held. So a nightly job that re-sends every open deal records only what changed. One consequence: without occurred_at, a deal that goes won, then cancelled, then won again records the second won as a duplicate. Send times.
Errors
| Status | Code | When |
|---|---|---|
400 | INVALID_INPUT | No results or more than 500; a row with neither call_id nor phone; an unknown stage or reason; a reason or value on a stage that does not take it. Carries path. |
403 | FORBIDDEN | The key lacks lead_result:write, or the org is not a seller. |
413 | TOO_LARGE | The body is over 512 kB. |
429 | TOO_MANY_REQUESTS | The key's rate limit is spent. See rate limits. |
A 400 refuses the whole request — nothing in it was recorded. See errors for the envelope.
Source
| Value | Source |
|---|---|
| Request and response shapes | LeadResultSchema.api, LeadResultSchema.api_result in src/lib/schema/lead_result/lead_result.schema.ts |
| Stages, reasons | LEAD_RESULTS.STAGE, LEAD_RESULTS.REASON in src/lib/const/lead_result.const.ts |
| Match window, future skew | LEAD_RESULTS.MATCH, LEAD_RESULTS.FUTURE_SKEW_MS |
| Per-request bound, cost, body | LEAD_RESULTS.CHUNK_ROWS, LEAD_RESULTS.API_COST, LEAD_RESULTS.MAX_API_BODY_BYTES |
| Lead dispositions | CAMPAIGNS.DISPOSITION.LEAD in src/lib/const/campaign.const.ts |
| Refusal codes | REFUSAL in src/lib/server/services/lead_result/lead_result.service.ts |