Skip to main content

Conversation finished webhook

When a voice conversation finishes, Stellar POSTs a small JSON event to an HTTPS endpoint you run, so you can track and reconcile call usage without polling. The event carries identifiers and call-detail fields: which agent handled the call, when it started and ended, how much of it counts for billing, and how it was routed.

The event deliberately carries no conversation content. Transcripts, summaries, topics, evaluations and recordings stay behind the Public API, which you can call with the conversationId from the event.

When the event fires

One event is emitted per voice conversation, right after the platform records the conversation as finished, usually a second or two after the call ends.

Every voice conversation in your organization is in scope. There is no filtering on our side: Playground sessions, Development and Staging conversations, and calls from every telephony source all produce an event. Each event carries environment and source so you can filter to the ones you care about.

Two things to know before you build against the feed:

  • It's forward-only. Only conversations that finish after you save the endpoint produce events. Older conversations are never delivered, and there is no backfill.
  • It isn't a guaranteed ledger. Nearly every finished conversation notifies, but in exceptional cases a notification may not be delivered. Events can also be dropped after a long outage on your side (see Coverage and limits).
info

Events are scoped to the organization that owns the conversation. If you work with sub-organizations, configure an endpoint on each one: an endpoint on the parent organization does not receive its children's conversations.

Configuring an endpoint

Open Settings from the main menu and go to Webhooks. An organization admin can register up to five endpoints, each with:

  • Name: a label, unique within your organization.
  • URL: where we POST. https is required, and the host must resolve to a public address. Private and internal addresses are rejected when you save, and re-checked immediately before every delivery.
  • Event types: select Conversation finished. An endpoint only receives the event types it subscribes to.
  • Static headers: optional fixed headers sent with every request, for endpoints fronted by a gateway that wants its own API key. Values are stored encrypted and are never shown again after you save them.
  • Enabled: switch deliveries off without deleting the endpoint.

Static headers are validated when you save. At most 10 per endpoint. A name must start with a letter and contain only letters, digits and hyphens, up to 100 characters. A value can be up to 1,000 characters and cannot contain line breaks. Names may not start with Stellar-, and may not collide with the headers we set ourselves: Content-Type, User-Agent, Content-Length, Host, Transfer-Encoding and Connection.

The signing secret

Saving a new endpoint generates a signing secret and shows it once. Copy it into your own configuration before closing the dialog. No screen and no API returns it again.

If you lose it, use Rotate secret. Rotation shows a new value once and replaces the old one immediately: there is no overlap period during which both secrets verify, so deliveries signed with the new secret fail verification until you deploy it. Rotate at a moment when you can ship the new value straight away.

Sending a test event

Send test event posts a real, fully signed envelope to your URL and shows the HTTP status code you returned. The test payload uses the all-zeros UUID 00000000-0000-0000-0000-000000000000 as its conversationId. Treat that value as a test marker and don't try to look it up. Test deliveries don't touch the endpoint's delivery status.

Delivery status

Each endpoint shows the time of its last delivery attempt, the status code it returned, a truncated snippet of the response body when the attempt failed, and a count of consecutive failures. Endpoints are never disabled automatically, so a broken endpoint keeps being tried; the consecutive-failure count is the number to watch.

Request format

Every delivery is a POST with a JSON body and these headers, plus whatever static headers you configured:

POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Stellar-Webhook/1
Stellar-Event-Id: 6f1b7a1e-6a54-4c1f-9c0e-2c0f21e6a1d3
Stellar-Event-Type: voice.conversation.finished
Stellar-Signature: t=1755500000,v1=9f2c1b0a7e4d3c2b1a09f8e7d6c5b4a3928170f6e5d4c3b2a1908f7e6d5c4b3a2

Stellar-Event-Id and Stellar-Event-Type repeat eventId and type from the body, so you can route or drop a delivery without parsing it.

Payload

