# Common integration patterns

**Availability: Preview**

This page collects integration designs that work well with the RelationsAI API, with the trade-offs of each, so you can pick a starting point for your own system.

Every pattern here uses the same building blocks: idempotent writes, webhooks for results, and the list endpoints for reconciliation.

## Before you begin

| Pattern | Scopes |
|---|---|
| Event-triggered single calls | `contacts:write`, `calls:write`, `calls:read`, `analysis:read` |
| Nightly campaign | `contacts:write`, `campaigns:write`, `campaigns:read` |
| Reconciliation | `calls:read` (and `campaigns:read` for campaigns) |
| Receiving results (all patterns) | `webhooks:write` once, to register the endpoint |

See [API keys](/getting-started/api-keys). Give each component its own key with only the scopes it needs.

## Event-triggered single calls

Your system places one call per business event (a new lead, a missed payment, an appointment tomorrow) and reacts to webhooks.

```text
business event ─▶ queue ─▶ worker: upsert contact, POST /calls (key = event id)
call.ended / call.settled ─▶ webhook receiver ─▶ inbox ─▶ worker: update your record
```

- Put events on a queue and place calls from a worker, not from the request that produced the event. The worker can then retry with backoff without blocking anything.
- Record each event id you handled and skip a redelivered one, so it never places a second call; after a timeout, check for the call by `metadata` before resending. An optional `Idempotency-Key` derived from the event (`<event id>-call`) adds a second guard. See [Retry and reliability practices](/guides/retry-and-reliability).
- Put your ids in `metadata`; the webhook handler needs nothing else to find the record.
- Respect the `dial` rate limit (30 call starts per minute per workspace). Bursts above it are answered `429`; let the queue absorb them.

The [CRM integration guide](/guides/integrating-with-crm-systems) shows this pattern in full, with a runnable example.

**Trade-offs:** lowest latency (the call starts seconds after the event) and the simplest mental model. But pacing, calling-hours timing and per-recipient bookkeeping are yours to build, so it suits tens of calls per hour, not thousands at once.

## Nightly campaign from a data warehouse

A scheduled job builds a campaign from a query, starts it and collects per-recipient outcomes.

**1. Upsert each row** as a contact, pacing the requests (for example one every 600 ms) and keeping each `id`:

```bash
curl -X POST https://voice-api.relationsai.com/v1/contacts/upsert \
     -H "Authorization: Bearer <token>" \
     -H "Content-Type: application/json" \
     -d '{
  "phone_number": "+12025550100",
  "name": "Asha Rao"
}'
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/contacts/upsert", {
  method: "POST",
  headers: {
    "Authorization": "Bearer <token>",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "phone_number": "+12025550100",
    "name": "Asha Rao"
  }),
});
const contact = await response.json();
```
```python

response = requests.post(
    "https://voice-api.relationsai.com/v1/contacts/upsert",
    headers={
        "Authorization": "Bearer <token>",
    },
    json={
        "phone_number": "+12025550100",
        "name": "Asha Rao",
    },
    timeout=30,
)
contact = response.json()
```

**2. Create the campaign** with up to 1000 of those ids:

```bash
curl -X POST https://voice-api.relationsai.com/v1/campaigns \
     -H "Authorization: Bearer <token>" \
     -H "Content-Type: application/json" \
     -d '{
  "name": "Nightly renewals 2026-10-21",
  "agent_id": "agent_1ZltiWzxrXmMprGD6q63",
  "concurrency_limit": 5,
  "budget_cap": {
    "amount": 500000,
    "currency": "INR"
  },
  "contact_ids": [
    "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1",
    "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3"
  ]
}'
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/campaigns", {
  method: "POST",
  headers: {
    "Authorization": "Bearer <token>",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "name": "Nightly renewals 2026-10-21",
    "agent_id": "agent_1ZltiWzxrXmMprGD6q63",
    "concurrency_limit": 5,
    "budget_cap": {
      "amount": 500000,
      "currency": "INR"
    },
    "contact_ids": [
      "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1",
      "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3"
    ]
  }),
});
const campaign = await response.json();
```
```python

response = requests.post(
    "https://voice-api.relationsai.com/v1/campaigns",
    headers={
        "Authorization": "Bearer <token>",
    },
    json={
        "name": "Nightly renewals 2026-10-21",
        "agent_id": "agent_1ZltiWzxrXmMprGD6q63",
        "concurrency_limit": 5,
        "budget_cap": {
            "amount": 500000,
            "currency": "INR",
        },
        "contact_ids": [
            "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1",
            "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3",
        ],
    },
    timeout=30,
)
campaign = response.json()
```

