# Webhooks

An outbound webhook is an HTTP POST that Ringtime sends to your system. It tells you what
happened in a conversation, without you polling for it. Ringtime sends one POST per event
to the single endpoint your organization registered.

## Registering your endpoint

You cannot register a webhook endpoint with your API key. Registration is not part of the
public API today.

Ask Ringtime to set it up. Send your HTTPS receiver URL to success@ringtime.ai. Ringtime
registers the URL and gives you a signing secret that starts with `whsec_`.

Ringtime shows the signing secret once, at registration. Store it in your own secret
store. If you lose it, or you think it leaked, ask Ringtime to rotate it. A rotation
issues a new secret and old signatures stop verifying.

## The envelope

Every webhook body has the same four top-level fields.

```json
{
  "event": "task.completed",
  "schema_version": 1,
  "occurred_at": "2026-09-15T14:22:05Z",
  "data": {}
}
```

| Field | Type | What it is |
|---|---|---|
| `event` | string | The event type. `task.completed` or `interaction.finalized`. |
| `schema_version` | integer | The envelope version. `1` today. |
| `occurred_at` | string | When the event happened, ISO 8601 in UTC with a `Z` suffix. Not when the POST was sent. |
| `data` | object | The body for this event type. The shape depends on `event`. |

`data` is an open object. Ringtime adds fields over time and never removes one. Ignore
keys you do not know, and do not fail on them.

## The two events

| Event | When | On by default |
|---|---|---|
| `task.completed` | The task reached its final state. One per task. | Yes |
| `interaction.finalized` | One contact attempt finished. One per attempt. | No |

`interaction.finalized` is opt-in. Ask Ringtime to add it to your endpoint if you want
per-attempt detail. A task with three attempts then sends three `interaction.finalized`
events and one `task.completed`.

## `task.completed`

The business result of the whole task.

| Field | Type | What it is |
|---|---|---|
| `id` | string | The task id, for example `task_01HZX9Q2K9`. |
| `campaign` | object | The campaign that produced the task. See below. |
| `status` | string | The final task status. `completed` today. |
| `outcome` | string | The single headline result. See the values below. |
| `participant` | object | The person Ringtime contacted. See below. |
| `ids` | array | Your external ids, echoed from the trigger. Empty for a task you did not trigger. |
| `context` | object | The `context` you sent on the trigger, returned as sent. Empty for a task you did not trigger. |
| `business_outcomes` | object | The full picture of what the task achieved. See below. |

`outcome` is one of `booked`, `qualified`, `not_eligible`, `screened`, `escalated`,
`callback`, `declined`, `opted_out`, or `none`. `none` means Ringtime reached the person
but recorded no business result. The set can grow, so treat an unknown value as `none`
rather than failing. `outcome` is a summary. Read `business_outcomes` for the detail.

`campaign` carries `external_id`, the routing key you triggered with, and `name`, the
campaign name in the participant's language. Both are `null` when Ringtime has no
campaign for the task.

`participant` carries `first_name`, `last_name`, `phone` in E.164, and `language`, a
BCP 47 code or `null`.

Each entry in `ids` carries `source`, `type` and `value`, exactly as you sent them on the
trigger.

`business_outcomes` holds the outcomes Ringtime captured across every attempt of the task.
The keys are `bookings` and `side_effects` (arrays, one entry per occurrence) and
`screening_assessment`, `lead_qualification`, `opt_out`, `disinterest`, `not_eligible`,
`escalation` and `callback_preference` (an object or `null`, the most recent one wins).

### Example

