Campaigns
Run independent outbound call attempts against a list of contacts, or attribute inbound calls on a number to a campaign — with goals, retries, follow-ups, reporting, and improvement suggestions.
A campaign is a long-lived strategy + reporting object ("Q3 renewal calls", "Restaurant missed-call recovery"). It coordinates existing Rymi primitives (agents, calls, DNC, credits, webhooks) rather than replacing them — every dial still goes through the normal call path with quota, credit, DNC, and kill-switch checks applied per attempt.
For the concepts (campaigns vs. group call fanout, goals, follow-ups, compliance, the improve loop), see the Campaigns guide.
The Campaign Object
| Field | Type | Description |
|---|---|---|
id | uuid | Campaign ID |
tenant_id | string | Owning tenant |
agent_id | uuid | Published agent this campaign runs |
agent_snapshot_id | string | null | Snapshot recorded at launch (attribution only — calls always serve the agent's current published snapshot) |
type | string | outbound or inbound |
name | string | Campaign name |
goal | Goal object | What counts as success |
status | string | draft, scheduled, running, paused, completed, failed, archived |
schedule_policy | object | Calling windows, quiet hours, campaign timezone default |
retry_policy | object | max_attempts, backoff schedule |
concurrency_policy | object | Campaign cap, per-number rate |
automation_policy | object | Follow-up rules — see the guide |
reporting_policy | object | Reporting preferences |
compliance_policy | object | require_consent, require_consent_evidence, max_attempts_per_24h, ai_disclosure, dnc_scrub — see the guide |
post_call_config_override | object | null | Per-campaign override merged into the agent's post-call config |
stat_members … stat_provider_cost_micros | integer | Reducer-maintained counters read by the report endpoint in O(1) — see Reporting |
launched_at / paused_at / completed_at | string | null | Lifecycle timestamps |
created_at / updated_at | string | ISO 8601 timestamps |
Goal Object
| Field | Type | Required | Description |
|---|---|---|---|
goal_type | string | Yes | Short identifier, e.g. book_meeting |
success_field | string | Yes | Field in call_intelligence.structured_data evaluated for success |
success_when | object | Yes | Predicate: { equals }, { not_equals }, { exists }, { gte }, { lte }, or { in } |
secondary_fields | string[] | No | Additional fields to surface in the report (e.g. callback_requested) |
human_label | string | Yes | Human-readable goal description |
{
"goal_type": "book_meeting",
"success_field": "meeting_booked",
"success_when": { "equals": true },
"secondary_fields": ["callback_requested", "not_interested"],
"human_label": "Book a qualified meeting"
}Calls that skip analysis (skip_reason: "no_user_content") never count as a success.
Create Campaign
Creates a new campaign in draft status against a published agent. Publishable keys cannot create campaigns.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | uuid | Yes | Agent this campaign will run — must belong to the tenant |
type | string | Yes | outbound or inbound |
name | string | Yes | 1–200 characters |
goal | Goal object | No | Success criteria |
schedule_policy | object | No | Calling windows / quiet hours |
retry_policy | object | No | Max attempts + backoff |
concurrency_policy | object | No | Caps |
automation_policy | object | No | Follow-up rules |
reporting_policy | object | No | Reporting preferences |
compliance_policy | object | No | Consent gate, per-24h cap, AI disclosure, DNC-scrub mode — defaults to a reasonable floor (see the guide) |
metadata | object | No | Arbitrary key-value data |
curl -X POST https://api.rymi.live/v1/campaigns \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "550e8400-e29b-41d4-a716-446655440000",
"type": "outbound",
"name": "Q3 renewal calls",
"goal": {
"goal_type": "book_meeting",
"success_field": "meeting_booked",
"success_when": { "equals": true },
"human_label": "Book a qualified meeting"
},
"retry_policy": { "max_attempts": 3, "backoff_minutes": [120, 1440] }
}'const { campaign } = await rymi.campaigns.create({
agent_id: "550e8400-e29b-41d4-a716-446655440000",
type: "outbound",
name: "Q3 renewal calls",
goal: {
goal_type: "book_meeting",
success_field: "meeting_booked",
success_when: { equals: true },
human_label: "Book a qualified meeting",
},
retry_policy: { max_attempts: 3, backoff_minutes: [120, 1440] },
});result = rymi.campaigns.create(
agent_id="550e8400-e29b-41d4-a716-446655440000",
type="outbound",
name="Q3 renewal calls",
goal={
"goal_type": "book_meeting",
"success_field": "meeting_booked",
"success_when": {"equals": True},
"human_label": "Book a qualified meeting",
},
retry_policy={"max_attempts": 3, "backoff_minutes": [120, 1440]},
)Response 201
{
"campaign": {
"id": "9c1e...campaign",
"tenant_id": "ten_456",
"agent_id": "550e8400-e29b-41d4-a716-446655440000",
"agent_snapshot_id": null,
"type": "outbound",
"name": "Q3 renewal calls",
"goal": { "goal_type": "book_meeting", "success_field": "meeting_booked", "success_when": { "equals": true }, "human_label": "Book a qualified meeting" },
"status": "draft",
"stat_members": 0,
"stat_attempts": 0,
"launched_at": null,
"created_at": "2026-07-03T10:00:00Z",
"updated_at": "2026-07-03T10:00:00Z"
}
}Errors
| Status | Meaning |
|---|---|
400 | Validation error (type not outbound/inbound, missing name, malformed goal) |
401 | Missing or invalid API key |
403 | Publishable keys cannot create campaigns |
404 | agent_id not found for this tenant |
List Campaigns
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Max records (max 500) |
offset | integer | 0 | Records to skip |
status | string | — | Filter by status |
type | string | — | outbound or inbound |
agent_id | uuid | — | Filter by agent |
curl "https://api.rymi.live/v1/campaigns?status=running&limit=25" \
-H "Authorization: Bearer YOUR_API_KEY"const { campaigns } = await rymi.campaigns.list({ status: "running", limit: 25 });result = rymi.campaigns.list(status="running", limit=25)Response 200
{
"campaigns": [ { "id": "9c1e...campaign", "name": "Q3 renewal calls", "status": "running" } ],
"total": 1,
"offset": 0,
"limit": 25
}Errors
| Status | Meaning |
|---|---|
401 | Missing or invalid API key |
Get Campaign
curl https://api.rymi.live/v1/campaigns/9c1e...campaign \
-H "Authorization: Bearer YOUR_API_KEY"const { campaign } = await rymi.campaigns.get("9c1e...campaign");result = rymi.campaigns.get("9c1e...campaign")Errors
| Status | Meaning |
|---|---|
401 | Missing or invalid API key |
404 | Campaign not found |
Update Campaign
Updates policy/goal/name fields. status, agent_snapshot_id, launched_at, and the stat_* counters are lifecycle-owned and not writable here.
Request Body
At least one field required: name, goal, schedule_policy, retry_policy, concurrency_policy, automation_policy, reporting_policy, compliance_policy, post_call_config_override, metadata.
curl -X PATCH https://api.rymi.live/v1/campaigns/9c1e...campaign \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "retry_policy": { "max_attempts": 5 } }'const { campaign } = await rymi.campaigns.update("9c1e...campaign", {
retry_policy: { max_attempts: 5 },
});result = rymi.campaigns.update("9c1e...campaign", retry_policy={"max_attempts": 5})Errors
| Status | Meaning |
|---|---|
400 | Malformed goal, empty name, or no updatable fields provided |
401 | Missing or invalid API key |
403 | Publishable keys cannot update campaigns |
404 | Campaign not found |
Launch Campaign
Validates launch blockers, then transitions the campaign to running: stamps launched_at + agent_snapshot_id (the agent's current published snapshot), flips outbound members to ready, and fires campaign.launched. Callable from draft or paused.
WARNING
Launching an outbound campaign places real outbound PSTN calls at scale and incurs charges per attempt.
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/launch \
-H "Authorization: Bearer YOUR_API_KEY"const { campaign, blockers } = await rymi.campaigns.launch("9c1e...campaign");result = rymi.campaigns.launch("9c1e...campaign")Response 200
{
"campaign": { "id": "9c1e...campaign", "status": "running", "launched_at": "2026-07-03T10:05:00Z" },
"blockers": [
{ "code": "agent_published_snapshot", "ok": true },
{ "code": "has_valid_members", "ok": true },
{ "code": "caller_id_available", "ok": true },
{ "code": "dnc_check_completed", "ok": true },
{ "code": "sufficient_credits", "ok": true },
{ "code": "followup_connectors_present", "ok": true }
]
}Errors
| Status | Meaning |
|---|---|
401 | Missing or invalid API key |
403 | Publishable keys cannot launch campaigns |
404 | Campaign not found |
409 | Campaign cannot be launched from its current status, or one or more launch blockers failed — response body includes the itemized blockers list |
Evaluate Launch Blockers (dry run)
Runs the same launch-blocker checks as Launch Campaign without launching — no status change, no dials. Safe to call from any status; use it to render a pre-launch checklist.
Response 200
{
"blockers": [
{ "code": "agent_published_snapshot", "ok": true },
{ "code": "has_valid_members", "ok": true },
{ "code": "caller_id_available", "ok": true },
{ "code": "sufficient_credits", "ok": false, "detail": "Tenant has insufficient credits to run this campaign." }
],
"launchable": false
}launchable is true only when every blocker's ok is true.
| Status | Meaning |
|---|---|
401 | Missing or invalid API key |
404 | Campaign not found |
Pause Campaign
Stops the scheduler from selecting new work for this campaign. In-flight calls finish naturally. Only callable from running. Fires campaign.paused.
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/pause \
-H "Authorization: Bearer YOUR_API_KEY"const { campaign } = await rymi.campaigns.pause("9c1e...campaign");result = rymi.campaigns.pause("9c1e...campaign")Errors
| Status | Meaning |
|---|---|
401 | Missing or invalid API key |
403 | Publishable keys cannot pause campaigns |
404 | Campaign not found |
409 | Campaign is not running |
Resume Campaign
Resumes a paused campaign. The scheduler continues from remaining due members. Only callable from paused.
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/resume \
-H "Authorization: Bearer YOUR_API_KEY"const { campaign } = await rymi.campaigns.resume("9c1e...campaign");result = rymi.campaigns.resume("9c1e...campaign")Errors
| Status | Meaning |
|---|---|
401 | Missing or invalid API key |
403 | Publishable keys cannot resume campaigns |
404 | Campaign not found |
409 | Campaign is not paused |
Archive Campaign
Marks a campaign archived. Terminal — the scheduler never selects work for an archived campaign again.
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/archive \
-H "Authorization: Bearer YOUR_API_KEY"const { campaign } = await rymi.campaigns.archive("9c1e...campaign");result = rymi.campaigns.archive("9c1e...campaign")Errors
| Status | Meaning |
|---|---|
401 | Missing or invalid API key |
403 | Publishable keys cannot archive campaigns |
404 | Campaign not found |
409 | Campaign is already archived |
Members
A campaign member is one contact's membership + per-campaign state in one outbound campaign. See Contacts for the tenant-level contact model.
List Members
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Max records (max 500) |
offset | integer | 0 | Records to skip |
status | string | — | ready, queued, in_progress, succeeded, failed, exhausted, opted_out, suppressed |
curl "https://api.rymi.live/v1/campaigns/9c1e...campaign/members?status=ready" \
-H "Authorization: Bearer YOUR_API_KEY"const { members } = await rymi.campaigns.members.list("9c1e...campaign", { status: "ready" });Attach Members
Attaches existing contacts (by id) to a campaign as members.
| Field | Type | Required | Description |
|---|---|---|---|
contact_ids | string[] | Yes | Non-empty array of contact IDs owned by the tenant |
variables_override | object | No | Per-campaign call variable overrides applied to every attached member |
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/members \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "contact_ids": ["c_1", "c_2"] }'const { members } = await rymi.campaigns.members.add("9c1e...campaign", {
contact_ids: ["c_1", "c_2"],
});result = rymi.campaigns.members.add("9c1e...campaign", contact_ids=["c_1", "c_2"])Errors
| Status | Meaning |
|---|---|
400 | contact_ids missing or empty |
403 | Publishable keys cannot attach campaign members |
404 | Campaign not found, or one or more contact IDs not found for this tenant |
Import Members
Accepts inline contact rows (JSON or CSV) — creates/merges contacts and attaches them as members in one call. See Contacts → Bulk Import for row shape and dedup rules; the same phone normalization and (tenant_id, phone) upsert apply.
| Field | Type | Required | Description |
|---|---|---|---|
contacts | object[] | One of contacts/csv | Inline contact rows |
csv | string | One of contacts/csv | Raw CSV text (header row required) |
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/members/import \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{ "phone": "+15551234567", "name": "Asha", "custom_fields": { "plan": "Pro" } }
]
}'const result = await rymi.campaigns.members.import("9c1e...campaign", {
contacts: [{ phone: "+15551234567", name: "Asha", custom_fields: { plan: "Pro" } }],
});result = rymi.campaigns.members.import_(
"9c1e...campaign",
contacts=[{"phone": "+15551234567", "name": "Asha", "custom_fields": {"plan": "Pro"}}],
)Response 200
{ "created": 1, "merged": 0, "attached": 1, "invalid": [] }Errors
| Status | Meaning |
|---|---|
400 | Neither contacts nor csv provided, or no rows to import |
403 | Publishable keys cannot import campaign members |
404 | Campaign not found |
Update Member
| Field | Type | Description |
|---|---|---|
status | string | One of the member statuses |
suppression_reason | string | null | dnc, no_consent, invalid_phone, duplicate, manual |
next_attempt_at | string | ISO 8601 timestamp |
variables_override | object | Per-member call variables |
curl -X PATCH https://api.rymi.live/v1/campaigns/9c1e...campaign/members/m_1 \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "suppressed", "suppression_reason": "manual" }'await rymi.campaigns.members.update("9c1e...campaign", "m_1", {
status: "suppressed",
suppression_reason: "manual",
});Remove Member
Removes a member from the campaign. Does not delete the underlying contact.
curl -X DELETE https://api.rymi.live/v1/campaigns/9c1e...campaign/members/m_1 \
-H "Authorization: Bearer YOUR_API_KEY"await rymi.campaigns.members.remove("9c1e...campaign", "m_1");Execution And Insight
Read-only, paginated lists over a campaign's execution history. None of these duplicate call data — attempts links to calls by call_id.
List Attempts
| Parameter | Type | Description |
|---|---|---|
limit / offset | integer | Pagination (max 500) |
status | string | scheduled, queued, dialing, ringing, in_progress, completed, failed, skipped, cancelled |
outcome | string | goal_success, goal_fail, no_answer, busy, voicemail, error |
curl "https://api.rymi.live/v1/campaigns/9c1e...campaign/attempts?outcome=goal_success" \
-H "Authorization: Bearer YOUR_API_KEY"const { attempts } = await rymi.campaigns.attempts("9c1e...campaign", { outcome: "goal_success" });result = rymi.campaigns.attempts("9c1e...campaign", outcome="goal_success")Each attempt maps to at most one calls row (call_id) — fetch full call detail, transcript, and recording via the Calls API.
List Batches
One row per scheduler sweep tick that claimed work.
curl "https://api.rymi.live/v1/campaigns/9c1e...campaign/batches" \
-H "Authorization: Bearer YOUR_API_KEY"const { batches } = await rymi.campaigns.batches("9c1e...campaign");List Follow-up Jobs
| Parameter | Type | Description |
|---|---|---|
limit / offset | integer | Pagination |
status | string | scheduled, queued, sent, failed, blocked, cancelled, done |
kind | string | call, sms, whatsapp, telegram, webhook, human_handoff_task, mark_dnc |
curl "https://api.rymi.live/v1/campaigns/9c1e...campaign/followups?status=blocked" \
-H "Authorization: Bearer YOUR_API_KEY"const { followups } = await rymi.campaigns.followups("9c1e...campaign", { status: "blocked" });A blocked follow-up with result.reason: "capability_missing" means the tenant lacks the connector/template for that channel — see Follow-ups.
Get Campaign Report
Returns { summary, distributions }. summary reads the campaign's stat_* counters directly (O(1) — never scans attempts). distributions are computed via SQL aggregates over campaign_attempts joined to calls/call_intelligence. Cost is reported in credits; durations in minutes (house rule — see Reporting).
curl https://api.rymi.live/v1/campaigns/9c1e...campaign/report \
-H "Authorization: Bearer YOUR_API_KEY"const report = await rymi.campaigns.report("9c1e...campaign");report = rymi.campaigns.report("9c1e...campaign")Response 200
{
"summary": {
"stat_members": 500,
"stat_suppressed": 12,
"stat_attempts": 640,
"stat_answered": 310,
"stat_completed": 480,
"stat_failed": 40,
"stat_no_answer": 120,
"stat_goal_successes": 96,
"stat_callbacks": 22,
"stat_opt_outs": 4,
"stat_followups_sent": 88,
"stat_handoffs": 6,
"duration_minutes": 812.5,
"cost_credits": 4620,
"goal_conversion_rate": 0.2
},
"distributions": {
"by_outcome": [
{ "outcome": "goal_success", "count": 96 },
{ "outcome": "no_answer", "count": 120 }
],
"by_sentiment": [
{ "sentiment": "positive", "count": 140 },
{ "sentiment": "neutral", "count": 260 }
],
"by_hour": [
{ "hour": 14, "attempted": 80, "answered": 44, "answer_rate": 0.55 }
],
"by_snapshot": [
{ "agent_snapshot_id": "snap_1", "attempts": 400, "goal_successes": 60 },
{ "agent_snapshot_id": "snap_2", "attempts": 240, "goal_successes": 36 }
],
"top_failure_reasons": [
{ "reason": "voicemail", "count": 30 }
],
"cost_per_success_credits": 48.1
}
}by_snapshot segments outcomes by the agent snapshot actually served on each attempt — use it to compare performance before/after a mid-campaign republish (see Snapshot behavior).
Errors
| Status | Meaning |
|---|---|
401 | Missing or invalid API key |
404 | Campaign not found |
Improve
Runs the improve engine: deterministic detectors compute signals from campaign metrics; triggered signals gate an LLM pass that writes reviewable proposals. No LLM cost when no signal fires. See The Improve Loop.
Run Improve
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/improve \
-H "Authorization: Bearer YOUR_API_KEY"const { suggestions } = await rymi.campaigns.improve("9c1e...campaign");result = rymi.campaigns.improve("9c1e...campaign")Response 200
{
"suggestions": [
{
"id": "sug_1",
"campaign_id": "9c1e...campaign",
"category": "early_hangup",
"evidence": { "early_hangup_rate": 0.46, "sample_call_ids": ["call_a", "call_b"] },
"proposal": { "text": "Shorten the opener — 42% of hangups occur before second 15.", "agent_draft_op": { "field": "opener" } },
"status": "proposed",
"created_by_model": "claude-sonnet",
"created_at": "2026-07-03T12:00:00Z"
}
]
}An empty suggestions array means no detector signal fired — metrics are healthy and no LLM call was made.
List Suggestions
| Parameter | Type | Description |
|---|---|---|
limit / offset | integer | Pagination |
status | string | proposed, accepted, dismissed |
curl "https://api.rymi.live/v1/campaigns/9c1e...campaign/suggestions?status=proposed" \
-H "Authorization: Bearer YOUR_API_KEY"const { suggestions } = await rymi.campaigns.suggestions("9c1e...campaign", { status: "proposed" });Accept Suggestion
Agent-editing suggestions create a normal agent_changes draft op for you to review and republish (nothing mutates a live agent silently). Campaign-policy suggestions patch the campaign directly.
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/suggestions/sug_1/accept \
-H "Authorization: Bearer YOUR_API_KEY"const { suggestion } = await rymi.campaigns.acceptSuggestion("9c1e...campaign", "sug_1");Dismiss Suggestion
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/suggestions/sug_1/dismiss \
-H "Authorization: Bearer YOUR_API_KEY"const { suggestion } = await rymi.campaigns.dismissSuggestion("9c1e...campaign", "sug_1");Inbound Routes
Bind an owned phone number to an inbound campaign so incoming calls on that number attribute to the campaign. Only one active route per number is allowed — creating or reactivating a second returns 409.
List Routes
curl "https://api.rymi.live/v1/campaigns/9c1e...campaign/routes" \
-H "Authorization: Bearer YOUR_API_KEY"const { routes } = await rymi.campaigns.routes.list("9c1e...campaign");Create Route
| Field | Type | Required | Description |
|---|---|---|---|
phone_number | string | Yes | Owned number, E.164 |
schedule | object | No | Business-hours windows |
active | boolean | No | Defaults to true |
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/routes \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "phone_number": "+15551234567" }'const { route } = await rymi.campaigns.routes.create("9c1e...campaign", {
phone_number: "+15551234567",
});result = rymi.campaigns.routes.create("9c1e...campaign", phone_number="+15551234567")Errors
| Status | Meaning |
|---|---|
400 | phone_number not a valid E.164 number |
403 | Publishable keys cannot create campaign routes |
404 | Campaign not found |
409 | The number already has an active campaign route |
Update Route
curl -X PATCH https://api.rymi.live/v1/campaigns/9c1e...campaign/routes/r_1 \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "active": false }'await rymi.campaigns.routes.update("9c1e...campaign", "r_1", { active: false });Re-activating a route (active: true) can trigger the same one-active-route-per-number 409.
Delete Route
curl -X DELETE https://api.rymi.live/v1/campaigns/9c1e...campaign/routes/r_1 \
-H "Authorization: Bearer YOUR_API_KEY"await rymi.campaigns.routes.delete("9c1e...campaign", "r_1");If no active campaign route exists for a number, inbound calls fall back to the number's normal agent_id.
Publishable Key Rules
Publishable keys cannot create, update, launch, pause, resume, or archive campaigns; cannot attach, import, update, or remove members; cannot create, update, or delete inbound routes. Reads (list/get/report/attempts/batches/followups/suggestions) require a secret key.

