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": "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
| 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. 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.
| 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");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
Returns { "intake": null } when the campaign has no intake URL.
{
"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
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.
| Field | Type | Required | Description |
|---|---|---|---|
assume_voice_consent | boolean | No | Treat 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_country | string | null | No | ISO-3166 alpha-2 code ("IN") applied to numbers posted without a + country code, so 98765 43210 becomes +919876543210 |
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" }'const { url } = await rymi.campaigns.intake.set("9c1e...campaign", {
assume_voice_consent: true,
default_country: "IN",
});result = rymi.campaigns.intake.set("9c1e...campaign", assume_voice_consent=True, default_country="IN")
url = result["url"]Response 201
{
"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
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
Returns 204. Leads already accepted stay in the campaign. Node: rymi.campaigns.intake.disable(id). Python: rymi.campaigns.intake.disable(id).
Post Leads (public)
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/plainholding JSON, fornavigator.sendBeaconandno-corsfetches.
Field names match case-insensitively, and spaces or dashes count as underscores, so Phone Number and mobile-number both work.
| Lead field | Accepted names |
|---|---|
| Phone (required) | phone, phone_number, mobile, mobile_number, number, contact, contact_number, whatsapp, tel |
| Name | name, full_name, your_name, or first_name + last_name |
| Consent | consent, voice_consent, call_consent, opt_in, agree. true, yes, on, 1 and हाँ count as yes. An object such as { "voice": true } also works |
| Other | email, 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.
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" }'<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>// 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
{ "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
| Status | Meaning |
|---|---|
400 | Empty body, or more than 50 leads in one request |
404 | Unknown or rotated URL |
410 | The campaign is completed or archived |
429 | More 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
| 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_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
| 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 { signals_fired, suggestions_created } = await rymi.campaigns.improve("9c1e...campaign");result = rymi.campaigns.improve("9c1e...campaign")Response 200
{
"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
| 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" });Response 200
{
"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
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.
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
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": [ ... ] }.
| Status | Code | Meaning |
|---|---|---|
400 | experiment_weights | Not exactly two arms, or weights are not integers from 1 to 99 summing to 100 |
400 | experiment_versions | A version is missing, a draft, a duplicate, or not a version of this campaign's agent |
401 | unauthorized | Missing authentication context |
403 | publishable_forbidden | Publishable keys cannot edit traffic experiments |
404 | not_found | Campaign not found |
409 | experiment_campaign_type | Campaign is not outbound |
409 | experiment_open | A draft or running experiment already exists. End it before creating another |
503 | experiment_versions_unavailable | Agent versions cannot be read yet |
Start
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.
| Status | Code | Meaning |
|---|---|---|
400 | experiment_versions | A version is no longer eligible. The experiment stays a draft |
404 | experiment_not_found | No open experiment |
409 | experiment_not_draft | The open experiment is not a draft |
End
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.
| Status | Code | Meaning |
|---|---|---|
404 | experiment_not_found | No open experiment |
409 | experiment_not_running | The open experiment is not running |
Read
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.

