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

# Custom Integration

> Learn how to send call transcripts from your own backend system to Cekura using webhooks for simulation testing and observability.

export const CopyPageButton = () => {
  if (typeof window !== 'undefined') {
    setTimeout(function () {
      if (document.getElementById('ck-tools')) return;
      var anchor = document.getElementById('content-area') || document.querySelector('.mdx-content');
      if (!anchor) return;
      if (!document.getElementById('ck-style')) {
        var s = document.createElement('style');
        s.id = 'ck-style';
        s.textContent = '#ck-tools{position:absolute;top:6px;right:0;z-index:100;font-family:inherit;}' + '.ck-row{display:inline-flex;align-items:stretch;border:1px solid rgba(0,0,0,0.15);border-radius:8px;overflow:hidden;background:#fff;}' + ':root.dark .ck-row{background:rgba(255,255,255,0.06);border-color:rgba(255,255,255,0.12);}' + '.ck-btn{padding:5px 12px;border:none;background:none;cursor:pointer;font-size:13px;font-weight:500;font-family:inherit;color:#374151;}' + ':root.dark .ck-btn{color:#d1d5db;}' + '.ck-btn:hover{background:rgba(0,0,0,0.04);}' + ':root.dark .ck-btn:hover{background:rgba(255,255,255,0.06);}' + '.ck-chevron{padding:5px 8px;border:none;background:none;cursor:pointer;font-size:14px;font-family:inherit;color:#374151;}' + ':root.dark .ck-chevron{color:#d1d5db;}' + '.ck-chevron:hover{background:rgba(0,0,0,0.04);}' + ':root.dark .ck-chevron:hover{background:rgba(255,255,255,0.06);}' + '.ck-divider{width:1px;background:rgba(0,0,0,0.12);flex-shrink:0;}' + ':root.dark .ck-divider{background:rgba(255,255,255,0.12);}' + '.ck-dd{position:absolute;top:calc(100% + 4px);right:0;min-width:180px;background:#fff;border:1px solid rgba(0,0,0,0.12);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.1);padding:4px;display:none;z-index:200;}' + ':root.dark .ck-dd{background:#1f2937;border-color:rgba(255,255,255,0.1);box-shadow:0 4px 16px rgba(0,0,0,0.35);}' + '.ck-item{display:block;width:100%;padding:7px 12px;border:none;background:none;border-radius:6px;cursor:pointer;font-size:13px;font-family:inherit;text-align:left;color:#374151;}' + ':root.dark .ck-item{color:#d1d5db;}' + '.ck-item:hover{background:rgba(0,0,0,0.05);}' + ':root.dark .ck-item:hover{background:rgba(255,255,255,0.07);}';
        document.head.appendChild(s);
      }
      var wrap = document.createElement('div');
      wrap.id = 'ck-tools';
      var row = document.createElement('div');
      row.className = 'ck-row';
      var mainBtn = document.createElement('button');
      mainBtn.className = 'ck-btn';
      mainBtn.textContent = 'Copy page';
      var divider = document.createElement('span');
      divider.className = 'ck-divider';
      var chevron = document.createElement('button');
      chevron.className = 'ck-chevron';
      chevron.textContent = '▾';
      var dd = document.createElement('div');
      dd.className = 'ck-dd';
      function closeDD() {
        dd.style.display = 'none';
      }
      function openDD() {
        dd.style.display = 'block';
      }
      chevron.onclick = function (e) {
        e.stopPropagation();
        if (dd.style.display === 'block') {
          closeDD();
        } else {
          openDD();
        }
      };
      document.addEventListener('click', function (e) {
        if (!e.target.closest('#ck-tools')) {
          closeDD();
        }
      });
      document.addEventListener('keydown', function (e) {
        if (e.key === 'Escape') {
          closeDD();
        }
      });
      function makeItem(label, fn) {
        var b = document.createElement('button');
        b.className = 'ck-item';
        b.textContent = label;
        b.onclick = function () {
          fn();
          closeDD();
        };
        return b;
      }
      function getMarkdown() {
        var walk = function (node) {
          if (!node) return '';
          if (node.nodeType === 3) return node.textContent || '';
          if (node.nodeType !== 1) return '';
          var tag = node.tagName.toLowerCase();
          var skip = ['script', 'style', 'svg', 'noscript', 'button', 'iframe'];
          if (skip.indexOf(tag) !== -1) return '';
          if (node.id === 'ck-tools') return '';
          var ch = Array.from(node.childNodes).map(walk).join('');
          if (tag === 'h1') return '\n# ' + ch.trim() + '\n\n';
          if (tag === 'h2') return '\n## ' + ch.trim() + '\n\n';
          if (tag === 'h3') return '\n### ' + ch.trim() + '\n\n';
          if (tag === 'p') return '\n' + ch.trim() + '\n\n';
          if (tag === 'pre') return '\n```\n' + node.textContent.trim() + '\n```\n\n';
          if (tag === 'li') return '- ' + ch.trim() + '\n';
          if (tag === 'code') return '`' + ch.trim() + '`';
          return ch;
        };
        var content = document.querySelector('.mdx-content') || document.getElementById('content-area') || document.body;
        return walk(content).replace(/\n\n\n+/g, '\n\n').trim();
      }
      function copyMd() {
        var md = getMarkdown();
        navigator.clipboard.writeText(md).then(function () {
          mainBtn.textContent = 'Copied!';
          setTimeout(function () {
            mainBtn.textContent = 'Copy page';
          }, 2000);
        });
      }
      function viewMd() {
        var md = getMarkdown();
        var safe = md.split('&').join('&amp;').split('<').join('&lt;').split('>').join('&gt;');
        var html = '<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{font-family:monospace;max-width:860px;margin:40px auto;padding:0 24px;line-height:1.7;white-space:pre-wrap;word-wrap:break-word}</style></head><body>' + safe + '</body></html>';
        window.open(URL.createObjectURL(new Blob([html], {
          type: 'text/html'
        })), '_blank');
      }
      function openClaude() {
        var prompt = 'Can you read this Cekura docs page ' + window.location.href + ' so I can ask you questions?';
        window.open('https://claude.ai/new?q=' + encodeURIComponent(prompt), '_blank');
      }
      mainBtn.onclick = copyMd;
      dd.appendChild(makeItem('Copy page', copyMd));
      dd.appendChild(makeItem('View as Markdown', viewMd));
      dd.appendChild(makeItem('Open in Claude', openClaude));
      row.appendChild(mainBtn);
      row.appendChild(divider);
      row.appendChild(chevron);
      wrap.appendChild(row);
      wrap.appendChild(dd);
      anchor.style.position = 'relative';
      anchor.insertBefore(wrap, anchor.firstChild);
    }, 50);
  }
  return null;
};

