Skip to content

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

FieldTypeDescription
iduuidCampaign ID
tenant_idstringOwning tenant
agent_iduuidPublished agent this campaign runs
agent_snapshot_idstring | nullSnapshot recorded at launch (attribution only — calls always serve the agent's current published snapshot)
typestringoutbound or inbound
namestringCampaign name
goalGoal objectWhat counts as success
statusstringdraft, scheduled, running, paused, completed, failed, archived
schedule_policyobjectCalling windows, quiet hours, campaign timezone default
retry_policyobjectmax_attempts, backoff schedule
concurrency_policyobjectCampaign cap, per-number rate
automation_policyobjectFollow-up rules — see the guide
reporting_policyobjectReporting preferences
compliance_policyobjectrequire_consent, require_consent_evidence, max_attempts_per_24h, ai_disclosure, dnc_scrub — see the guide
post_call_config_overrideobject | nullPer-campaign override merged into the agent's post-call config
stat_membersstat_provider_cost_microsintegerReducer-maintained counters read by the report endpoint in O(1) — see Reporting
launched_at / paused_at / completed_atstring | nullLifecycle timestamps
created_at / updated_atstringISO 8601 timestamps

Goal Object

FieldTypeRequiredDescription
goal_typestringYesShort identifier, e.g. book_meeting
success_fieldstringYesField in call_intelligence.structured_data evaluated for success
success_whenobjectYesPredicate: { equals }, { not_equals }, { exists }, { gte }, { lte }, or { in }
secondary_fieldsstring[]NoAdditional fields to surface in the report (e.g. callback_requested)
human_labelstringYesHuman-readable goal description
json
{
  "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

POST/v1/campaigns

Creates a new campaign in draft status against a published agent. Publishable keys cannot create campaigns.

Request Body

FieldTypeRequiredDescription
agent_iduuidYesAgent this campaign will run — must belong to the tenant
typestringYesoutbound or inbound
namestringYes1–200 characters
goalGoal objectNoSuccess criteria
schedule_policyobjectNoCalling windows / quiet hours
retry_policyobjectNoMax attempts + backoff
concurrency_policyobjectNoCaps
automation_policyobjectNoFollow-up rules
reporting_policyobjectNoReporting preferences
compliance_policyobjectNoConsent gate, per-24h cap, AI disclosure, DNC-scrub mode — defaults to a reasonable floor (see the guide)
metadataobjectNoArbitrary key-value data
bash
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] }
  }'
ts
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] },
});
python
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

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

StatusMeaning
400Validation error (type not outbound/inbound, missing name, malformed goal)
401Missing or invalid API key
403Publishable keys cannot create campaigns
404agent_id not found for this tenant

List Campaigns

GET/v1/campaigns

Query Parameters

ParameterTypeDefaultDescription
limitinteger50Max records (max 500)
offsetinteger0Records to skip
statusstringFilter by status
typestringoutbound or inbound
agent_iduuidFilter by agent
bash
curl "https://api.rymi.live/v1/campaigns?status=running&limit=25" \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
const { campaigns } = await rymi.campaigns.list({ status: "running", limit: 25 });
python
result = rymi.campaigns.list(status="running", limit=25)

Response 200

json
{
  "campaigns": [ { "id": "9c1e...campaign", "name": "Q3 renewal calls", "status": "running" } ],
  "total": 1,
  "offset": 0,
  "limit": 25
}

Errors

StatusMeaning
401Missing or invalid API key

Get Campaign

GET/v1/campaigns/:id
bash
curl https://api.rymi.live/v1/campaigns/9c1e...campaign \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
const { campaign } = await rymi.campaigns.get("9c1e...campaign");
python
result = rymi.campaigns.get("9c1e...campaign")

Errors

StatusMeaning
401Missing or invalid API key
404Campaign not found

Update Campaign

PATCH/v1/campaigns/:id

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.

bash
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 } }'
ts
const { campaign } = await rymi.campaigns.update("9c1e...campaign", {
  retry_policy: { max_attempts: 5 },
});
python
result = rymi.campaigns.update("9c1e...campaign", retry_policy={"max_attempts": 5})

Errors

StatusMeaning
400Malformed goal, empty name, or no updatable fields provided
401Missing or invalid API key
403Publishable keys cannot update campaigns
404Campaign not found

