Skip to main content

Overview

When you trigger an AI outbound contact with a callback_url, Oliver sends an HTTP POST request to that URL once the request reaches a terminal status (completed, suppressed, or failed). Exactly one outcome webhook is delivered per request, and only when a callback_url was provided. The outcome is also always available by polling GET /ai_outbound/requests/{id} — the webhook simply lets you close the loop without polling.

When the webhook fires

The webhook fires once per request, on any terminal event — not only when outcome is set. Every accepted request ends in exactly one of the three events below, so every request with a callback_url produces exactly one webhook.
A suppression is a legitimate terminal outcome (for example, the client was contacted too recently), not an error — so it still fires a webhook. Because outcome stays null on a suppressed request, branch your handler on status (or event) rather than on the presence of outcome.

Suppressed requests

Suppression can happen at two points:
  1. At intake, before Oliver responds to your POST. The create call returns 200 with status: "suppressed" and the suppression_reason already populated, and the webhook is dispatched immediately afterward. If you handle the synchronous response, this webhook is confirmation rather than news.
  2. At dispatch or send time, after the request was accepted with 201. Eligibility is re-checked just before the message goes out, so a request queued minutes earlier can still be suppressed — for example, the client opted out, or another AI conversation opened in the meantime.
Common suppression_reason values: Treat this list as open-ended: new gates may add reasons, so handle an unrecognized suppression_reason gracefully.

Failed requests

ai_outbound.failed covers two cases, distinguished by outcome:
  • outcome: "undeliverable" — the carrier reported that the number cannot receive SMS (for example, a landline). This is not an opt-out; the client never made a choice.
  • outcome: null — dispatch never completed. error_message explains why, e.g. "dispatch did not complete within 96 hours".

Timing: when does no_response fire?

no_response is reported after 48 hours of silence following the initial outbound text — no reply, no booking, no opt-out. API-triggered outreach sends a single opener and is then reply-driven (there are no automated follow-up messages), so the clock starts at contacted_at. If the client does reply, the conversation stays live and the request resolves on its own merits — booked, opted_out, or no_response once the thread has been quiet for the same 48-hour window. A backstop sweeper closes out any request that has not reached a terminal status 96 hours after creation (the 48-hour send window plus the 48-hour conversation grace, with margin). When that sweeper has to close a request that never got as far as sending, the result is ai_outbound.failed with an error_message, not no_response.

Payload Structure

The webhook payload is sent as a JSON object with the following structure:
string
required
The event name, in the form ai_outbound.{status} — e.g. ai_outbound.completed, ai_outbound.suppressed, or ai_outbound.failed.
string
required
Oliver’s unique identifier for the request.
string
The idempotency key you supplied when creating the request, if any.
string
required
Oliver’s identifier for the practice (the value you send in the X-Client-Id header).
string
required
The outreach channel. One of sms, voice.
string
required
The purpose of the outreach. One of scheduling, callback_followup, refill, general.
string
required
The terminal status of the request. One of completed, suppressed, failed.
string
The resolution. booked, opted_out, or no_response on a completed request; undeliverable on a failed request whose text bounced. null on a suppressed request, and on a failure that never reached the carrier.
object
Additional structured detail about the outcome, when available. On a booked outcome this is { "appointment_id": "..." }, identifying the appointment Oliver booked. null otherwise.
string
Why the request was suppressed, when status is suppressed. See Suppressed requests for the values.
string
Failure detail, when status is failed.
string
Oliver’s identifier for the resolved client.
string
Oliver’s identifier for the resolved patient, if applicable.
string
When the first outbound message was sent, in ISO 8601 format. Null if the client was never contacted (e.g. suppressed at intake).
string
required
When the request reached its terminal status, in ISO 8601 format.
string
required
When the webhook was sent, in ISO 8601 format.

Example Payloads

Verifying Webhook Signatures

When your practice has an AI outbound webhook secret configured, each request includes a signature in the X-Webhook-Signature header. You should verify this signature before processing the payload.

Signature Verification Process

  1. Extract the X-Webhook-Signature header from the request
  2. Compute an HMAC-SHA256 hash of the raw request body using your webhook secret
  3. Compare the computed signature with the one in the header

Example Verification

Always use constant-time comparison functions (like hmac.compare_digest in Python or crypto.timingSafeEqual in Node.js) to prevent timing attacks.

Delivery and Retries

  • Webhooks are delivered by an HTTP POST with a Content-Type: application/json body and a User-Agent of Oliver-AiOutbound/1.0.
  • Your callback_url must be https://. Oliver verifies the TLS certificate and allows 10 seconds for a response.
  • Respond with a 2xx status code to acknowledge receipt. Process asynchronously if needed to avoid timeouts.
  • If your endpoint does not respond successfully, Oliver retries up to 3 attempts in total with exponential backoff (2s, then 4s) before marking the webhook as failed. A failed webhook is not retried afterward — fall back to GET /ai_outbound/requests/{id} to recover the outcome.
  • An hourly reconciliation job re-sends any webhook that was enqueued but never delivered (for example, if delivery was interrupted). Delivery is therefore at-least-once in that narrow window, so make your handler idempotent — de-duplicate on request_id.