```json
{
  "event": "task.completed",
  "schema_version": 1,
  "occurred_at": "2026-09-15T14:22:05Z",
  "data": {
    "id": "task_01HZX9Q2K9",
    "campaign": {
      "external_id": "warehouse-nl",
      "name": "Warehouse operator"
    },
    "status": "completed",
    "outcome": "screened",
    "participant": {
      "first_name": "Jane",
      "last_name": "Doe",
      "phone": "+15555550100",
      "language": "nl"
    },
    "ids": [
      { "source": "carerix", "type": "candidate", "value": "42881" },
      { "source": "carerix", "type": "application", "value": "90117" }
    ],
    "context": {
      "requisition": "REQ-118"
    },
    "business_outcomes": {
      "bookings": [],
      "screening_assessment": {
        "screening_flow_id": "sf_01HZX9Q2M2",
        "flow_name": "Warehouse intake",
        "language": "nl",
        "score": 78,
        "score_scale": "0_100",
        "threshold": 60,
        "answers": [
          {
            "position": 1,
            "screening_question_id": "sq_01HZX9Q2M3",
            "question": "Can you work early shifts?",
            "question_type": "yes_no",
            "category": "availability",
            "weight": "mandatory",
            "possible_answers": ["yes", "no"],
            "value": true,
            "source": "heard",
            "answer": "Yes, from 6am onwards.",
            "assessment": "positive"
          }
        ],
        "unanswered": [],
        "has_mandatory_fail": false,
        "has_soft_negative": false,
        "rationale": "All mandatory questions answered positively.",
        "recommended_next_step": "proceed"
      },
      "lead_qualification": null,
      "side_effects": [],
      "opt_out": null,
      "disinterest": null,
      "not_eligible": null,
      "escalation": null,
      "callback_preference": null
    }
  }
}
```

## `interaction.finalized`

One contact attempt, with its transcript. Where `task.completed` says what the task
achieved, this says whether Ringtime got through at all.

| Field | Type | What it is |
|---|---|---|
| `id` | string | The interaction id, for example `int_01HZX9Q2KA`. |
| `task_id` | string or null | The parent task id. `null` when the interaction has no task. |
| `campaign` | object | Same shape as on `task.completed`. |
| `participant` | object | Same shape as on `task.completed`. |
| `ids` | array | Same shape as on `task.completed`. |
| `channel` | string | `voice`, `whatsapp`, `sms`, `chat` or `slack`. |
| `direction` | string | `outbound` or `inbound`. |
| `started_at` | string or null | When the session started, if it did. |
| `ended_at` | string or null | When the attempt ended, if known. |
| `duration_secs` | integer or null | Session length in seconds, when both ends are known. |
| `reach` | string or null | How far the attempt got. `unreached`, `reached`, `engaged` or `resolved`. Voicemail counts as `unreached`. |
| `answer_type` | string or null | How the attempt was answered, for example `answered_by_human`, `answered_by_voicemail`, `not_answered`, `not_answered_busy` or `replied`. `null` when it does not apply. |
| `system_failure` | boolean | `true` when Ringtime, not the person, failed the attempt. |
| `transcript` | array | The conversation, turn by turn. |
| `business_outcome_events` | array | The outcomes captured on this attempt. |

Each transcript turn carries `role` (`agent` or `participant`), `text`, and `at`, the time
of the turn. Voice turns are transcribed. The transcript carries speech only. Tool calls
and their results never appear in it, and there is no recording link.

Each entry in `business_outcome_events` carries `id`, `kind`, `payload`,
`schema_version` and `occurred_at`. `kind` names the shape of `payload` and can gain new
values, so skip an entry whose `kind` you do not know and keep processing the rest.

### Example

```json
{
  "event": "interaction.finalized",
  "schema_version": 1,
  "occurred_at": "2026-09-15T14:21:48Z",
  "data": {
    "id": "int_01HZX9Q2KA",
    "task_id": "task_01HZX9Q2K9",
    "campaign": {
      "external_id": "warehouse-nl",
      "name": "Warehouse operator"
    },
    "participant": {
      "first_name": "Jane",
      "last_name": "Doe",
      "phone": "+15555550100",
      "language": "nl"
    },
    "ids": [{ "source": "carerix", "type": "candidate", "value": "42881" }],
    "channel": "voice",
    "direction": "outbound",
    "started_at": "2026-09-15T14:18:02Z",
    "ended_at": "2026-09-15T14:21:48Z",
    "duration_secs": 226,
    "reach": "engaged",
    "answer_type": "answered_by_human",
    "system_failure": false,
    "transcript": [
      {
        "role": "agent",
        "text": "Hi Jane, this is Acme Staffing calling about the warehouse role.",
        "at": "2026-09-15T14:18:05Z"
      },
      {
        "role": "participant",
        "text": "Yes, I have a few minutes.",
        "at": "2026-09-15T14:18:11Z"
      }
    ],
    "business_outcome_events": [
      {
        "id": "boe_01HZX9Q2MB",
        "kind": "screening_assessment",
        "schema_version": 1,
        "occurred_at": "2026-09-15T14:21:40Z",
        "payload": {
          "screening_flow_id": "sf_01HZX9Q2M2",
          "flow_name": "Warehouse intake",
          "language": "nl",
          "score": 78,
          "score_scale": "0_100",
          "threshold": 60,
          "answers": [],
          "unanswered": [],
          "has_mandatory_fail": false,
          "has_soft_negative": false,
          "rationale": "All mandatory questions answered positively.",
          "recommended_next_step": "proceed"
        }
      }
    ]
  }
}
```

