> ## Documentation Index
> Fetch the complete documentation index at: https://docs.yotel.in/llms.txt
> Use this file to discover all available pages before exploring further.

# Event routing

> Choose which call events reach Salesforce and which reach your webhooks, per tenant and per campaign.

Event routing is a matrix: **rows are events, columns are destinations**. Each
cell answers one question — *may this event, for this call, go to this
destination?*

Set it in the dashboard at **Settings → Event routing** for the whole tenant,
and at **Campaign → Settings → Event routing** for one campaign.

## What is routable

Only the five call events that mean something to an external system:

| Event | Meaning |
| - | - |
| [`call.started`](/webhooks/event-catalog#callstarted) | The dialer originated the call |
| [`call.answered`](/webhooks/event-catalog#callanswered) | The callee picked up |
| [`call.ended`](/webhooks/event-catalog#callended) | Hangup, with the mechanical outcome |
| [`call.disposition_set`](/webhooks/event-catalog#calldisposition_set) | The behavioural outcome — Interested, Site Visit, Do Not Call |
| [`call.recording_ready`](/webhooks/event-catalog#callrecording_ready) | The recording is uploaded and has a stable link |

`ai_session.*`, `campaign.*`, `agent.*` and `lead.completed` are **webhook-only
by design**. They have no Salesforce meaning, so they are not rows in the
matrix at all rather than rows that cannot be changed.

## Destinations

* **Salesforce** — contribute to this tenant's `Call_Detail__c` writeback.
  Requires a configured Salesforce connected app; without one the column is
  locked in the dashboard, because a checkbox that writes nowhere is worse
  than an absent one. Three of the five Salesforce cells have **no producer**
  at all — see below.
* **Webhook** — publish to your webhook subscriptions.

## Cells with no producer

Three Salesforce cells exist in the matrix but nothing reads them:

| Cell | Why |
| - | - |
| `call.started → salesforce` | No `Call_Detail__c` is written at this point in the call |
| `call.answered → salesforce` | Same |
| `call.ended → salesforce` | Same |

Yotel's Salesforce writeback is triggered by the **disposition**
(`call.disposition_set`) and by the **recording** (`call.recording_ready`) —
never by a lifecycle event. One `Call_Detail__c` per call, carrying the outcome
the call actually had.

<Warning>
  **Turning one of these on does nothing, and turning
  `call.disposition_set → salesforce` off stops your writeback entirely.** The
  API accepts a write to an unsupported cell and reports it back on the next
  `GET`, so it looks like it worked. If you want the mechanical outcome as well
  as the behavioural one, take it from the `call.ended` **webhook** — do not
  move the Salesforce switch onto it.
</Warning>

Every unsupported cell ships **off**, is locked in the dashboard, and is listed
in the `unsupported` array of every routing `GET` — read that array rather than
hard-coding this table, since it is the API's own answer and it carries a
human-readable `reason`.

<Warning>
  **Routing is an additional gate, never a bypass.** A subscription still
  applies its own event filter on top. For an event to reach your endpoint it
  must pass **both**: routing says the event may go to `webhook` *and* your
  subscription is subscribed to that event type. Turning a routing cell on
  does not subscribe you to anything.
</Warning>

## How a cell is resolved

Per cell, most specific first:

```
campaigns.event_routing[event][destination]        -- campaign override
  ?? tenants.settings.event_routing[event][dest]   -- tenant default
  ?? the shipped default                           -- below
```

**Resolution is per cell, never per matrix.** A campaign override that names
only `call.ended` leaves every other event resolving through the tenant
default. And a stored `false` is an override, not an absence — turning a cell
off at tenant level really does turn it off, it does not fall through to the
shipped default.

A campaign is either **inheriting** — it has no override at all, and tracks
the tenant defaults as they change — or **pinned** to its own matrix. The
dashboard's *Inherit tenant defaults* toggle is that state. Turning it off
seeds the campaign's matrix from the values the campaign is resolving to right
now, so nothing changes until you change it; turning it back on discards the
override.

## Shipped defaults

Chosen to reproduce the behaviour that existed before routing did. If you have
never configured anything, this is what you have:

| Event | Salesforce | Webhook |
| - | - | - |
| `call.started` | off | **on** |
| `call.answered` | off | **on** |
| `call.ended` | off | **on** |
| `call.disposition_set` | **on** | **on** |
| `call.recording_ready` | **on** | **on** |

Every webhook column ships **on** so no existing subscription goes quiet — a
tenant does not notice a webhook that stops arriving, so that guarantee is
pinned by a test rather than left to review.

`call.ended → Salesforce` ships **off** because the Salesforce write is
triggered by the *disposition*, not by the hangup: one `Call_Detail__c` per
call, carrying the outcome the call actually had.

## API

All four routes require the `seats:manage` permission (tenant admins).

| Route | Purpose |
| - | - |
| `GET /api/tenant/event-routing` | The tenant default matrix |
| `PUT /api/tenant/event-routing` | Set (or clear) the tenant defaults |
| `GET /api/campaigns/{id}/event-routing` | The campaign's resolved matrix |
| `PUT /api/campaigns/{id}/event-routing` | Set (or clear) the campaign override |

A `GET` returns the closed sets alongside the resolved matrix, and each cell
carries the layer that decided it:

```json theme={null}
{
  "events": ["call.started", "call.answered", "call.ended", "call.disposition_set", "call.recording_ready"],
  "destinations": ["salesforce", "webhook"],
  "routing": {
    "call.disposition_set": {
      "salesforce": { "value": true, "source": "tenant" },
      "webhook": { "value": true, "source": "system" }
    }
  },
  "unsupported": [
    {
      "event": "call.started",
      "destination": "salesforce",
      "reason": "is not written at this point in the call. Yotel's Salesforce writeback is triggered by the disposition and by the recording — never by a lifecycle event — so this cell would save and do nothing. It is locked rather than left as a checkbox that has no effect."
    }
  ]
}
```

`unsupported` lists every cell that has no producer (see
[Cells with no producer](#cells-with-no-producer)). It is returned by all four
routes and is the authoritative list — the dashboard locks its cells from it.
Writing to one of those cells is **not** an error and the value is echoed back
on the next `GET`; it simply never does anything.

The campaign routes add `campaign_id` and `inherits`. **`inherits` is the
authoritative answer to "is this campaign pinned?"** — per-cell `source`
cannot tell you: a campaign that stored a partial override reports no
`campaign` sources while still being pinned.

A `PUT` body wraps the matrix:

```json theme={null}
{ "event_routing": { "call.recording_ready": { "salesforce": false } } }
```

(That example turns the recording PATCH off for this campaign. It is a cell
that does something — `call.ended → salesforce` would be accepted and reported
back while changing nothing.)

Send `{"event_routing": null}` to clear — at campaign level that means "go back
to inheriting", at tenant level "go back to the shipped defaults". A partial
matrix is legal and is the point: what it does not name keeps resolving
through the layer below.

An unknown event or destination is a **422**, never silently stored — a typo
would otherwise resolve to the shipped default and look like it worked.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.