Skip to content

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".

PropertyValue
MethodPOST
Path/api/public/v1/lead-results
Scopeslead_result:write
Org kindSeller organizations only
Cost10 rate-limit tokens
Results1–500 per request
RetriesSafe 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"
    }
  ]
}
FieldTypeRequiredNotes
call_idstring (uuid)Or phonecall.id from the webhook. An id that is not one of your organization's calls is refused.
phonestringOr call_idThe number we dialled, ≤ 40 characters. A national number is read as South African. Ignored when call_id is sent.
campaign_idstring (uuid)NoNarrows a phone match to one campaign.
deal_idstringNoYour CRM's id for the deal or opportunity, ≤ 255 characters. Keep it the same as the deal moves through stages. See below.
stagestringYesSee stages.
reasonstringNoWhy there was no sale, or why it was cancelled. Only on the stages listed against it in reasons.
valuenumberNoThe sale's value in rand, at most two decimal places, up to 10 000 000. Only on won.
occurred_atstringNoWhen 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 ​

stageMeaningClosed
attemptingYour team has tried and not yet spoken to them.No
contactedYour team has spoken to them; nothing is decided.No
appointmentA meeting, visit or follow-up call is booked.No
quotedThey have a quote or proposal.No
wonThey bought. The only stage that carries a value.Yes
lostClosed without a sale, including never reaching them.Yes
cancelledA 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 ​

reasonMeaningOn
unreachableCouldn't reach themlost
wrong_numberWrong or dead numberlost
no_recollectionDidn't ask to be contactedlost
not_interestedNot interestedlost, cancelled
not_qualifiedDidn't qualifylost, cancelled
pricePricelost, cancelled
competitorWent with someone elselost, cancelled
timingNot nowlost
existing_customerAlready a customerlost
cooling_offCancelled within the cooling-off periodcancelled
not_taken_upNot taken up: the first payment never clearedcancelled
otherOtherlost, 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 }
  }
}
FieldTypeMeaning
indexnumberThe result's position in your results array, from 0.
statusstringrecorded, duplicate (an identical result is already held; nothing written) or refused.
call_idstring | nullThe call it was matched to. null on a recorded result means it is held unmatched.
matched_bystring | nullcall (your call_id) or phone (the fallback). null when unmatched or refused.
errorobject | nullOn refused only: { code, message }. Branch on code.
error.codeWhen
CALL_NOT_FOUNDcall_id is not one of your organization's calls.
INVALID_PHONENo call_id, and phone is not a valid number.
FUTURE_TIMEoccurred_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 ​

StatusCodeWhen
400INVALID_INPUTNo 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.
403FORBIDDENThe key lacks lead_result:write, or the org is not a seller.
413TOO_LARGEThe body is over 512 kB.
429TOO_MANY_REQUESTSThe 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 ​

ValueSource
Request and response shapesLeadResultSchema.api, LeadResultSchema.api_result in src/lib/schema/lead_result/lead_result.schema.ts
Stages, reasonsLEAD_RESULTS.STAGE, LEAD_RESULTS.REASON in src/lib/const/lead_result.const.ts
Match window, future skewLEAD_RESULTS.MATCH, LEAD_RESULTS.FUTURE_SKEW_MS
Per-request bound, cost, bodyLEAD_RESULTS.CHUNK_ROWS, LEAD_RESULTS.API_COST, LEAD_RESULTS.MAX_API_BODY_BYTES
Lead dispositionsCAMPAIGNS.DISPOSITION.LEAD in src/lib/const/campaign.const.ts
Refusal codesREFUSAL in src/lib/server/services/lead_result/lead_result.service.ts