{
"eventId": "6f1b7a1e-6a54-4c1f-9c0e-2c0f21e6a1d3",
"type": "voice.conversation.finished",
"occurredAt": "2026-08-18T09:31:02.114Z",
"data": {
"conversationId": "0d0b3c8a-1f2e-4a5b-8c7d-6e5f4a3b2c1d",
"callId": "4b2f1e0d-9c8b-4a7d-96e5-1f2a3b4c5d6e",
"tenantId": "8a7b6c5d-4e3f-4a2b-91c0-d1e2f3a4b5c6",
"tenantName": "Acme BV",
"agentId": "2c3d4e5f-6a7b-48c9-90d1-e2f3a4b5c6d7",
"agentName": "Reception NL",
"channel": "VOICE",
"source": "SIP",
"environment": "PRODUCTION",
"startedAt": "2026-08-18T09:30:15.000Z",
"finishedAt": "2026-08-18T09:31:02.000Z",
"durationSeconds": 27,
"billableSeconds": 30,
"startingFee": 0.1,
"completionReason": "CALLER_HUNG_UP",
"routing": {
"agentConnectorId": "5d6e7f8a-9b0c-4d1e-82f3-a4b5c6d7e8f9",
"agentConnectorName": "Amsterdam office",
"agentConnectorType": "SIP",
"tenantSipFallback": false,
"flowConnectorId": null,
"flowConnectorName": null,
"dialedIdentifier": "+31201234567"
}
}
}

Field reference

The envelope identifies the delivery:

FieldTypeDescription
eventIdstring (UUID)Identifies this event. Repeated on every retry of the same event, and shared across your endpoints. Your dedupe key.
typestringAlways voice.conversation.finished for this event.
occurredAtstring (ISO 8601, UTC)When we produced the event. Stable across retries, so it is not a freshness signal; use the signature timestamp for that.
dataobjectThe conversation record below.

data holds the conversation:

FieldTypeDescription
conversationIdstring (UUID)The conversation. Use it against the Public API to fetch content.
callIdstring (UUID) or nullThe telephony call record. Present for calls over our SIP gateway and for outbound dialing; null for every other source.
tenantIdstring (UUID)The organization that owns the conversation.
tenantNamestringThat organization's name at the time of delivery. Names can change, so match on tenantId rather than on this.
agentIdstring (UUID)The agent that handled the conversation.
agentNamestringThat agent's name at the time of delivery.
channelstringAlways VOICE. Chat and email conversations do not produce this event.
sourcestringWhere the conversation came from; see the values below.
environmentstringPRODUCTION, STAGING or DEVELOPMENT, matching the agent environment that ran.
startedAtstring (ISO 8601, UTC)Start of the conversation.
finishedAtstring (ISO 8601, UTC)End of the conversation. Sort on this field, not on arrival order.
durationSecondsnumberfinishedAt - startedAt in seconds, not rounded. Fractional when the timestamps carry sub-second precision, and never negative.
billableSecondsintegerThe conversation's contribution to your invoiced minutes. See Reconciling billable usage. This is seconds, not minutes.
startingFeenumberThe per-conversation start tariff that applies to this conversation, in EUR.
completionReasonstring or nullHow the conversation ended; see the values below. null when the platform recorded no reason.
routingobjectHow the call reached the agent. See The routing block.

source is one of SIP (inbound over our SIP gateway), OUTBOUND_CALL, GENESYS, TWILIO, STEAM, WEB_WIDGET, PUBLIC_WEBSOCKET or PLAYGROUND.

completionReason is one of CALLER_HUNG_UP, AGENT_HUNG_UP, TRANSFERRED, AI_CLOSED, USER_CLOSED, SYSTEM_CLOSED, OPERATOR_TERMINATED, UNRESPONSIVE or ERROR.

tip

Treat all of these as open sets. New sources, completion reasons and connector types are added as the platform grows, so map unknown values to a catch-all rather than rejecting the event.

The routing block