Launch Campaign

POST/v1/campaigns/:id/launch

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.

bash
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/launch \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
const { campaign, blockers } = await rymi.campaigns.launch("9c1e...campaign");
python
result = rymi.campaigns.launch("9c1e...campaign")

Response 200

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

StatusMeaning
401Missing or invalid API key
403Publishable keys cannot launch campaigns
404Campaign not found
409Campaign 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)

GET/v1/campaigns/:id/launch-blockers

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

json
{
  "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.

StatusMeaning
401Missing or invalid API key
404Campaign not found

Pause Campaign

POST/v1/campaigns/:id/pause

Stops the scheduler from selecting new work for this campaign. In-flight calls finish naturally. Only callable from running. Fires campaign.paused.

bash
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/pause \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
const { campaign } = await rymi.campaigns.pause("9c1e...campaign");
python
result = rymi.campaigns.pause("9c1e...campaign")

Errors

StatusMeaning
401Missing or invalid API key
403Publishable keys cannot pause campaigns
404Campaign not found
409Campaign is not running

Resume Campaign

POST/v1/campaigns/:id/resume

Resumes a paused campaign. The scheduler continues from remaining due members. Only callable from paused.

bash
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/resume \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
const { campaign } = await rymi.campaigns.resume("9c1e...campaign");
python
result = rymi.campaigns.resume("9c1e...campaign")

Errors

StatusMeaning
401Missing or invalid API key
403Publishable keys cannot resume campaigns
404Campaign not found
409Campaign is not paused

Archive Campaign

POST/v1/campaigns/:id/archive

Marks a campaign archived. Terminal — the scheduler never selects work for an archived campaign again.

bash
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/archive \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
const { campaign } = await rymi.campaigns.archive("9c1e...campaign");
python
result = rymi.campaigns.archive("9c1e...campaign")

Errors

StatusMeaning
401Missing or invalid API key
403Publishable keys cannot archive campaigns
404Campaign not found
409Campaign 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

GET/v1/campaigns/:id/members
ParameterTypeDefaultDescription
limitinteger50Max records (max 500)
offsetinteger0Records to skip
statusstringready, queued, in_progress, succeeded, failed, exhausted, opted_out, suppressed
bash
curl "https://api.rymi.live/v1/campaigns/9c1e...campaign/members?status=ready" \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
const { members } = await rymi.campaigns.members.list("9c1e...campaign", { status: "ready" });

Attach Members

POST/v1/campaigns/:id/members

Attaches existing contacts (by id) to a campaign as members.

FieldTypeRequiredDescription
contact_idsstring[]YesNon-empty array of contact IDs owned by the tenant
variables_overrideobjectNoPer-campaign call variable overrides applied to every attached member
bash
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"] }'
ts
const { members } = await rymi.campaigns.members.add("9c1e...campaign", {
  contact_ids: ["c_1", "c_2"],
});
python
result = rymi.campaigns.members.add("9c1e...campaign", contact_ids=["c_1", "c_2"])

Errors

StatusMeaning
400contact_ids missing or empty
403Publishable keys cannot attach campaign members
404Campaign not found, or one or more contact IDs not found for this tenant

Import Members

POST/v1/campaigns/:id/members/import

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.

FieldTypeRequiredDescription
contactsobject[]One of contacts/csvInline contact rows
csvstringOne of contacts/csvRaw CSV text (header row required)
bash
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" } }
    ]
  }'
ts
const result = await rymi.campaigns.members.import("9c1e...campaign", {
  contacts: [{ phone: "+15551234567", name: "Asha", custom_fields: { plan: "Pro" } }],
});
python
result = rymi.campaigns.members.import_(
    "9c1e...campaign",
    contacts=[{"phone": "+15551234567", "name": "Asha", "custom_fields": {"plan": "Pro"}}],
)

Response 200

json
{ "created": 1, "merged": 0, "attached": 1, "invalid": [] }

Errors

StatusMeaning
400Neither contacts nor csv provided, or no rows to import
403Publishable keys cannot import campaign members
404Campaign not found

Update Member

