Voicetta
    Voicetta.

    Voicetta Developers

    Thread Events

    The thread.completed event for graded SMS and WhatsApp conversations: full payload, the complete close_reason vocabulary, when it fires, and what it omits.

    Thread Events

    thread.completed is the graded record of a finished SMS or WhatsApp conversation: transcript, summary, evaluation verdicts and cost, in one object. Where the message events tell you what is being said, this tells you how the conversation went.

    Headers, signatures, retries and the shared envelope are on the webhook reference. This page is the payload, the timing, and the one field you must not ignore.

    When it fires

    Not when the last message arrives — when the conversation is final.

    A conversation closes after a period of inactivity, 30 minutes by default. If our evaluations found a gap, we may send a recovery follow-up first and wait up to 24 hours for a reply. Grading runs at close, which is why the summary and the verdicts only exist from that moment and not before.

    So expect this event roughly half an hour after the conversation goes quiet, and considerably later when a follow-up was involved. If you need to know something is happening now, that is what the message events are for.

    Payload

    json
    { "event": "thread.completed", "event_id": "c82fa013-5b7d-4029-8e4f-8d3c6b5f9a42", "version": 1, "occurred_at": "2026-08-20T14:45:00+00:00", "thread": { "thread_id": "c82fa013-5b7d-4029-8e4f-8d3c6b5f9a42", "workspace_id": "2a1f8e7d-4c3b-4a29-9f18-6d5e4c3b2a19", "assistant_id": "7c6b5a49-3e2d-4c1b-8a97-5f4e3d2c1b09", "assistant_name": "Front Desk", "channel": "whatsapp", "close_reason": "graded", "message_count": 6, "started_at": "2026-08-20T14:12:00+00:00", "last_message_at": "2026-08-20T14:19:00+00:00", "completed_at": "2026-08-20T14:45:00+00:00", "anchor_message_id": "b71e9d02-4a6c-4f18-9d3e-7c2b5a4e8f31", "customer": { "customer_id": "5d4c3b2a-1f09-4e8d-7c6b-5a493e2d1c0b", "name": "Jane Texter", "phone": "+15551234567" }, "transcript": [ { "role": "user", "content": "Do you have a room free on Friday?" }, { "role": "assistant", "content": "Yes, we have a double available." } ], "analysis": { "summary": "Customer asked about Friday availability and held a double room.", "intent": "booking_enquiry", "outcome": "booked", "sentiment": "positive" }, "evaluations": [ { "evaluation_id": "3e2d1c0b-5a49-4f8e-9d7c-6b5a493e2d1c", "title": "Offered a booking reference", "result": "pass", "initial_result": "fail", "rationale": "The reference was given after the follow-up.", "recovered": true } ], "costs": { "currency": "USD", "total": "0.0300", "charged_message_count": 3 }, "follow_ups": [ { "status": "sent", "channel": "whatsapp", "sent_at": "2026-08-20T14:40:00+00:00" } ] } }

    Reading the fields

    • thread_id is the same value the message events carry, so the graded record joins straight onto the live feed with no lookup table.
    • anchor_message_id is the message the grade is stored against. Fetch it from /v1/messages under that id if you want the underlying record.
    • completed_at is when grading finished, and is the value occurred_at reports. Sort on it, not on arrival order.
    • last_message_at is when the conversation actually went quiet. The gap between the two is the inactivity window, and it is normal for that to be half an hour or more.
    • message_count counts the messages in the transcript.
    • evaluations[].recovered is true when a criterion initially failed and passed after a recovery follow-up. Compare initial_result with result to report on how often recovery works.

    Costs are indicative, not billed

    costs.total is a per-channel rate multiplied by charged_message_count, computed when the payload is built. Text messaging is not debited from your wallet per message, so this figure reconciles against nothing. Show it as an approximation; bill from your invoice.

    close_reason: read it, do not ignore it

    Every close is delivered, including the ones where our own grading failed. That is a deliberate choice, and it puts one obligation on you.

    Several of the reasons below produce a thread with no summary and no verdicts. In a dashboard that is indistinguishable from a conversation that was graded and passed nothing — which is a very different fact about your business. close_reason is how you tell them apart.

    ReasonWhat happenedGraded?
    gradedThe normal path: inactivity reached, evaluations ranYes
    no_reply_after_follow_upA recovery follow-up was sent and never answeredYes
    customer_opted_outThe customer opted out of messagingPartly
    no_enabled_evaluationsThe assistant has no evaluations switched onNo
    empty_transcriptNothing gradeable was found in the conversationNo
    grading_failedOur grading ran out of attemptsNo
    follow_up_failedThe recovery follow-up ran out of attemptsNo
    follow_up_daily_capThe per-customer daily follow-up cap was already reachedNo
    follow_up_no_criteriaNothing failed, so there was nothing to recoverPartly
    follow_up_no_customer_phoneNo number to send a follow-up toNo
    follow_up_missing_openai_keyFollow-up generation was not configuredNo
    follow_up_send_ambiguousThe send could not be confirmed either wayNo
    follow_up_skipped:<reason>The follow-up was skipped; the suffix says whyNo

    A practical shape for a quality report: count graded and no_reply_after_follow_up as your denominator, and surface the rest as "not graded" rather than folding them into a pass rate. Otherwise a configuration mistake on our side quietly depresses a number you are managing your team on.

    The vocabulary grows as the product does, so treat an unfamiliar value as "closed for some other reason" rather than as an error.

    Why we do not filter these out

    Withholding the ungraded closes would be the tidier-looking option, and it would mean a conversation that really happened never reached you because our grading failed — with no way for you to discover it existed. Sending everything with an honest reason attached leaves the judgement where it belongs.

    The one thing we never send is a conversation containing no messages at all. That carries no information whatever its close reason.

    What a thread payload does not carry

    No recording, no latency measurement, no configuration snapshot. Those describe an audio pipeline that a text conversation never goes through, so those blocks are absent rather than empty. The dashboard greys them out while the thread event is selected.

    No is_test either: threads never contain test conversations, so the field could only ever be false.

    Reading threads instead of receiving them

    GET /v1/threads returns the same objects on demand, filtered by date, assistant, channel or close_reason. Full parameters in the read API reference.

    close_reason=graded is the quick way to build a report over conversations that actually produced a verdict, while leaving the rest available when you want to know how many there were.