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_members … stat_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
statusstring-Filter by status
typestring-outbound or inbound
agent_iduuid-Filter 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": "voicemail_handling", "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
statusstring-ready, 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. It works on a running campaign too: new members are dialed on the scheduler's next sweep (about 30 seconds), inside the calling window. For sources that can't send an API key, use the Lead Intake URL. 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/:mid
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/:mid

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

Lead Intake URL ​

Each campaign can have one secret intake URL. Website forms, Typeform, Tally, Google Apps Script, Zapier and Make can POST leads to it with no API key, which suits tools that can't set an Authorization header. Leads go through the same phone normalization and (tenant_id, phone) dedup as Import Members. While the campaign is running, the scheduler picks up new members within about 30 seconds and dials them inside the campaign's calling window. Leads posted to a draft or paused campaign wait until you launch or resume it.

Rymi stores only a SHA-256 hash of the token. You see the URL once, when you create or rotate it. Anyone holding the URL can add numbers to the campaign, so keep it server-side when you can, and rotate it if it leaks. DNC suppression, consent gating and the calling window still apply to every lead.

Get Intake ​

GET/v1/campaigns/:id/intake

Returns { "intake": null } when the campaign has no intake URL.

json
{
  "intake": {
    "enabled": true,
    "assume_voice_consent": true,
    "default_country": "IN",
    "created_at": "2026-10-04T06:10:00Z",
    "rotated_at": "2026-10-04T06:10:00Z",
    "last_used_at": "2026-10-04T07:42:13Z"
  }
}

Create Or Update Intake ​

PUT/v1/campaigns/:id/intake

The first call creates the URL and returns it as url (status 201). Later calls update the settings and return url: null; the URL stays the same.

FieldTypeRequiredDescription
assume_voice_consentbooleanNoTreat every posted lead as having voice consent. Use it when the form itself collects consent, such as a subscribe form with a consent line. A lead that posts an explicit "no" still counts as no. Default false
default_countrystring | nullNoISO-3166 alpha-2 code ("IN") applied to numbers posted without a + country code, so 98765 43210 becomes +919876543210
bash
curl -X PUT https://api.rymi.live/v1/campaigns/9c1e...campaign/intake \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "assume_voice_consent": true, "default_country": "IN" }'
ts
const { url } = await rymi.campaigns.intake.set("9c1e...campaign", {
  assume_voice_consent: true,
  default_country: "IN",
});
python
result = rymi.campaigns.intake.set("9c1e...campaign", assume_voice_consent=True, default_country="IN")
url = result["url"]

Response 201 ​

json
{
  "intake": { "enabled": true, "assume_voice_consent": true, "default_country": "IN", "created_at": "...", "rotated_at": "...", "last_used_at": null },
  "url": "https://api.rymi.live/v1/public/intake/rci_8Xk2..."
}

Rotate Intake ​

POST/v1/campaigns/:id/intake/rotate

Issues a new URL and returns it as url. The old URL stops accepting leads at once, so update every lead source. Node: rymi.campaigns.intake.rotate(id). Python: rymi.campaigns.intake.rotate(id).

Remove Intake ​

DELETE/v1/campaigns/:id/intake

Returns 204. Leads already accepted stay in the campaign. Node: rymi.campaigns.intake.disable(id). Python: rymi.campaigns.intake.disable(id).

Post Leads (public) ​

POST/v1/public/intake/:token

No Authorization header. The token in the path is the credential. The endpoint accepts:

  • application/json: one lead object, an array of leads, or { "contacts": [...] }. Up to 50 leads per request.
  • application/x-www-form-urlencoded: a plain HTML form post.
  • text/plain holding JSON, for navigator.sendBeacon and no-cors fetches.

Field names match case-insensitively, and spaces or dashes count as underscores, so Phone Number and mobile-number both work.

Lead fieldAccepted names
Phone (required)phone, phone_number, mobile, mobile_number, number, contact, contact_number, whatsapp, tel
Namename, full_name, your_name, or first_name + last_name
Consentconsent, voice_consent, call_consent, opt_in, agree. true, yes, on, 1 and हाँ count as yes. An object such as { "voice": true } also works
Otheremail, timezone, language, tags

Every other text, number or boolean field lands in the contact's custom_fields, which your agent can read as call variables. The contact's source is set to intake_url.

bash
curl -X POST https://api.rymi.live/v1/public/intake/rci_8Xk2... \
  -H "Content-Type: application/json" \
  -d '{ "name": "Ramesh Kumar", "phone": "98765 43210", "city": "Meerut", "consent": "yes" }'
html
<form method="POST" action="https://api.rymi.live/v1/public/intake/rci_8Xk2...">
  <input name="name" placeholder="Your name" />
  <input name="phone" placeholder="Mobile number" required />
  <label><input type="checkbox" name="consent" value="yes" required /> I agree to receive calls about the event</label>
  <button type="submit">Subscribe</button>
</form>
js
// Forms → Script editor → add an "On form submit" trigger
function onFormSubmit(e) {
  const answers = e.namedValues; // { "Name": ["Ramesh"], "Phone Number": ["9876543210"] }
  const lead = Object.fromEntries(Object.entries(answers).map(([k, v]) => [k, v[0]]));
  UrlFetchApp.fetch("https://api.rymi.live/v1/public/intake/rci_8Xk2...", {
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify(lead),
  });
}