PATCH/v1/campaigns/:id/members/:member_id
FieldTypeDescription
statusstringOne of the member statuses
suppression_reasonstring | nulldnc, no_consent, invalid_phone, duplicate, manual
next_attempt_atstringISO 8601 timestamp
variables_overrideobjectPer-member call variables
bash
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" }'
ts
await rymi.campaigns.members.update("9c1e...campaign", "m_1", {
  status: "suppressed",
  suppression_reason: "manual",
});

Remove Member

DELETE/v1/campaigns/:id/members/:member_id

Removes a member from the campaign. Does not delete the underlying contact.

bash
curl -X DELETE https://api.rymi.live/v1/campaigns/9c1e...campaign/members/m_1 \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
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

GET/v1/campaigns/:id/attempts
ParameterTypeDescription
limit / offsetintegerPagination (max 500)
statusstringscheduled, queued, dialing, ringing, in_progress, completed, failed, skipped, cancelled
outcomestringgoal_success, goal_fail, no_answer, busy, voicemail, error
bash
curl "https://api.rymi.live/v1/campaigns/9c1e...campaign/attempts?outcome=goal_success" \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
const { attempts } = await rymi.campaigns.attempts("9c1e...campaign", { outcome: "goal_success" });
python
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

GET/v1/campaigns/:id/batches

One row per scheduler sweep tick that claimed work.

bash
curl "https://api.rymi.live/v1/campaigns/9c1e...campaign/batches" \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
const { batches } = await rymi.campaigns.batches("9c1e...campaign");

List Follow-up Jobs

GET/v1/campaigns/:id/followups
ParameterTypeDescription
limit / offsetintegerPagination
statusstringscheduled, queued, sent, failed, blocked, cancelled, done
kindstringcall, sms, whatsapp, telegram, webhook, human_handoff_task, mark_dnc
bash
curl "https://api.rymi.live/v1/campaigns/9c1e...campaign/followups?status=blocked" \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
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

GET/v1/campaigns/:id/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).

bash
curl https://api.rymi.live/v1/campaigns/9c1e...campaign/report \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
const report = await rymi.campaigns.report("9c1e...campaign");
python
report = rymi.campaigns.report("9c1e...campaign")

Response 200

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

StatusMeaning
401Missing or invalid API key
404Campaign 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

POST/v1/campaigns/:id/improve
bash
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/improve \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
const { suggestions } = await rymi.campaigns.improve("9c1e...campaign");
python
result = rymi.campaigns.improve("9c1e...campaign")

Response 200

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

GET/v1/campaigns/:id/suggestions
ParameterTypeDescription
limit / offsetintegerPagination
statusstringproposed, accepted, dismissed
bash
curl "https://api.rymi.live/v1/campaigns/9c1e...campaign/suggestions?status=proposed" \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
const { suggestions } = await rymi.campaigns.suggestions("9c1e...campaign", { status: "proposed" });

Accept Suggestion

POST/v1/campaigns/:id/suggestions/:sid/accept

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.

bash
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/suggestions/sug_1/accept \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
const { suggestion } = await rymi.campaigns.acceptSuggestion("9c1e...campaign", "sug_1");

Dismiss Suggestion

POST/v1/campaigns/:id/suggestions/:sid/dismiss
bash
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/suggestions/sug_1/dismiss \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
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

GET/v1/campaigns/:id/routes
bash
curl "https://api.rymi.live/v1/campaigns/9c1e...campaign/routes" \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
const { routes } = await rymi.campaigns.routes.list("9c1e...campaign");

Create Route

POST/v1/campaigns/:id/routes
FieldTypeRequiredDescription
phone_numberstringYesOwned number, E.164
scheduleobjectNoBusiness-hours windows
activebooleanNoDefaults to true
bash
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" }'
ts
const { route } = await rymi.campaigns.routes.create("9c1e...campaign", {
  phone_number: "+15551234567",
});
python
result = rymi.campaigns.routes.create("9c1e...campaign", phone_number="+15551234567")

Errors

StatusMeaning
400phone_number not a valid E.164 number
403Publishable keys cannot create campaign routes
404Campaign not found
409The number already has an active campaign route

Update Route

PATCH/v1/campaigns/:id/routes/:route_id
bash
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 }'
ts
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

DELETE/v1/campaigns/:id/routes/:route_id
bash
curl -X DELETE https://api.rymi.live/v1/campaigns/9c1e...campaign/routes/r_1 \
  -H "Authorization: Bearer YOUR_API_KEY"
ts
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.