**3. Add the rest** in chunks of up to 1000, while it is still a draft:

```bash
curl -X POST https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/recipients \
     -H "Authorization: Bearer <token>" \
     -H "Content-Type: application/json" \
     -d '{
  "contact_ids": [
    "ct_5Qw8Er2Ty6Ui9Op3As7Df4"
  ]
}'
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/recipients", {
  method: "POST",
  headers: {
    "Authorization": "Bearer <token>",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "contact_ids": [
      "ct_5Qw8Er2Ty6Ui9Op3As7Df4"
    ]
  }),
});
const added = await response.json();
```
```python

response = requests.post(
    "https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/recipients",
    headers={
        "Authorization": "Bearer <token>",
    },
    json={
        "contact_ids": [
            "ct_5Qw8Er2Ty6Ui9Op3As7Df4",
        ],
    },
    timeout=30,
)
added = response.json()
```

**4. Start it:**

```bash
curl -X POST https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/start \
     -H "Authorization: Bearer <token>"
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/start", {
  method: "POST",
  headers: {
    "Authorization": "Bearer <token>",
  },
});
const campaign = await response.json();
```
```python

response = requests.post(
    "https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/start",
    headers={
        "Authorization": "Bearer <token>",
    },
    timeout=30,
)
campaign = response.json()
```

Make a re-run safe: before each step, check whether it already happened (a campaign named `Nightly renewals 2026-10-21` already exists, it is already `running`). Every request is executed, so a blind re-run creates a second campaign. An optional `Idempotency-Key` per step derived from the batch date (for example `nightly-2026-10-21-start`) makes a re-run replay the steps that already happened.

Complete runnable example: [JavaScript](/examples/guides/common-integration-patterns/nightly-batch.mjs) · [Python](/examples/guides/common-integration-patterns/nightly-batch.py)

How the job is built:

