--- title: "Media Calls – Event Interfaces" slug: "media-calls-event-interfaces" updated: 2026-09-30T13:36:05Z published: 2026-09-30T13:36:05Z canonical: "developer.rocket.chat/media-calls-event-interfaces" --- > ## Documentation Index > Fetch the complete documentation index at: https://developer.rocket.chat/llms.txt > Use this file to discover all available pages before exploring further. # Media Calls – Event Interfaces Media call events let your app react to voice calls in the workspace. Media calls are the one-to-one direct calls between two workspace users, or between a user and an external phone number through your PBX (SIP). They are not video conferences. With these events, your app can observe calls starting, being answered, and ending. It can also block a call or change the features a call was requested with before the call is created. Apps with the `media-call.history` permission can also read a user's call history. > [!WARNING] > Media call events and the call history reader are available from Apps-Engine 1.67.0 (Rocket.Chat 8.9.0). ## Implement the interface Unlike other event interfaces, all media call events are grouped in a single interface, `IMediaCallHandler`. Implement it on your app's main class and add only the methods for the events you need. Every method is optional, and implementing a method subscribes your app to that event. For example, an app that only needs to know when calls end implements only `executePostMediaCallEnded`. | Method | When it runs | Context | | --- | --- | --- | | `executePreMediaCallCreated` | Before the call is created. Routing and permission checks have already run, but the call is not saved yet and does not ring until your handler returns. The app can allow the call, change its features, or block it. | IPreMediaCallCreatedContext | | `executePostMediaCallParticipantJoined` | When the callee accepts the call. Runs at most once per call. The participant who joined is always `call.callee`. | `IMediaCallParticipantJoinedContext` (`call`, with `acceptedAt` set) | | `executePostMediaCallStarted` | When media starts flowing on the call. | `IMediaCallStartedContext` (`call`, with `activatedAt` set) | | `executePostMediaCallEnded` | When a call ends for any reason, including calls that were missed, declined, transferred, expired, or failed. | `IMediaCallEndedContext` (`call`, with `ended` and `endedAt` set, and `durationMs`) | Each method receives the event context followed by the `read`, `http`, `persistence`, and `modify` accessors. > [!WARNING] > The `call` object in each post-event is a snapshot taken when the event was emitted, not the call's current state. ## Before a call is created `executePreMediaCallCreated` receives the following context. There is no call ID yet because the call has not been saved. | Field | Description | | --- | --- | | `caller` | The contact making the call. | | `callee` | The contact receiving the call. | | `createdBy` | Who requested the call. This is the caller, except on transfers. | | `features` | The features requested for the call. Supported values are `audio`, `screen-share`, `transfer`, and `hold`. | | `origin` | `internal` (between two workspace users), `sip-outbound` (a user calling an external number), or `sip-inbound` (an external number calling a user). | | `parentCallId` | Set when the call replaces another call through a transfer. | | `divertedBy` | Set when the PBX forwarded the call. The party whose line diverted it. | Each contact has a `type` (`user` or `sip`), an `id`, and, when available, `username`, `displayName`, and `sipExtension`. The handler must return one of the following results, built with `EventResult` from `@rocket.chat/apps-engine/definition/eventResult`: | Result | Effect | | --- | --- | | EventResult.pass() | Lets the call continue unchanged. | | EventResult.patch({ features }) | Changes the call's requested features. Only `features` can be changed. Contacts and origin can't. | | `EventResult.prevent({ reason })` or `EventResult.prevent({ i18n: { key, args } })` | Blocks the call. It never rings. | Keep the following in mind: - A slow handler delays the call from ringing, so keep this handler fast. - Features the workspace doesn't allow are removed after your handler runs, and unknown feature values are dropped. Patching can't enable a feature that the workspace has disabled. - When an app blocks a call, the reason is stored with the call, and the call appears in call history with the state `prevented`. If you pass an `i18n` key, the workspace resolves it using your app's translations in the workspace language and stores that text, so the reason stays readable even after your app is uninstalled. Reasons are limited to 1,000 characters. If you don't provide a reason, a generic "prevented by app" message is shown. The following example blocks calls to external numbers and removes screen sharing from all other calls: ```typescript import { App } from '@rocket.chat/apps-engine/definition/App'; import type { IAppAccessors, IHttp, ILogger, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; import { EventResult } from '@rocket.chat/apps-engine/definition/eventResult'; import type {    IMediaCallHandler,    IPreMediaCallCreatedContext,    MediaCallCreateEventResult, } from '@rocket.chat/apps-engine/definition/mediaCalls'; import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; export class CallPolicyApp extends App implements IMediaCallHandler {    constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) {        super(info, logger, accessors);    }    public async [AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED](        context: IPreMediaCallCreatedContext,        read: IRead,        http: IHttp,        persistence: IPersistence,        modify: IModify,    ): Promise {        if (context.origin === 'sip-outbound') {            return EventResult.prevent({ reason: 'External calls are not allowed in this workspace.' });        }        if (context.features.includes('screen-share')) {            return EventResult.patch({                features: context.features.filter((feature) => feature !== 'screen-share'),            });        }        return EventResult.pass();    } } ``` ## After a call ends `executePostMediaCallEnded` runs for every call that ends, whatever the outcome. The context contains the `call` and `durationMs`, which is how long media was flowing, in milliseconds. It is `0` for calls that never started. To tell outcomes apart, use the helper functions from `@rocket.chat/apps-engine/definition/mediaCalls`: | Helper | Returns `true` when | | --- | --- | | `isAnsweredCall(context)` | The callee accepted the call. | | `isRejectedCall(context)` | The callee saw the call and declined it. A declined call is not a missed call. | | `isMissedCall(context)` | Nobody answered and the callee did not decline. This covers ring timeouts, unreachable callees, expired calls, and connection failures before the call was accepted. | > [!WARNING] > Don't detect missed calls by checking `call.hangupReason === 'not-answered'`. That value is written only when the caller's client times out. A caller who closes the tab or loses connection produces a different reason (`expired`) for the same missed call. Use `isMissedCall` instead. Why the call ended is in `call.hangupReason`, who ended it is in `call.endedBy`, and when is in `call.endedAt`. Calls that no user ended, such as expired, failed, or transferred calls, report a `server` actor in `call.endedBy`. `hangupReason` is free-form text and new values can appear, so use `isKnownMediaCallHangupReason()` before handling reasons in an exhaustive `switch`. ```typescript import type { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; import { isAnsweredCall, isMissedCall, isRejectedCall } from '@rocket.chat/apps-engine/definition/mediaCalls'; import type { IMediaCallEndedContext } from '@rocket.chat/apps-engine/definition/mediaCalls'; import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; // Inside your app class that implements IMediaCallHandler public async [AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED](    context: IMediaCallEndedContext,    read: IRead,    http: IHttp,    persistence: IPersistence,    modify: IModify, ): Promise {    const caller = context.call.caller.displayName ?? context.call.caller.id;    if (isMissedCall(context)) {        this.getLogger().info(`Missed call from ${caller}`);    } else if (isRejectedCall(context)) {        this.getLogger().info(`Call from ${caller} was declined`);    } else if (isAnsweredCall(context)) {        this.getLogger().info(`Call from ${caller} lasted ${Math.round(context.durationMs / 1000)} seconds`);    } } ``` ## The call object The post-events provide an `IMediaCall` object with the following main fields: | Field | Description | | --- | --- | | id | The call ID. | | state | `none`, `ringing`, `accepted`, `active`, or `hangup`. | | origin | `internal`, `sip-outbound`, or `sip-inbound`. | | caller, callee, createdBy | The contacts involved in the call. | | features | The features the call may use. These values are final once the call is accepted. | | uids | IDs of the workspace users on the call. External SIP endpoints are not listed. | | `createdAt`, `acceptedAt`, `activatedAt`, `endedAt` | When the call was created, accepted, started flowing media, and ended. | | `ended`, `endedBy`, `hangupReason` | Whether the call ended, who ended it, and why. | | parentCallId | Set when the call replaced another call through a transfer. | | divertedBy | Set when the PBX forwarded the call. | ## Read call history Apps can read a user's call history through `read.getCallHistoryReader()`. This requires the `media-call.history` permission. This permission is not granted by default. Without it, every method returns `undefined`. ```json "permissions": [    { "name": "media-call.history" } ] ``` > [!WARNING] > Declaring permissions in `app.json` replaces the default permissions, so list every permission your app uses. Every method reads the history of one user, identified by `uid`. | Method | Description | | --- | --- | | `getById(id, uid)` | Returns one history entry by its ID, or `undefined` if it doesn't exist. | | `getByCallId(callId, uid)` | Returns the user's history entry for a call, or `undefined` if it doesn't exist. | | `search(uid, filters?, pagination?)` | Searches the user's history, newest first. Returns `{ items, total }`, where `total` is the number of matching entries regardless of pagination. | The `search` method accepts the following options: | Option | Description | | --- | --- | | `filters.searchTerm` | Matches the contact's name, username, or extension. | | `filters.direction` | `inbound` or `outbound`. | | `filters.inStates` | An array of states to include: `ended`, `not-answered`, `failed`, `error`, `transferred`, or `prevented`. | | `pagination.count` | How many entries to return. Defaults to 50, maximum 100. | | `pagination.offset` | How many entries to skip. | | `pagination.sort` | Sort order, for example `{ ts: -1 }`. | Each history entry includes `id`, `uid`, `callId`, `ts`, `direction`, `state`, `duration` (in seconds), `endedAt`, and `external`: - For calls with another workspace user (`external: false`), the entry includes `contactId` and, when available, `contactName`, `contactUsername`, `rid` (the direct message room), and `messageId` (the message sent after the call ended). - For calls with an external number (`external: true`), the entry includes `contactExtension`. > [!NOTE] > The history entry is saved after `executePostMediaCallEnded` runs. If you read the history inside that handler, the entry for the call that just ended may not exist yet. Read history later instead, for example from a slash command, an API endpoint, or a scheduled job. ```typescript const history = await read.getCallHistoryReader().search(    userId,    { direction: 'inbound', inStates: ['not-answered'] },    { count: 20 }, ); if (!history) {    // The app doesn't have the media-call.history permission    return; } for (const item of history.items) {    const contact = item.external ? item.contactExtension : item.contactName ?? item.contactUsername;    this.getLogger().info(`Missed call from ${contact} at ${item.ts.toISOString()}`); } ``` For the complete type definitions, refer to the `mediaCalls` and `accessors` folders in the [Apps-Engine definitions](https://github.com/RocketChat/Rocket.Chat/tree/develop/packages/apps-engine/src/definition).