routing tells you which of your configurations the call went through, which is what you need to attribute a conversation to a location, department or customer of yours.

A call reaches an agent either through a connector (a phone connector or SIP connector) or through a flow connector. Whichever applies is populated, and the other is null:

FieldTypeDescription
agentConnectorIdstring (UUID) or nullThe connector that handled the call.
agentConnectorNamestring or nullThat connector's name.
agentConnectorTypestring or nullSIP, PHONE_NUMBER, TWILIO, GENESYS or STEAM.
tenantSipFallbackbooleantrue when the connector that handled the call is your organization-wide SIP fallback connector.
flowConnectorIdstring (UUID) or nullThe flow connector that handled the call, when the call was flow-connector-driven.
flowConnectorNamestring or nullThat flow connector's name.
dialedIdentifierstring or nullThe number or SIP user part the caller dialed. Inbound calls only; always null on outbound calls.

Which fields are populated depends on source:

sourceConnectorFlow connectordialedIdentifier
SIPPopulated when the call was connector-drivenPopulated when the call was flow-connector-drivenPopulated
OUTBOUND_CALLPopulated when the call was connector-drivenPopulated when the call was flow-connector-drivennull
GENESYS, TWILIOPopulatednullnull
STEAMnullnullnull
PLAYGROUND, WEB_WIDGET, PUBLIC_WEBSOCKETnullnullnull

An entirely null routing block is normal on the last two rows, and not an error to report: Playground, widget and websocket conversations have no connector concept, and STEAM conversations carry no routing information today. Steam may start reporting a connector in a later release, so read a null there as "not available", not as "no connector".

dialedIdentifier is null on outbound calls. Attribute an outbound conversation through its connector or flow connector instead; if you need the specific outbound call it belongs to, the Public API's conversation resource carries an outboundCallId, which the event does not.

A conversation that finished before this feature shipped can report a null connector on any source.

When the connector is not the discriminator

