Skip to content
RelationsAI
Guides

Common integration patterns

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

PatternScopes
Event-triggered single callscontacts:write, calls:write, calls:read, analysis:read
Nightly campaigncontacts:write, campaigns:write, campaigns:read
Reconciliationcalls:read (and campaigns:read for campaigns)
Receiving results (all patterns)webhooks:write once, to register the endpoint

See 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.
  • 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 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:

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" }'

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

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" ] }'

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

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" ] }'

4. Start it:

curl -X POST https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/start \ -H "Authorization: Bearer <token>"

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

Nightly batch: upsert today's contacts, build one campaign and start it.

The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.

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 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.

Schedule the job so that the campaign starts inside your workspace's calling window: a start outside it is refused with 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:

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>"

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

Catch anything your webhook handler missed: list recent calls and sync the settled ones

The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.

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. 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 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:

ClassPer keyPer workspace
read600 / min1200 / min
write120 / min240 / min
dial30 / 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 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.

Choosing a pattern

NeedUse
React within seconds to one eventEvent-triggered single calls
Call a list (tens to thousands) for one purposeA campaign
Call a list on a scheduleA nightly (or hourly) campaign job
Make sure nothing is missedReconciliation, alongside either
No servers of your ownAn 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-patternWhy it hurtsInstead
Polling every call in a tight loopBurns the read limit and still lagsWebhooks, plus a list-based reconciliation
A loop of single calls for a listHits the dial limit, no screening summary, no pacingA campaign
One shared key for every componentOne noisy job starves the others, and a leak exposes everythingOne key per component, least scopes
Blind retries after timeoutsDuplicate calls or campaignsCheck state by your metadata or names first; optionally keys derived from your event or batch ids
Adding recipients to a started campaignRefused: recipients are fixed at startA new campaign for the new audience

Next steps

Going live.