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

# Send Recordings for Review with a Signed Webhook

> Notify Triple Session when a call recording is ready. Triple Session downloads it from your Recording source with a credential you control and evaluates it with AI Coach.

Use a **Webhook Received** automation with a **Review Meeting** action to have AI Coach evaluate recordings stored in your own systems, for example a contact-center platform that drops recordings into a folder.

The flow has three parts:

1. **Your job notifies Triple Session** when a recording is ready. It sends one signed HTTPS request per recording, with the agent's email, a unique ID and the recording's URL.
2. **Triple Session downloads that one file** from your **Recording source** over HTTPS with a credential that your admin enters in Triple Session.
3. **AI Coach evaluates the call**. The result appears on the agent's call list like any other call.

Triple Session never lists, scans or polls your storage. It only fetches the files you point it to.

## Requirements

* Access to [Automations](/automations/getting-started) and a company admin role.
* Every agent email you send belongs to a Triple Session member of your company with a recording seat and standard credits.
* A **Recording source** that follows the [contract below](#recording-source-contract).

## Set up the automation

<Steps>
  <Step title="Create the automation">
    Go to **Settings > Automations** and click **New Automation**. Set the **Trigger** to **Webhook Received** and add a **Review Meeting** action.
  </Step>

  <Step title="Generate the webhook secret">
    Open the trigger. Copy the **Webhook URL** and the **Automation ID**, then click **Generate Secret** and store the secret in your job's secret store. The secret signs every notification.

    <Warning>
      Regenerating the secret invalidates the old one immediately. Update your job at the same time.
    </Warning>
  </Step>

  <Step title="Require signed requests">
    In the trigger, check **Require signed requests**. Triple Session then rejects any request without a valid signature, including requests that only send the API key header.

    You can send signed requests before you check the box. Signatures are always verified when present.
  </Step>

  <Step title="Enter the Recording source credential">
    Open the **Review Meeting** action and expand **Recording source authentication**:

    * **Auth type**: **Basic**, **Bearer token** or **API key header**.
    * **Pinned host**: the exact hostname of your Recording source, for example `recordings.example.com`. Include the port only if it isn't 443.
    * The credential itself: username and password, a token, or a header name and value.

    Click **Save Workflow**. Triple Session encrypts the credential on save and never shows it again. The action then shows **Credential saved** with a **Replace** button.
  </Step>

  <Step title="Turn the automation live">
    Click **Live**. Then [send a test notification](#test-the-setup).
  </Step>
</Steps>

### Rotate the credential

Click **Replace**, enter the new credential and save. The next download uses it, so there is no downtime. Keep the old credential valid on your side until the save is done.

If you change the auth type, the pinned host or the header name, you must enter the credential again. A saved credential is only ever sent to the host it was entered for.

## Recording source contract

The Recording source is the HTTPS endpoint that serves your recording files. If your recordings live in storage that can't do this with one plain request, such as **SharePoint Online**, **Amazon S3**, **Azure Blob Storage** or an **SFTP** server, put a small HTTPS endpoint in front of it that meets this contract.

| Requirement | Details |
| - | - |
| Address | A public hostname with a valid TLS certificate. URLs that resolve to private or internal IP addresses are rejected. |
| Request | `GET <recording_url>` returns the file bytes. `recording_url` must be `https://` on the pinned host. |
| Authentication | Exactly one of: `Authorization: Basic …`, `Authorization: Bearer …`, or a custom header such as `x-api-key: …`. Never put a secret in the URL. |
| File types | `Content-Type` of `audio/wav`, `audio/mpeg`, `audio/mp4` / `audio/x-m4a` (M4A) or `video/mp4`. If the header is missing or `application/octet-stream`, the URL path must end in `.wav`, `.mp3`, `.m4a` or `.mp4`. |
| Size | Up to 2 GB per file. |
| Timeouts | Response headers within 30 seconds, and no gap longer than 30 seconds while streaming. |
| Redirects | Allowed, up to 20. The credential is only sent to `https://<pinned host>`. It is dropped as soon as a redirect goes to another host, port or to `http://`, and it isn't sent again for the rest of that download. |

Serve only recordings. Transcripts and sidecar files in the same folder should never be sent to Triple Session.

## Notification request

Send one `POST` request per recording to the **Webhook URL** from the trigger.

### Headers

| Header | Value |
| - | - |
| `content-type` | `application/json` |
| `x-ts-automation-id` | The **Automation ID** from the trigger. |
| `x-ts-timestamp` | Current Unix time in seconds, for example `1767225600`. |
| `x-ts-signature` | `sha256=` followed by the lowercase hex HMAC-SHA256 of `<timestamp>.<raw body>`, keyed with the webhook secret. |

Sign the exact bytes you send. Build the JSON body once, sign that string, and send that same string.

Requests are rejected if the timestamp is more than 5 minutes away from Triple Session's clock, so keep your server's clock in sync (NTP).

Don't send `x-ts-automation-api-key` with signed requests. If you send both, only the signature is checked.

### Body

| Field | Type | Required | Description |
| - | - | - | - |
| `sales_rep_email` | string | Yes | Email of the Triple Session member who handled the call. |
| `meeting_id` | string | Recommended | Your unique ID for the recording. Used as the idempotency key. |
| `recording_url` | string | Yes | `https://` URL of the file on your Recording source. |
| `meeting_name` | string | No | Title shown in Triple Session. |
| `meeting_date` | string | No | When the call happened. ISO 8601 (`2026-09-29T14:05:00Z`) or a date (`2026-09-29`). |
| `meeting_duration_in_minutes` | number | No | Call length. |
| `custom_metadata` | object | No | Extra key-value data stored with the call (up to 10 KB). |

```json theme={null}
{
  "sales_rep_email": "agent@example.com",
  "meeting_id": "F9-8841239",
  "recording_url": "https://recordings.example.com/files/F9-8841239.wav",
  "meeting_name": "Inbound call - F9-8841239",
  "meeting_date": "2026-09-29T14:05:00Z",
  "meeting_duration_in_minutes": 7
}
```

### Responses

| Status | Meaning | What to do |
| - | - | - |
| `200` `"Automation started"` | Accepted. The download and evaluation run in the background. | Record the ID as sent. |
| `200` `"Meeting already processed"` | This `meeting_id` was accepted in the last 24 hours. | Nothing. Treat as sent. |
| `400` | The body isn't valid JSON or is missing a required field. | Fix the payload. Don't retry as is. |
| `401` | Missing or invalid signature, stale timestamp, unknown or paused automation. The `error` field says which. | Fix the signing or the automation. Don't retry as is. |
| `413` | Body larger than 1 MB. | Send the recording by URL, not inline. |
| `429` | Rate limit reached (100 requests per hour per automation by default). | Wait for the number of seconds in `Retry-After`, then retry. |
| `5xx` | Temporary error. | Retry with exponential backoff. |

<Note>
  A `meeting_id` is accepted once per 24 hours. If a download failed and you've fixed the cause, re-send it after 24 hours, or send it with a new `meeting_id`.
</Note>

If you expect more than 100 recordings per hour, contact support to raise the limit for your automation.

## Sample code

Each sample sends one notification, signs it, and retries on `429` (honouring `Retry-After`) and on `5xx` or network errors with exponential backoff. None of them send the raw secret.

<Tabs>
  <Tab title="Node.js">
    Requires Node.js 18 or later.

    ```js theme={null}
    import crypto from "node:crypto";

    const WEBHOOK_URL = process.env.TS_WEBHOOK_URL; // from the trigger panel
    const AUTOMATION_ID = process.env.TS_AUTOMATION_ID;
    const WEBHOOK_SECRET = process.env.TS_WEBHOOK_SECRET;
    const MAX_ATTEMPTS = 5;

    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

    export async function notifyTripleSession(recording) {
      const body = JSON.stringify({
        sales_rep_email: recording.agentEmail,
        meeting_id: recording.id,
        recording_url: recording.url,
        meeting_name: recording.name,
        meeting_date: recording.date, // ISO 8601
      });

      for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
        const timestamp = Math.floor(Date.now() / 1000).toString();
        const signature =
          "sha256=" +
          crypto.createHmac("sha256", WEBHOOK_SECRET).update(`${timestamp}.${body}`).digest("hex");

        let response;
        try {
          response = await fetch(WEBHOOK_URL, {
            method: "POST",
            headers: {
              "content-type": "application/json",
              "x-ts-automation-id": AUTOMATION_ID,
              "x-ts-timestamp": timestamp,
              "x-ts-signature": signature,
            },
            body,
          });
        } catch (error) {
          response = null; // network error: retry below
        }

        if (response?.ok) return response.json();

        const retryable = !response || response.status === 429 || response.status >= 500;
        if (!retryable || attempt === MAX_ATTEMPTS) {
          const detail = response ? `${response.status} ${await response.text()}` : "network error";
          throw new Error(`Triple Session rejected ${recording.id}: ${detail}`);
        }

        const retryAfter = Number(response?.headers.get("retry-after"));
        await sleep(retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 1000);
      }
    }
    ```
  </Tab>

  <Tab title="PowerShell">
    Works in Windows PowerShell 5.1 and PowerShell 7.

    ```powershell theme={null}
    $WebhookUrl    = $env:TS_WEBHOOK_URL     # from the trigger panel
    $AutomationId  = $env:TS_AUTOMATION_ID
    $WebhookSecret = $env:TS_WEBHOOK_SECRET

    function Send-TripleSessionRecording {
      param(
        [Parameter(Mandatory)] [string] $AgentEmail,
        [Parameter(Mandatory)] [string] $RecordingId,
        [Parameter(Mandatory)] [string] $RecordingUrl,
        [string] $MeetingName,
        [string] $MeetingDate,   # ISO 8601
        [int] $MaxAttempts = 5
      )

      $payload = [ordered]@{
        sales_rep_email = $AgentEmail
        meeting_id      = $RecordingId
        recording_url   = $RecordingUrl
      }
      if ($MeetingName) { $payload.meeting_name = $MeetingName }
      if ($MeetingDate) { $payload.meeting_date = $MeetingDate }

      $body = $payload | ConvertTo-Json -Compress
      $utf8 = New-Object System.Text.UTF8Encoding($false)
      $hmac = New-Object System.Security.Cryptography.HMACSHA256 (, $utf8.GetBytes($WebhookSecret))

      for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        $timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()
        $hash = $hmac.ComputeHash($utf8.GetBytes("$timestamp.$body"))
        $signature = "sha256=" + (($hash | ForEach-Object { $_.ToString("x2") }) -join "")

        $headers = @{
          "x-ts-automation-id" = $AutomationId
          "x-ts-timestamp"     = $timestamp
          "x-ts-signature"     = $signature
        }

        try {
          $response = Invoke-WebRequest -Uri $WebhookUrl -Method Post -UseBasicParsing `
            -ContentType "application/json" -Headers $headers -Body $utf8.GetBytes($body)
          return $response.Content | ConvertFrom-Json
        } catch {
          $errorResponse = $_.Exception.Response
          $status = if ($errorResponse) { [int]$errorResponse.StatusCode } else { 0 }  # 0 = network error
          $retryable = $status -eq 0 -or $status -eq 429 -or $status -ge 500
          if (-not $retryable -or $attempt -eq $MaxAttempts) {
            throw "Triple Session rejected ${RecordingId}: status $status. $($_.ErrorDetails.Message)"
          }

          $retryAfter = 0
          if ($errorResponse -and $errorResponse.Headers -is [System.Net.WebHeaderCollection]) {
            [int]::TryParse($errorResponse.Headers["Retry-After"], [ref]$retryAfter) | Out-Null
          } elseif ($errorResponse -and $errorResponse.Headers.RetryAfter.Delta) {
            $retryAfter = [int]$errorResponse.Headers.RetryAfter.Delta.TotalSeconds
          }
          $delay = if ($retryAfter -gt 0) { $retryAfter } else { [math]::Pow(2, $attempt) }
          Start-Sleep -Seconds $delay
        }
      }
    }

    # Example
    Send-TripleSessionRecording -AgentEmail "agent@example.com" -RecordingId "F9-8841239" `
      -RecordingUrl "https://recordings.example.com/files/F9-8841239.wav"
    ```
  </Tab>

  <Tab title="curl">
    For a one-off manual test from a shell with `openssl`.

    ```bash theme={null}
    BODY='{"sales_rep_email":"agent@example.com","meeting_id":"test-001","recording_url":"https://recordings.example.com/files/test-001.wav"}'
    TS=$(date +%s)
    SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$TS_WEBHOOK_SECRET" | sed 's/^.* //')

    curl -i -X POST "$TS_WEBHOOK_URL" \
      -H "content-type: application/json" \
      -H "x-ts-automation-id: $TS_AUTOMATION_ID" \
      -H "x-ts-timestamp: $TS" \
      -H "x-ts-signature: sha256=$SIG" \
      --data "$BODY"
    ```
  </Tab>
