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

# Retry & Escalation

> Configure per-step retry intervals and email/webhook escalation when a contact's call attempts are exhausted.

# Retry & Escalation

When an outbound voice call doesn't connect — the contact didn't answer, the line was busy, or it went to voicemail — your campaign can automatically:

1. **Retry** the contact later, on a custom schedule (uniform or per-step).
2. **Escalate** to a human via email or webhook once retries are exhausted (or if the contact permanently failed).

Both are configured per-campaign in **Advanced Settings** of the new-campaign modal.

***

## Retry on No Answer

Failed outcomes that trigger a retry: `no_answer`, `busy`, `voicemail`.

### Per-step interval schedule (recommended)

Instead of a single interval that applies to every retry, you can specify a **list of delays** — one entry per retry, in order. The first retry runs after the initial attempt fails.

| Retry | Delay                   |
| ----- | ----------------------- |
| #1    | 1 hour (60 minutes)     |
| #2    | 5 hours (300 minutes)   |
| #3    | 24 hours (1440 minutes) |

Builder UI in the campaign modal:

```
Retry schedule — one row per retry. First retry runs after the initial attempt fails.

  Retry #1   after [   60 ] minutes  ✕
  Retry #2   after [  300 ] minutes  ✕
  Retry #3   after [ 1440 ] minutes  ✕

  + Add another retry

  Example: 60, 300, 1440 = retry after 1 h, then 5 h, then 24 h.
```

The number of rows is the number of retries the platform will make (the original attempt is not counted). Maximum 10 retries per contact.

### Uniform mode (legacy)

Older campaigns saved a single `intervalMinutes` value applied to every retry, plus a `maxAttempts` count. Those campaigns continue to work — the backend reads the legacy shape and treats it as the equivalent per-step list.

### Persisted shape

In the campaign's `config` JSONB column:

```json theme={null}
{
  "retry": {
    "enabled": true,
    "intervalsMinutes": [60, 300, 1440]
  }
}
```

Both shapes are accepted on save:

```json theme={null}
// New per-step (preferred)
{ "enabled": true, "intervalsMinutes": [60, 300, 1440] }

// Uniform (legacy, still works)
{ "enabled": true, "maxAttempts": 3, "intervalMinutes": 60 }
```

When both are present, **per-step wins** and the length of `intervalsMinutes` dictates the attempt count.

### Behaviour notes

* Retries are scheduled via the same arq deferred-job pipeline used elsewhere in the platform (proven in production).
* If the contact's wallet has no funds at retry time, the dispatch is skipped and logged — no infinite loop.
* DND / TRAI compliance checks run on every retry, the same as the original attempt.
* A retry that succeeds (caller picks up) ends the chain immediately for that contact.

***

## Escalation

Escalation fires **once per contact** when the contact has either:

* Exhausted every configured retry without picking up, **or**
* Hit a permanent failure (`invalid_number`, `error`, `undelivered`, etc.) that retries can't recover from, **or**
* Failed with retries disabled.

It does not fire when a contact picks up, regardless of conversation outcome — that's a successful delivery as far as the platform is concerned.

### Channels

Both channels are independent. Either or both may be configured per campaign — at least one is required for escalation to fire.

#### Email

A short HTML message is sent via your configured email pipeline (Resend) to the address you set.

**Subject:**

```
[Acme Onboarding Campaign] Contact escalated — *********4321
```

**Body:**

> A contact in campaign **Acme Onboarding Campaign** has exhausted its retry attempts and was flagged for escalation.
>
> |              |                            |
> | ------------ | -------------------------- |
> | Contact      | John Doe `<+919876543210>` |
> | Last outcome | `no_answer`                |
> | Attempts     | 3                          |
> | Campaign ID  | 47                         |
> | Run ID       | 1029                       |
> | Timestamp    | 2026-04-20 13:02:14 IST    |
>
> *Action: reach out to this contact manually or investigate why retries failed.*

The timestamp is rendered in the **timezone configured in your campaign's Schedule step** (defaults to `Asia/Kolkata` if unset). The contact's phone number is masked to its last 4 digits in the subject; the full number appears in the body.

#### Webhook

If a webhook URL is set, a JSON payload is POSTed to it with a 10-second timeout:

```json theme={null}
{
  "event": "campaign.contact_escalated",
  "campaign_id": 47,
  "campaign_name": "Acme Onboarding Campaign",
  "run_id": 1029,
  "contact_id": 12891,
  "contact_phone": "+919876543210",
  "contact_name": "John Doe",
  "outcome": "no_answer",
  "attempt": 3,
  "timestamp": "2026-04-20T07:32:14Z",
  "timestamp_local": "2026-04-20 13:02:14 IST",
  "timezone": "Asia/Kolkata"
}
```

`timestamp` is always UTC ISO-8601 for machine consumers; `timestamp_local` and `timezone` mirror what humans see in the email.

A response with status `2xx` is treated as a successful delivery. Non-2xx responses are logged but don't retry — the platform fires escalation exactly once per contact.

### Configuration

In the campaign modal, **Advanced Settings → Escalation**:

```
☑ Escalation
   Email recipient            [ oncall@yourcompany.com           ]
   Webhook URL                [ https://you.com/hooks/campaign   ]
                              POSTs JSON: {event, campaign_id, ...}

   At least one is required for escalation to fire. Both will be tried independently.
```

Persisted shape in `config`:

```json theme={null}
{
  "escalation": {
    "enabled": true,
    "escalateEmail": "oncall@yourcompany.com",
    "escalateWebhookUrl": "https://you.com/hooks/campaign"
  }
}
```

Legacy fields `email` and `escalateTo` are still read by the backend for backward compatibility.

### Counter

Each successful escalation dispatch (at least one channel accepted the call) increments `escalated_count` on the campaign run. You can see this in the campaign results panel and via the API:

```bash theme={null}
curl https://api.thinnest.ai/campaigns/campaign_123/results \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```json theme={null}
{
  "campaign_id": "campaign_123",
  "status": "completed",
  "total_contacts": 500,
  "sent": 498,
  "delivered": 485,
  "failed": 13,
  "escalated": 11
}
```

***

## End-to-end flow

1. Campaign starts → contacts dialed in batches.
2. A contact's first attempt returns `no_answer`.
3. **Retry #1** is scheduled for `intervalsMinutes[0]` minutes from now.
4. Retry runs at the scheduled time, returns `busy`.
5. **Retry #2** is scheduled for `intervalsMinutes[1]` minutes after retry #1.
6. Retry #2 runs, returns `voicemail`.
7. There are no more retries (list length = 3, this was retry #3 candidate but the schedule only had 3 entries → stop).
8. **Escalation fires** — email is sent + webhook POSTed.
9. `escalated_count` on the run goes up by 1.

If the contact picks up at any retry, the chain ends and no escalation fires.

***

## Best practices

* Use **escalating delays** (`60, 300, 1440`) rather than aggressive uniform delays. Calling someone six times in an hour is a fast way to become spam.
* Set both **email + webhook** so a missed email (filter, vacation) doesn't mean a missed lead.
* For high-volume campaigns, point the webhook at a real ticketing system (Zendesk, HubSpot, Slack) so escalations land in your team's queue.
* Use the campaign's **Schedule timezone** rather than relying on UTC for the email timestamp — operators reading the email shouldn't have to do mental math.

***

## Related

* [Outbound Campaigns](/docs/campaigns) — Campaign basics.
* [Contact Management](/docs/campaigns/contact-management) — Importing contacts.