## Verifying the signature

Verify every request before you trust it. Ringtime signs each POST with your secret,
following the [Standard Webhooks](https://www.standardwebhooks.com/) specification.

Three headers carry the proof.

| Header | What it is |
|---|---|
| `webhook-id` | The event id. Stable across retries. Also your duplicate key. |
| `webhook-timestamp` | When the request was signed, in Unix seconds. |
| `webhook-signature` | `v1,` followed by the base64 signature. |

The `v1,` inside `webhook-signature` is the Standard Webhooks tag for the HMAC-SHA256
algorithm. It is a fixed part of the header format, not a version number, and it never
changes.

To verify:

1. Take the key. Strip the `whsec_` prefix from your secret and base64-decode the rest.
   Those raw bytes are the HMAC key.
2. Build the signed string. Join the `webhook-id`, the `webhook-timestamp` and the **raw**
   request body with a dot: `id.timestamp.body`. Use the body exactly as received. Do not
   parse and re-serialize it.
3. Compute HMAC-SHA256 over that string with the key, and base64-encode the result.
4. Compare `v1,` plus your result against the `webhook-signature` header, with a
   constant-time comparison.
5. Reject the request when `webhook-timestamp` is more than 300 seconds away from your
   own clock.

### With a library

Use a Standard Webhooks library and you get all five steps for free. Pass the secret
exactly as Ringtime gave it to you, `whsec_` prefix included.

```python
from standardwebhooks.webhooks import Webhook

# Raises on a bad signature or an out-of-window timestamp.
Webhook(secret).verify(raw_body, request.headers)
```

### By hand

```bash
# The key: the secret without its prefix, base64-decoded.
KEY_HEX=$(printf '%s' "${RINGTIME_WEBHOOK_SECRET#whsec_}" \
  | openssl base64 -d | xxd -p | tr -d '\n')

# The signature over "id.timestamp.body".
printf '%s.%s.%s' "$WEBHOOK_ID" "$WEBHOOK_TIMESTAMP" "$RAW_BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt hexkey:"$KEY_HEX" -binary \
  | openssl base64 -A
```

Compare the output against the part of `webhook-signature` after `v1,`.

## Delivery and retries

Delivery is at least once. Your endpoint can receive the same event twice, for example
when your 2xx answer is lost on the way back. Make your handler idempotent. Keep the
`webhook-id` of every event you processed and drop a repeat. A retry of the same event
carries the same `webhook-id` and the same body, byte for byte.

Answer fast. Ringtime waits 10 seconds for a response, then treats the attempt as failed.
Acknowledge with a 2xx status first and do your own work afterwards.

Ringtime retries when your endpoint answers 429, answers 5xx, times out, or cannot be
reached. The wait grows exponentially from 1 minute, with full jitter, and is capped at
1 hour. On a 429 Ringtime honours a numeric `Retry-After` header, clamped to between
1 minute and 1 hour. A `Retry-After` given as a date is ignored.

Ringtime stops retrying in two cases:

- Your endpoint answers any other 4xx. That is a refusal, so Ringtime does not retry.
- 24 hours passed since the first attempt. The event is then dropped.

## Requirements for your endpoint

- Use HTTPS. Plain HTTP is refused at registration.
- Be reachable from the public internet. Ringtime does not call private, loopback or
  link-local addresses.
- Do not put a username or password in the URL. Ringtime refuses such a URL.
- Answer within 10 seconds with a 2xx status.
- Be idempotent. Dedupe on the `webhook-id` header.
- Verify the signature before you act on the body.

## Next

- [How it works](/how-it-works)
- [Getting started](/getting-started)
- [Errors](/errors)
