Skip to main content
Call end events are triggered when a call terminates, either normally (realtime.call.ended) or through a hangup (realtime.call.hangup or realtime.call.hungup). These events are essential for cleanup and releasing capacity.

Event Types

Three event types trigger call end handling:
type
string
required
One of:
  • realtime.call.ended
  • realtime.call.hangup
  • realtime.call.hungup

Event Structure

id
string
required
Unique webhook ID for deduplication
type
string
required
Event type (see above)
data
object
required
Event data object
data.call_id
string
required
Unique identifier for the call that ended

Processing Flow

Call end events follow a simpler flow than incoming calls:

1. Webhook Verification

Validates the webhook signature:
event = client.webhooks.unwrap(
    raw_body,
    headers=headers,
    secret=settings.openai_webhook_secret.get_secret_value(),
)

2. Deduplication Check

Prevents duplicate processing using webhook ID cache (30-minute TTL).

3. Call ID Validation

Ensures the webhook includes a call ID:
if not call_id:
    return JSONResponse(
        status_code=status.HTTP_200_OK,
        content={"ok": True, "ignored": True, "reason": "missing_call_id"},
    )

4. Capacity State Cleanup

Releases the call from capacity tracking:
# Release from pending state
await _release_pending_capacity_state(request, call_id)

# Release from accepted state
async with request.app.state.capacity_lock:
    request.app.state.accepted_call_ids.pop(call_id, None)
This cleanup affects multiple state structures:
  • pending_call_ids: set[str] - Removes call from pending set
  • pending_tenant_by_call_id: dict[str, str] - Removes tenant mapping
  • pending_by_tenant: dict[str, set[str]] - Removes call from tenant’s pending set
  • accepted_call_ids: dict[str, float] - Removes call from accepted tracking

5. Stop Call Session

Stops the active call session:
call_manager = request.app.state.call_manager
await call_manager.stop_call(call_id, reason="call_ended")
The CallManager handles:
  • Closing WebSocket connections
  • Stopping audio streams
  • Cleaning up resources
  • Recording final metrics

Response Format

Successful Handling

{
  "ok": true
}

Missing Call ID

{
  "ok": true,
  "ignored": true,
  "reason": "missing_call_id"
}

Processing Error

{
  "ok": false,
  "error": "Error description"
}

Implementation Details

Capacity Lock

All capacity state modifications use an async lock to prevent race conditions:
async with request.app.state.capacity_lock:
    request.app.state.pending_call_ids.discard(call_id)
    # ... other state cleanup

Graceful Error Handling

Even if cleanup fails, the webhook returns 200 OK to prevent retries:
try:
    await call_manager.stop_call(call_id, reason="call_ended")
    log_event(logging.INFO, "call_ended_handled", call_id)
    return JSONResponse(status_code=status.HTTP_200_OK, content={"ok": True})
except Exception as e:
    log_event(logging.ERROR, "call_ended_handling_failed", call_id, error=str(e))
    return JSONResponse(
        status_code=status.HTTP_200_OK, 
        content={"ok": False, "error": str(e)}
    )

Pending State Release

The _release_pending_capacity_state function handles complex state cleanup:
async def _release_pending_capacity_state(request: Request, call_id: str) -> None:
    async with request.app.state.capacity_lock:
        # Remove from global pending set
        request.app.state.pending_call_ids.discard(call_id)
        
        # Get and remove tenant mapping
        tenant = request.app.state.pending_tenant_by_call_id.pop(call_id, None)
        
        if tenant:
            # Remove from tenant's pending set
            tenant_pending = request.app.state.pending_by_tenant.get(tenant)
            if tenant_pending:
                tenant_pending.discard(call_id)
                
                # Clean up empty tenant pending sets
                if not tenant_pending:
                    request.app.state.pending_by_tenant.pop(tenant, None)

Event Sequence Example

Normal Call Flow

  1. realtime.call.incoming → Call accepted
  2. Call session starts and runs
  3. User hangs up
  4. realtime.call.ended → Cleanup triggered

Failed Start Flow

  1. realtime.call.incoming → Call accepted
  2. Session start fails
  3. System calls hangup API
  4. realtime.call.hangup → Cleanup triggered

Example Webhook Payload

{
  "id": "webhook_end_abc123",
  "type": "realtime.call.ended",
  "data": {
    "call_id": "call_xyz789"
  }
}

Testing with cURL

You must generate a valid webhook signature using your webhook secret.
curl -X POST https://your-domain.com/api/v1/openai/webhook \
  -H "Content-Type: application/json" \
  -H "openai-webhook-signature: SIGNATURE_HERE" \
  -d '{
    "id": "webhook_test_end123",
    "type": "realtime.call.ended",
    "data": {
      "call_id": "call_test456"
    }
  }'

Monitoring and Logging

Successful Handling

log_event(logging.INFO, "call_ended_handled", call_id)

Error Cases

log_event(logging.ERROR, "missing_call_id", "Call ID is missing in call ended event")
log_event(logging.ERROR, "call_ended_handling_failed", call_id, error=str(e))

Metrics Recording

Call end events typically trigger final metrics recording through the CallManager:
await metrics_store.record_end(
    tenant_id=tenant_id,
    call_id=call_id,
    end_reason=EndReason.NORMAL  # or EndReason.ERROR, EndReason.HANGUP, etc.
)
End reasons tracked:
  • EndReason.NORMAL - Call ended normally
  • EndReason.ERROR - Call ended due to error
  • EndReason.HANGUP - Call was hung up
  • EndReason.TIMEOUT - Call timed out

Capacity Recovery

Call end events are critical for capacity management:
  1. Immediate recovery: Capacity is released as soon as the event is processed
  2. Slot availability: Released slots become available for new incoming calls
  3. Per-tenant tracking: Both tenant-specific and global capacity are updated

Capacity State After Cleanup

After processing a call end event:
# Call is removed from all tracking structures
call_id not in request.app.state.pending_call_ids
call_id not in request.app.state.pending_tenant_by_call_id
call_id not in request.app.state.accepted_call_ids

# Tenant's pending count is decremented
len(request.app.state.pending_by_tenant.get(tenant_id, set())) -= 1

Best Practices

  1. Always return 200 OK: Even on errors, return success to prevent webhook retries
  2. Log all events: Call end events are important for debugging and monitoring
  3. Clean up thoroughly: Ensure all capacity state is released to prevent leaks
  4. Handle missing calls gracefully: A call might have already been cleaned up
  5. Monitor cleanup failures: Track when call_manager.stop_call() fails

Error Scenarios

Call Already Cleaned Up

If a call was already stopped (e.g., due to error handling), the end event still processes successfully:
# CallManager handles missing calls gracefully
await call_manager.stop_call(call_id, reason="call_ended")  # No error if already stopped

Duplicate End Events

Deduplication prevents processing the same webhook twice, but different event types (call.ended vs call.hangup) for the same call will both be processed. The second one is a no-op since the call is already cleaned up.

Webhook Retry Behavior

OpenAI’s webhook system retries failed webhooks (non-200 responses). Since call end events always return 200 OK, retries are prevented even on internal errors. This is intentional to avoid duplicate cleanup.

Webhooks Overview

Learn about webhook authentication and event types

Incoming Calls

Handle incoming call events with capacity gating

Build docs developers (and LLMs) love