If your organization uses an organization-wide SIP fallback connector (one connector that handles every SIP URI that doesn't match a more specific connector), then a single connector serves all of your locations. agentConnectorId and agentConnectorName are identical for every one of them and tenantSipFallback is true. In that setup dialedIdentifier is the only field that tells the locations apart, so key your attribution on it.

How dialedIdentifier is normalized

On an inbound call, dialedIdentifier is the dialed value as it arrived, with a + prefixed only when the value is all digits, at least two digits long, and does not start with 0. Everything else survives verbatim:

Dialed valuedialedIdentifier
31201234567+31201234567
200+200
00312012345670031201234567
55
amsterdam-officeamsterdam-office

So don't assume E.164. Match on the exact strings your own configuration uses, and note that a short numeric extension such as 200 reaches you as +200.

Verifying the signature

Every request carries a Stellar-Signature header:

Stellar-Signature: t=1755500000,v1=9f2c1b0a7e4d3c2b1a09f8e7d6c5b4a3928170f6e5d4c3b2a1908f7e6d5c4b3a2

t is the unix timestamp in seconds at which we sent this request, and v1 is a hex-encoded HMAC-SHA256:

signedPayload = "<t>" + "." + <raw request body>
v1 = hex(HMAC_SHA256(endpoint secret, signedPayload))

Today every request carries exactly one v1, but the header format permits more than one, and the request is authentic when any entry matches. Parse the header as a list of v1 values from the start: if we later sign with two secrets at once during a rotation overlap, a receiver written this way needs no change.

Five rules matter:

  • Sign the raw body bytes. Parsing the JSON and re-serializing it changes whitespace and key order, and the signature will never match. Capture the raw body before any body parser touches it.
  • Check every v1 entry. Accept the request when any of them matches your secret, rather than only the first or the last one you parsed.
  • Compare in constant time. Use crypto.timingSafeEqual or your language's equivalent, with a length check first.
  • Reject stale timestamps. A tolerance of 5 minutes on t is a good default.
  • Use t, not occurredAt, for freshness. t is generated fresh for each delivery attempt, so a retry that arrives 20 minutes after the conversation still passes a 5-minute window. occurredAt stays at the original publish time and would fail such a check. Each attempt is also signed with the endpoint's secret as it is at that moment, so a retry in flight across a rotation is signed with the new secret.

Here is a complete Node/TypeScript receiver on Express:

import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.STELLAR_WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 300;

function parseSignatureHeader(header: string): {
t: string | undefined;
v1: string[];
} {
let t: string | undefined;
const v1: string[] = [];
for (const segment of header.split(",")) {
const separator = segment.indexOf("=");
if (separator <= 0) continue;
const key = segment.slice(0, separator).trim();
const value = segment.slice(separator + 1).trim();
if (key === "t") t = value;
// One v1 today, but the format permits more than one, so collect them all.
if (key === "v1") v1.push(value);
}
return { t, v1 };
}

function isValidSignature(
rawBody: Buffer,
header: string | undefined,
): boolean {
if (!header) return false;

const { t, v1 } = parseSignatureHeader(header);
if (!t || v1.length === 0) return false;

const timestamp = Number(t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS)
return false;

const hmac = crypto.createHmac("sha256", SECRET);
hmac.update(`${t}.`);
hmac.update(rawBody); // the exact bytes we received, never a re-serialization
const expected = Buffer.from(hmac.digest("hex"), "utf8");

// Compare byte lengths, not string lengths: timingSafeEqual throws on unequal
// buffers, and a multibyte character makes those two lengths differ.
return v1.some((candidate) => {
const presented = Buffer.from(candidate, "utf8");
return (
presented.length === expected.length &&
crypto.timingSafeEqual(presented, expected)
);
});
}

const app = express();

// express.raw keeps the body as a Buffer so the signature can be recomputed over it.
app.post(
"/webhooks/stellar",
express.raw({ type: "application/json" }),
(req, res) => {
if (!isValidSignature(req.body, req.get("stellar-signature"))) {
return res.status(401).send("invalid signature");
}

const event = JSON.parse(req.body.toString("utf8"));

// Return 2xx first, then process. We give up on the request after 10 seconds.
res.status(204).end();

void handleEvent(event); // must be idempotent on event.eventId
},
);

Delivery semantics

Deliveries are at-least-once and unordered, which has two consequences you have to build for.

Dedupe on eventId. The same event can arrive more than once, including after you have already returned a 2xx (an acknowledgement can be lost on the way back to us). Persist the eventId values you've processed and make reprocessing a no-op. One eventId covers one conversation event across all of your endpoints, so if two endpoints feed the same system, dedupe collapses them to a single event, which is intended.

Never rely on arrival order. Two conversations finishing seconds apart can arrive in either order, and a retried event arrives after events that finished later. Order on finishedAt.

We wait 10 seconds for your response, so acknowledge first and do your processing afterwards. How we treat your response:

Your responseWhat happens
Any 2xxDelivered. Nothing more is sent for this event.
408, 429, any 5xxRetried.
A connection error, TLS failure, or no response within 10 secondsRetried.
Any other 4xxTreated as a configuration error on your side. Not retried; recorded on the endpoint.
Any 3xxRedirects are not followed. Treated like the case above and not retried. Configure the final URL directly.

Retries start about 30 seconds after a failure and back off by doubling, up to 30 minutes between attempts, for at most 6 retries, roughly 31 minutes of coverage in total, enough to ride out a deploy or a short outage.

important

After the retry budget is exhausted the event is dropped. There is no manual redelivery, so an endpoint that is down for longer than about half an hour permanently misses those events. Recover them by reading the conversations from the Public API.

Next steps

  • Reviewing conversations: the Studio view of the same conversations, including content the event omits.
  • Environments: what the environment field means and how to keep test traffic out of your production reporting.
  • Deploying your agent: connectors and flow connectors, the objects the routing block points at.