<CopyPageButton />

## Overview

The custom integration feature allows you to send call transcripts from your own backend system or third-party service to Cekura via webhooks. This provides flexibility to integrate any voice platform while maintaining full control over your conversation data.

## How It Works

1. Configure your agent in Cekura and select "Custom" as the provider
2. Cekura provides you with a webhook URL
3. After each call ends, send the call data to the webhook URL
4. Cekura listens for call data for 5 minutes after the call ends
5. View evaluation results in the Cekura dashboard

## Setup

### In Cekura Dashboard

1. Create or edit an agent in the Cekura dashboard
2. Set the transcript provider to "Custom"
3. Copy the webhook URL provided by Cekura
4. Note your API key for authentication

<Note>
  **One webhook URL per project:** Cekura provides a single webhook URL per project. If you need separate webhook destinations for different environments (e.g., development vs. production), create separate projects in Cekura — each project gets its own webhook URL and API key. This is the recommended approach for environment isolation.
</Note>

### Webhook Implementation Requirements

Your system should send call transcripts to the Cekura webhook with the following specifications:

**HTTP Method:** `POST`

**Headers:**

```
X-CEKURA-API-KEY: your-api-key-here
Content-Type: application/json
```

**Timing:** Send the call data within 5 minutes after the call ends. Cekura will listen for webhooks for 5 minutes after the call ends.

