Common integration patterns
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. 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
- 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
metadatabefore resending. An optionalIdempotency-Keyderived 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
dialrate limit (30 call starts per minute per workspace). Bursts above it are answered429; 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:
2. Create the campaign with up to 1000 of those ids:
3. Add the rest in chunks of up to 1000, while it is still a draft:
4. Start it:
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.
- JavaScript (Node.js 20+)
nightly-batch.mjs - Python 3.9+ (requests)
nightly-batch.py
How the job is built:
- Query the rows for tonight (people to remind, renew, follow up), with phone number, name, consent and do-not-call status from your records.
- Upsert contacts, paced. Space the writes 600 ms apart (100 per minute, inside the
writelimit of 120 per key per minute) and resends after a429onceRetry-Afterhas passed (a429means nothing was done). For large lists, watchRateLimit-Remainingand slow down before you reach zero. - Create one campaign with up to 1000
contact_ids, then add the rest in chunks of 1000 with POST/campaigns/{campaign_id}/recipientswhile it is still a draft (up to 10,000 per campaign). Once started, its recipients are fixed: tomorrow's list is a new campaign. - 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.
- 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:
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.
- cURL script (bash)
reconcile-calls.sh - JavaScript (Node.js 20+)
reconcile-calls.mjs - Python 3.9+ (requests)
reconcile-calls.py
Rules for reliable reconciliation:
- Overlap the windows. Use
created_aftera 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 bystatus, a page can hold fewer thanlimitcalls whilehas_moreis true: keep followingnext_cursoruntil it isnull. - Only sync what is final. Skip calls whose
settledis stillfalse; the next run picks them up. - Use
metadatato match. The call carries your record id. - For campaigns, list GET
/campaignsfiltered bystatusand 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-RemainingandRateLimit-Reseton every response and slow down early. Treat a429as 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 ...andContent-Type: application/json. If the tool retries failed steps on its own, also send an optionalIdempotency-Keybuilt 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.codeanderror.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
- Build and test with a test key, a test-mode webhook endpoint and the test numbers.
- 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.
- Do not migrate stored ids: contacts, calls, campaigns, endpoints and events are separate in each mode, and test ids are
404with a live key. - 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.
- Go live with a small slice of real records first. See 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 |