1. **Query** the rows for tonight (people to remind, renew, follow up), with phone number, name, consent and do-not-call status from your records.
2. **Upsert contacts**, paced. Space the writes 600 ms apart (100 per minute, inside the `write` limit of 120 per key per minute) and resends after a `429` once `Retry-After` has passed (a `429` means nothing was done). For large lists, watch `RateLimit-Remaining` and slow down before you reach zero.
3. **Create one campaign** with up to 1000 `contact_ids`, then add the rest in chunks of 1000 with [`POST /campaigns/{campaign_id}/recipients`](/api/campaigns#add-recipients) while it is still a draft (up to 10,000 per campaign). Once started, its recipients are fixed: tomorrow's list is a new campaign.
4. **Start it.** Before re-running the job the same night, look for tonight's campaign first (or send optional idempotency keys derived from the batch date), so a re-run never creates a second campaign.
5. **Collect outcomes** from `campaign.completed` (or poll the campaign) and page through [`GET /campaigns/{campaign_id}/recipients`](/api/campaigns#list-recipients).

Schedule the job so that the campaign starts inside your workspace's calling window: a start outside it is refused with [`outside_calling_hours`](/errors/outside_calling_hours) (`details` gives the window and the timezone it is evaluated in). Set a `budget_cap` on the campaign to limit what a wrong query can spend; the cap pauses the campaign, but the check runs about every 15 minutes, so spend can go past it by the calls placed in that time.

In live mode a start counts every recipient against the workspace's daily limit of calls placed through the API and is refused whole if they do not all fit: a campaign larger than `limits.daily_call_limit` cannot be started through the API. Split very large audiences across days.

**Trade-offs:** campaigns handle pacing (`concurrency_limit`), compliance screening of every recipient at start and resume, and per-recipient results for you, and one object tells you how the night went. But results arrive over the run and per-recipient data can lag by a minute, and recipients cannot be added after the start.

## Reconciliation

Webhooks are the fast path; reconciliation is the safety net. A scheduled job (for example hourly) lists recent calls and syncs anything your webhook handler missed, using the list endpoints and their cursors:

```bash
curl -X GET "https://voice-api.relationsai.com/v1/calls?limit=100&status=ended&created_after=2026-10-20T09:00:00.000Z" \
     -H "Authorization: Bearer <token>"
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/calls?limit=100&status=ended&created_after=2026-10-20T09:00:00.000Z", {
  method: "GET",
  headers: {
    "Authorization": "Bearer <token>",
  },
});
const page = await response.json();
```
```python

response = requests.get(
    "https://voice-api.relationsai.com/v1/calls",
    headers={
        "Authorization": "Bearer <token>",
    },
    params={
        "limit": 100,
        "status": "ended",
        "created_after": "2026-10-20T09:00:00.000Z",
    },
    timeout=30,
)
page = response.json()
```

For each call in `data` whose `settled` is `true` and that your system has not written back, write it back now (match it by `metadata`). While `has_more` is `true`, send the same request with `cursor` set to `next_cursor`.

Complete runnable example: [cURL script](/examples/guides/common-integration-patterns/reconcile-calls.sh) · [JavaScript](/examples/guides/common-integration-patterns/reconcile-calls.mjs) · [Python](/examples/guides/common-integration-patterns/reconcile-calls.py)

Rules for reliable reconciliation:

- **Overlap the windows.** Use `created_after` a little earlier than the last run (calls created just before the last run may have settled since). Your "already synced" table makes the overlap harmless.
- **Keep the filters fixed while paging.** A cursor is bound to the filters that produced it; changing them mid-way is [`invalid_cursor`](/errors/invalid_cursor). When you filter by `status`, a page can hold fewer than `limit` calls while `has_more` is true: keep following `next_cursor` until it is `null`.
- **Only sync what is final.** Skip calls whose `settled` is still `false`; the next run picks them up.
- **Use `metadata` to match.** The call carries your record id.
- For campaigns, list [`GET /campaigns`](/api/campaigns#list-campaigns) filtered by `status` and page through each campaign's recipients.

## Rate-limit budgeting

Limits are per key **and** per workspace, per mode. If several components share a workspace, they share the workspace limit:

| Class | Per key | Per workspace |
|---|---|---|
| `read` | 600 / min | 1200 / min |
| `write` | 120 / min | 240 / min |
| `dial` | | 30 / min |

Plan for it:

- Give each component its own key, and budget its share of the workspace limit (for example: the nightly job 100 writes per minute, the event worker 60, leaving headroom for the console and retries).
- Prefer one list request over many single reads, and webhooks over polling.
- Read `RateLimit-Remaining` and `RateLimit-Reset` on every response and slow down early. Treat a `429` as a signal to back off, not as an error to retry immediately.

## No-code and automation tools

Any HTTP-capable automation tool that can send requests with custom headers and receive webhooks can drive the API directly. Make sure the tool can:

- **Set headers**: `Authorization: Bearer ...` and `Content-Type: application/json`. If the tool retries failed steps on its own, also send an optional `Idempotency-Key` built from the trigger (the record id plus a step name), so its retries replay instead of repeating the action.
- **Store the key as a secret**, not in a step anyone can read or export.
- **Receive webhooks with the raw body available**, so you can verify the signature. If the tool cannot verify HMAC signatures on the raw body, put a small verifying receiver in front of it (adapt the [receiver](/guides/receiving-webhooks#a-complete-receiver) to forward verified events to the tool) rather than accepting unsigned requests.
- **Branch on `error.code` and `error.retry`**, not on the HTTP status alone.

Avoid tools that retry every failed step automatically with a new request id, and never paste a live key into a browser-based step.

## Test-to-live migration

1. Build and test with a test key, a test-mode webhook endpoint and the test numbers.
2. Create a live key per component with the same scopes, and a live webhook endpoint (its own secret). Keep test and live configuration side by side and switch by configuration.
3. Do not migrate stored ids: contacts, calls, campaigns, endpoints and events are separate in each mode, and test ids are `404` with a live key.
4. Re-create any test-mode do-not-call entries you rely on as live entries from your real records, not by copying the test list.
5. Go live with a small slice of real records first. See [Going live](/guides/going-live).

## Choosing a pattern

| Need | Use |
|---|---|
| React within seconds to one event | Event-triggered single calls |
| Call a list (tens to thousands) for one purpose | A campaign |
| Call a list on a schedule | A nightly (or hourly) campaign job |
| Make sure nothing is missed | Reconciliation, alongside either |
| No servers of your own | An automation tool, with a small verifying receiver for webhooks |

Prefer campaigns for batches: pacing, compliance screening and calling hours are handled for you. Use single calls for real-time triggers.

## What can go wrong

| Anti-pattern | Why it hurts | Instead |
|---|---|---|
| Polling every call in a tight loop | Burns the `read` limit and still lags | Webhooks, plus a list-based reconciliation |
| A loop of single calls for a list | Hits the `dial` limit, no screening summary, no pacing | A campaign |
| One shared key for every component | One noisy job starves the others, and a leak exposes everything | One key per component, least scopes |
| Blind retries after timeouts | Duplicate calls or campaigns | Check state by your `metadata` or names first; optionally keys derived from your event or batch ids |
| Adding recipients to a started campaign | Refused: recipients are fixed at start | A new campaign for the new audience |

## Next steps

[Going live](/guides/going-live).