## Payload Format

Your webhook POST request should send a JSON payload with the following structure:

### Top Level Structure

```json theme={null}
{
  "agent_id": 123,  // Conditionally required: The ID of the AI agent in Cekura (see note below)
  "calls": [...]    // Required: Array of call objects
}
```

<Note>
  `agent_id` is required **unless** every call object in `calls` includes a `run_id` field. If you already have a Cekura Run ID for each call before sending the webhook — for example, when using SIP integrations where run IDs are available from SIP headers — you can omit `agent_id` entirely and include `run_id` on each call object instead. In this case, Cekura will trust the provided run IDs and match transcripts directly to the existing runs.
</Note>

### Call Object Structure

Each call in the `calls` array should include:

| Field               | Type   | Required               | Description                                                                                                                                                                                                                    |
| ------------------- | ------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id`                | string | Yes                    | Unique identifier for this call from your system (max 255 chars)                                                                                                                                                               |
| `startedAt`         | string | Yes                    | Call start time in ISO 8601 format                                                                                                                                                                                             |
| `endedAt`           | string | Yes                    | Call end time in ISO 8601 format                                                                                                                                                                                               |
| `messages`          | array  | No                     | List of transcript messages (see below)                                                                                                                                                                                        |
| `to_phone_number`   | string | No                     | Phone number that received the call (must start with + followed by digits, max 15 chars). Example: '+14155551234'                                                                                                              |
| `from_phone_number` | string | No                     | Phone number that initiated the call (must start with + followed by digits, max 15 chars). Example: '+18005551234'                                                                                                             |
| `metadata`          | object | No                     | Optional metadata as key-value pairs for additional call information                                                                                                                                                           |
| `endedReason`       | string | No                     | Reason call ended (default: "unknown", e.g., "assistant-ended-call", "customer-hungup")                                                                                                                                        |
| `run_id`            | string | Conditionally required | Cekura Run ID for this call. Required on each call when `agent_id` is omitted at the top level. Intended for SIP integrations where run IDs are pre-known from SIP headers.                                                    |
| `trace_id`          | string | No                     | OpenTelemetry trace ID for this call (max 32 chars). When provided, it is attached to the matched run so the OTel trace links to the call in the Cekura dashboard. See [Tracing](/documentation/guides/observability/tracing). |

<Tip>
  **Passing a recording URL:** The custom integration payload does not have a dedicated `voice_recording_url` field. To include a recording URL (or any other custom call context), add it to the `metadata` object:

  ```json theme={null}
  "metadata": {
    "recording_url": "https://recordings.example.com/call_123.wav"
  }
  ```

  You can then reference it in metrics:

  * **LLM Judge metrics:** use `{{metadata.recording_url}}` in your metric description
  * **Python metrics:** use `data["metadata"]["recording_url"]`

  See [Metric Variables](/documentation/key-concepts/metrics/metric-variables) for the full reference of variables available in metrics.
</Tip>

### Message Object Structure

Each message in the `messages` array should include:

| Field        | Type   | Required | Description                                                                          |
| ------------ | ------ | -------- | ------------------------------------------------------------------------------------ |
| `role`       | string | Yes      | Message role: "bot", "user", "system", "function\_call", or "function\_call\_result" |
| `content`    | string | Yes      | The message content or function description                                          |
| `start_time` | number | No       | Start time in milliseconds from epoch                                                |
| `end_time`   | number | No       | End time in milliseconds from epoch                                                  |
| `data`       | object | No       | Additional data for function calls                                                   |

<Tip>
  For function call messages, use the `data` field to include the tool call details. See the [Transcript Format](/documentation/advanced/transcript-format) page for examples of how to structure `function_call` and `function_call_result` messages in Cekura format.
</Tip>

## Complete Example

Here's a complete example payload with two calls:

```json theme={null}
{
  "agent_id": 2,
  "calls": [
    {
      "id": "0199e72d-795e-7ffe-b9b9-d3b08a3a11ae",
      "startedAt": "2025-10-15T09:22:21.787Z",
      "endedAt": "2025-10-15T09:24:30.229Z",
      "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
      "to_phone_number": "+18646190758",
      "from_phone_number": "+14155551234",
      "messages": [
        {
          "role": "bot",
          "content": "Hi there. This is Alex from Tech Solutions customer support. How can I help you today?",
          "start_time": 1760520142852,
          "end_time": 1760520147842
        },
        {
          "role": "user",
          "content": "Yeah. I have a question about a recent recurring charge on my account.",
          "start_time": 1760520149392,
          "end_time": 1760520153012
        },
        {
          "role": "bot",
          "content": "Got it. Let me help you figure that out. Could you tell me the email address linked to your account so I can look this up for you?",
          "start_time": 1760520154442,
          "end_time": 1760520160522
        },
        {
          "role": "user",
          "content": "Okay. It's john dot doe at email dot com",
          "start_time": 1760520157372,
          "end_time": 1760520165132
        }
      ],
      "metadata": {
        "customer_name": "John Doe",
        "call_type": "support"
      },
      "endedReason": "customer-hungup"
    },
    {
      "id": "0199e72d-791d-7eee-b5d9-b82843919e42",
      "startedAt": "2025-10-15T09:22:21.727Z",
      "endedAt": "2025-10-15T09:23:48.100Z",
      "to_phone_number": "+18646190758",
      "from_phone_number": "+14155559999",
      "messages": [
        {
          "role": "bot",
          "content": "Hi there. This is Alex from Tech Solutions customer support. How can I help you today?",
          "start_time": 1760520142868,
          "end_time": 1760520147998
        },
        {
          "role": "user",
          "content": "Yeah. The Taskmaster Pro app keeps crashing.",
          "start_time": 1760520149538,
          "end_time": 1760520152118
        }
      ],
      "metadata": {
        "customer_name": "Jane Smith",
        "call_type": "technical_issue"
      },
      "endedReason": "assistant-ended-call"
    }
  ]
}
```

## SIP / Pre-known Run ID Example

If you are using a SIP integration and already have Cekura Run IDs available from SIP headers before sending the webhook, you can omit `agent_id` at the top level and include `run_id` on each call instead. Cekura will trust the provided run IDs and attach the transcripts directly to the matching runs — no agent lookup needed.

```json theme={null}
{
  "calls": [
    {
      "id": "sip-call-abc123",
      "run_id": "456789",
      "startedAt": "2025-10-15T09:22:21.787Z",
      "endedAt": "2025-10-15T09:24:30.229Z",
      "to_phone_number": "+18646190758",
      "from_phone_number": "+14155551234",
      "messages": [
        {
          "role": "bot",
          "content": "Hi there. How can I help you today?"
        },
        {
          "role": "user",
          "content": "I have a billing question."
        }
      ],
      "endedReason": "customer-hungup"
    }
  ]
}
```

<Note>
  When using `run_id` per call, every call in the `calls` array **must** include a `run_id`. Mixing calls with and without `run_id` in the same payload (when `agent_id` is absent) is not supported.
</Note>

## Important Considerations

For successful webhook integration, ensure:

1. **Timing**: Send the call data within 5 minutes after the call ends
2. **Authentication**: Include the `X-CEKURA-API-KEY` header with your API key
3. **Phone Numbers**: Phone numbers must start with + followed by digits (max 15 chars, e.g., '+14155551234'). The `to_phone_number` should match the scenario's inbound number for testing
4. **Timestamps**: Use ISO 8601 format for `startedAt` and `endedAt`
5. **Message Timing**: Use milliseconds from epoch for message `start_time` and `end_time` (optional fields)
6. **Agent ID**: Use the correct agent ID from your Cekura dashboard. If you have a Cekura Run ID available ahead of time (e.g. from SIP headers), you can omit `agent_id` and include `run_id` on each call object instead — Cekura will match transcripts directly to the existing runs
7. **Unique Call IDs**: Each call should have a unique identifier (max 255 chars)
8. **Metadata**: Use the `metadata` field to pass additional key-value pairs for call information

## Troubleshooting

If your webhook integration isn't working as expected:

### No calls appear in Cekura

* Verify the webhook URL is correct
* Check that the `X-CEKURA-API-KEY` header is included and correct
* Ensure the payload format matches the specification
* Confirm the `agent_id` is correct
* Verify the call data was sent within 5 minutes after the call ended

### Authentication issues

* Double-check your API key is correct
* Ensure the header name is exactly `X-CEKURA-API-KEY` (case-sensitive)
* Verify the API key has proper permissions

### Calls not matching scenarios

* Confirm the `to_phone_number` in the payload matches the scenario's inbound number
* Ensure phone numbers start with + followed by digits (max 15 chars)
* Check that the call ID is unique and under 255 characters
* Verify timestamps are in ISO 8601 format

### Invalid payload errors

* Validate your JSON payload structure
* Ensure all required fields are present (`id`, `startedAt`, `endedAt`)
* Verify the `agent_id` is an integer, not a string (required unless each call includes a `run_id`)
* Check that phone numbers follow the correct format (+ followed by digits, max 15 chars)
* Ensure `messages` is an array with dictionaries containing role and content fields

## Mock Tools (optional)

If your agent calls tools and you want to test it without hitting live third-party services, use [Mock Tools](/documentation/key-concepts/evaluators/mock-tools). With a custom integration there are two independent pieces — both are required for tool-based evaluation to work.

### 1. Call the Cekura mock endpoint during the call

Point the tool at its Cekura mock endpoint instead of the real service. During the call, your agent makes a normal HTTP request and gets back the mock output you configured.

* **Method:** `POST`
* **Body:** the tool's arguments as a flat JSON object — the same shape as the `input` in your mock configuration. For example, a tool whose mock entry is `{"input": {"user_id": 1}, ...}` is called with body `{"user_id": 1}`.
* **Authentication:** include your `X-CEKURA-API-KEY` header. A missing or wrong key is the most common cause of a mock call failing.
* The endpoint matches your request against the configured input/output mappings and returns the matching `output`.

See the [Mock Tool API reference](/api-reference/test_framework/post-mock-tool) for the exact endpoint and request/response schema.

### 2. Report the tool call in your transcript

Calling the mock endpoint makes the tool *respond* correctly during the call, but it does **not** by itself tell Cekura's metrics that a tool was used. The [Mock Tool Call Accuracy](/documentation/key-concepts/evaluators/mock-tools#verifying-tool-calls-the-mock-tool-call-accuracy-metric) metric (and any tool-based metric) reads the **transcript** to determine what was called.

So your webhook `messages` array must include a `function_call` message for each tool call, carrying the tool `name` and `arguments` in its `data` object:

```json theme={null}
{
  "role": "function_call",
  "content": "",
  "data": {
    "id": "call_abc",
    "name": "pre_call_lookup",
    "arguments": {}
  }
}
```

Include the paired result as well (recommended):

```json theme={null}
{
  "role": "function_call_result",
  "content": "",
  "data": {
    "id": "call_abc",
    "name": "pre_call_lookup",
    "result": { "account_id": "TEST-90210", "account_tier": "Premium" }
  }
}
```

<Warning>
  If you omit these `function_call` messages, the Mock Tool Call Accuracy metric will report the tool as **not called** and the result will fail — even when the tool was actually called and used correctly during the call. The mock endpoint being hit is not visible to the metric; only the transcript is.
</Warning>

A tool that takes no input must still send `"arguments": {}`. See [Transcript Format](/documentation/advanced/transcript-format) for a complete transcript example with function calls.

## Next Steps

* Learn about [custom metrics](/documentation/key-concepts/metrics/custom-metrics)
* Explore [predefined metrics](/documentation/key-concepts/metrics/pre-defined-metrics)
* Set up [instruction following metric](/documentation/key-concepts/metrics/instruction-following-metric)