</Tabs>

## Build the folder-watch job

If your recordings land in a folder, a scheduled job on your side turns new files into notifications. Triple Session doesn't provide this job. A reliable one:

* Runs on a schedule that matches how often files arrive, for example every 15 minutes.
* Keeps a record of the IDs it has already sent and skips them.
* Sends **one notification per recording**. It never sends transcripts, metadata or other sidecar files.
* Logs every attempt with the ID, the HTTP status and the `error` field of the response.
* Keeps each file for a retention window (for example 7 days) so a failed recording can be sent again, then deletes it on your own schedule. Triple Session doesn't send a "you can delete it" callback.

## Test the setup

<Steps>
  <Step title="Send one real notification">
    Use one real recording and the email of a member with a recording seat. Use a `meeting_id` you haven't sent before.
  </Step>

  <Step title="Check the run log">
    Open the automation and click **View Logs**. Each notification that reaches a member creates a run. Open the **Review Meeting** action to see its status and any error.

    If the request returned `200` but no run appears, the `sales_rep_email` doesn't match a member of your company.
  </Step>

  <Step title="Check the call">
    When the action completes, the evaluated call appears on the agent's call list in AI Coach.
  </Step>
</Steps>

### Common errors in the run log

| Error | Cause |
| - | - |
| `Recording source returned 401 Unauthorized for <host>` | The Recording source rejected the credential. Replace it in the action, or check it on your side. `403` and `404` work the same way. |
| `Host <host> is not the pinned host <pinned host>` | `recording_url` points to a different host than the one you pinned. |
| `Recording URL must use https when source authentication is configured` | `recording_url` starts with `http://`. |
| `Media file URL must resolve only to public IP addresses` | The host resolves to a private or internal address. |
| `Unsupported file type: text/html…` | The source returned something other than audio or video, often a login page. |
| `Media response has no Content-Type and URL does not have a supported extension` | Add a `Content-Type` header, or make the URL end in a supported extension. |
| `Media file is too large. Maximum supported size is 2 GB` | The file is over the size limit. |
| `Failed to access media file URL: request to <host> failed` | Triple Session couldn't connect, the TLS handshake failed, or no response arrived within 30 seconds. |
| `Recording seat required to process review meeting action` | The agent has no recording seat. |
| `Insufficient standard credits to process review meeting action` | The agent has no standard credits left. |