Response 202 ​

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

invalid lists rows whose phone number didn't parse, with a reason. attached counts members newly added to this campaign; a number already in the campaign merges into its contact and doesn't attach twice.

Errors ​

StatusMeaning
400Empty body, or more than 50 leads in one request
404Unknown or rotated URL
410The campaign is completed or archived
429More than 120 requests a minute to this URL

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_voicemail": 30,
    "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 { signals_fired, suggestions_created } = await rymi.campaigns.improve("9c1e...campaign");
python
result = rymi.campaigns.improve("9c1e...campaign")

Response 200 ​

json
{
  "signals_fired": ["early_hangup", "bad_call_window"],
  "suggestions_created": 2
}

signals_fired lists the detector categories that triggered; suggestions_created is how many reviewable proposals were written. Both empty/0 means no detector signal fired: metrics are healthy and no LLM call was made. Fetch the written proposals with List Suggestions.

The detector categories are early_hangup, bad_call_window, low_conversion, repeated_objection, language_mismatch, tool_error_rate, and missing_followup.

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" });

Response 200 ​

json
{
  "suggestions": [
    {
      "id": "sug_1",
      "campaign_id": "9c1e...campaign",
      "category": "early_hangup",
      "evidence": {
        "calls_over_15s": 120,
        "early_hangups_over_15s": 55,
        "early_hangup_rate": 0.46,
        "threshold": 0.35
      },
      "proposal": {
        "text": "Shorten the opener: 42% of hangups occur before second 15.",
        "agent_draft_ops": [{ "key": "opener", "value": "Hi, this is Ada from Acme, quick question." }],
        "campaign_policy_patch": null
      },
      "status": "proposed",
      "created_by_model": "gemini-2.5-flash",
      "created_at": "2026-07-03T12:00:00Z"
    }
  ],
  "total": 1,
  "offset": 0,
  "limit": 50
}

limit defaults to 50 and is capped at 500. The evidence object is detector-specific; each category writes its own metrics, so don't assume a fixed shape. Rows also carry tenant_id and updated_at, omitted here for brevity.

On accept, a proposal.agent_draft_ops array (each { key, value }) becomes an agent_changes draft op to review and republish; a proposal.campaign_policy_patch ({ field, patch }) patches the campaign policy directly. A proposal may carry either, both, or neither. The two are applied independently, and a text-only proposal is advisory and applies nothing on accept.

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


Traffic experiment ​

Outbound campaigns only. The voice.ab_traffic_split flag ships off. These routes save and read an experiment whether or not the flag is on. Dials split only when the flag is on and the experiment is running.

Two arms. Each weight_percent is an integer from 1 to 99, and the two weights sum to 100. Each agent_version_id is a staging or production version of this campaign's agent. Drafts are rejected. There is no request field for a prompt or a snapshot.

Create a draft ​

POST/v1/campaigns/:id/traffic-experiment
bash
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/traffic-experiment \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "arms": [
      { "agent_version_id": "10000000-0000-4000-8000-000000000001", "weight_percent": 30 },
      { "agent_version_id": "10000000-0000-4000-8000-000000000002", "weight_percent": 70 }
    ]
  }'

Response 201: { "experiment": { "status": "draft" }, "arms": [ ... ] }.

StatusCodeMeaning
400experiment_weightsNot exactly two arms, or weights are not integers from 1 to 99 summing to 100
400experiment_versionsA version is missing, a draft, a duplicate, or not a version of this campaign's agent
401unauthorizedMissing authentication context
403publishable_forbiddenPublishable keys cannot edit traffic experiments
404not_foundCampaign not found
409experiment_campaign_typeCampaign is not outbound
409experiment_openA draft or running experiment already exists. End it before creating another
503experiment_versions_unavailableAgent versions cannot be read yet

Start ​

POST/v1/campaigns/:id/traffic-experiment/start
bash
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/traffic-experiment/start \
  -H "Authorization: Bearer YOUR_API_KEY"

Sets status to running and started_at. A body is ignored. Weights and version ids cannot be changed while the experiment is open. Re-checks that both versions are still eligible.

StatusCodeMeaning
400experiment_versionsA version is no longer eligible. The experiment stays a draft
404experiment_not_foundNo open experiment
409experiment_not_draftThe open experiment is not a draft

End ​

POST/v1/campaigns/:id/traffic-experiment/end
bash
curl -X POST https://api.rymi.live/v1/campaigns/9c1e...campaign/traffic-experiment/end \
  -H "Authorization: Bearer YOUR_API_KEY"

Sets status to ended and ended_at. Arms are kept for the campaign report. Ended experiments are not used for new dials.

StatusCodeMeaning
404experiment_not_foundNo open experiment
409experiment_not_runningThe open experiment is not running

Read ​

GET/v1/campaigns/:id/traffic-experiment

Returns the running experiment if there is one, otherwise the draft, otherwise the newest ended experiment, otherwise { "experiment": null, "arms": [] }.

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; cannot create, start, end, or read traffic experiments. Other campaign reads (list/get/report/attempts/batches/followups/suggestions) require a secret key.