# CtrlLane API Context

Base URL: https://app.ctrllane.com
Auth: Authorization: Bearer cp_live_...
Content-Type: application/json

CtrlLane exposes one v1 API for inspecting Data Sources, creating agents, assigning channels, sending WhatsApp messages, running outbound voice calls/campaigns, and reading transcripts and recordings.
The CtrlLane console, customer backends, CRM systems, and other integrations use these same operations and response models. Browser sessions and API keys are authentication methods, not separate APIs.
WhatsApp, outbound Voice, and Instagram messaging (DMs and public comment replies) are live. SMS is coming later. Other is a reserved agent channel without delivery.

Simple product model:
- Data Sources have two classes: structured rows/fields and unstructured text knowledge.
- Contacts is the native structured Data Source. A valid mobile number is mandatory; businesses may add their own fields.
- Incoming WhatsApp resolves or creates the native contact and appends the message to that contact's communication history.
- WhatsApp messages and Voice calls enrich the same contact timeline. They do not create separate customer records.
- The console, API, campaigns, and approved agent context read the same tenant-scoped contact and communication records.

Security model:
- Generate API keys inside the authenticated CtrlLane console under API.
- API keys are shown once, stored hashed, scoped, revocable, and tied to one business.
- Every request must include businessId. The key must belong to that business.
- Use Idempotency-Key on media/message sends and every voice call or campaign mutation.
- Idempotency-Key format for public v1 mutations: 1-512 visible ASCII characters, no whitespace/control characters. Stable business-operation IDs may include characters such as /, #, |, =, _, :, -, and ..
- Exact retry with the same Idempotency-Key and identical JSON body returns the original response.
- Reusing the same Idempotency-Key with a different body returns 409 idempotency_key_conflict.
- Never expose API keys or Meta tokens in browser code.
- Contract API mutations reject missing or malformed Idempotency-Key before side effects.

Recommended backend scopes:
- channels:read
- channels:write
- whatsapp:read
- whatsapp:write
- whatsapp:media
- contacts:read
- contacts:write
- communications:read
- communications:write
- webhooks:write

Channels:
- whatsapp: live, assignment is by Meta phone number.
- voice: live for direct calls, contact batches, campaigns, cancellation, transcripts, and recordings.
- sms: coming soon.
- instagram: Instagram DMs and public comment replies.
- other: reserved agent-only channel without delivery.

Voice lifecycle:
- Call status is one of: queued, ringing, answered, cancelling, completed, no_answer, busy, failed, cancelled.
- cancelling is non-terminal. CtrlLane persisted the hangup request and retries it; poll GET /api/v1/voice/calls/{callId} until a terminal status is returned.
- Carrier callbacks are authoritative for terminal status, connected duration, endedBy, endReason, line release, and final billing.
- A provider placement timeout is treated as an unknown active outcome and reconciled against the same callId. CtrlLane does not place a duplicate chargeable call.
- A line is leased before carrier placement. Campaign recipients use fenced claims so stale workers cannot dispatch or complete the same recipient.
- Campaign cancellation stops queued/starting recipients and requests cancellation of active calls. Active calls may remain cancelling until carrier confirmation.
- A follow-up is a new iteration of the same campaign, never a cloned campaign. AI-recommended audience selection requires a completed current summary run before the iteration can be created.
- Every call is linked to its campaign iteration. The stable campaign ID provides full history across retries and follow-ups; a final paid AI summary can be generated after all iterations are terminal.
- Provider call IDs, runtime session keys, line lease IDs, and resolved contact values are never exposed.

Data Sources and variables:
- Structured Data Sources contain rows and fields from tables, JSON, CSV, Google Sheets, or SQL.
- Unstructured Data Sources contain notes, documents, and other text used as background knowledge.
- Contacts is the built-in structured source and always requires a mobile number.
- Inbound WhatsApp creates or resolves a Contact and appends the message to its cross-channel communication history.
- GET /api/v1/data-sources returns metadata and declared fields, never contact values.
- V1 exposes tenant-scoped contact list, create, read, update, channel rollups, and cross-channel communication timeline endpoints.
- Structured variables are explicit grants: dataSourceVariables maps sourceId + fieldKey to an alias.
- Contact-backed calls must also scope the agent to contactCategoryIds.
- When a call targets contactId, CtrlLane snapshots only that contact's allowed variables before dialing.
- Direct toNumber calls are accepted only when the agent does not require contact-backed variables.
- Agent instructions are limited to 20,000 words. Voice firstGreeting is limited to 30 words.

Agent automation:
- External automation should first check /api/v1/contracts/whatsapp-automation.
- Use /api/v1/webhook-subscriptions with externalAutomation=true to make an outside CRM/backend own replies for one WhatsApp phone number.
- GET lists subscriptions; PATCH updates callback/events or pauses/resumes; DELETE revokes; POST /{subscriptionId}/rotate-secret returns a replacement signing secret once.
- CtrlLane keeps Meta transport, WhatsApp chat storage, channel assignment, outbound message send, and delivery status responsibilities.
- External CRM/automation logic owns AI, CRM context, and reply decisions. It must call /api/v1/media and /api/v1/messages for outbound delivery. Do not import CtrlLane source or call Frappe from CtrlLane runtime.
- CtrlLane never calls Frappe or customer-specific CRM code directly; it only delivers signed webhooks and accepts public API calls.
- There is no public /api/v1 agent-turn endpoint. Console-only agent testing uses logged-in session routes, not API-key routes.
- CtrlLane owns each native contact's campaign-scoped communication history and shared cross-channel campaign summary. Later campaign iterations receive that summary together with the agent's approved contact variables.
- Console test and voice turns use the last 20 messages from the canonical conversation timeline.
- Agent responses returned by console test routes cap visible messages at 40.
- Stored session roles are user, assistant, and system. Provider details stay outside model history.

WhatsApp automation webhook payloads:
CtrlLane sends signed inbound and outbound delivery events to the callbackUrl registered by /api/v1/webhook-subscriptions.
Inbound message example:
{
  "contractVersion": "1.1",
  "eventId": "provider_event_uuid_or_meta_message_id",
  "retryNumber": 0,
  "eventType": "whatsapp.message.received",
  "occurredAt": "2026-07-12T00:00:00.000Z",
  "businessId": "business_uuid",
  "phoneNumberId": "meta_phone_number_id",
  "channel": "whatsapp",
  "conversationId": "conversation_uuid",
  "providerMessageId": "wamid...",
  "customer": {
    "mobileE164": "+919999999999",
    "whatsappId": "919999999999",
    "name": "Customer name"
  },
  "message": {
    "type": "text",
    "text": "Catalogue bhejiye"
  }
}

Delivery/read example:
{
  "contractVersion": "1.1",
  "eventId": "provider_event_uuid",
  "retryNumber": 0,
  "eventType": "whatsapp.message.read",
  "occurredAt": "2026-07-31T00:00:00.000Z",
  "businessId": "business_uuid",
  "phoneNumberId": "meta_phone_number_id",
  "channel": "whatsapp",
  "conversationId": "conversation_uuid",
  "messageId": "connected_push_message_uuid",
  "providerMessageId": "wamid...",
  "recipient": { "mobileE164": "+919999999999" },
  "delivery": {
    "status": "read",
    "providerStatus": "read",
    "sentAt": "2026-07-31T00:00:01.000Z",
    "deliveredAt": "2026-07-31T00:00:02.000Z",
    "readAt": "2026-07-31T00:00:05.000Z",
    "failedAt": null,
    "failure": null
  }
}

Webhook security:
- Every webhook includes X-Connected-Push-Timestamp, X-Connected-Push-Signature, X-Connected-Push-Event-Id, and X-Connected-Push-Contract-Version.
- The signature is HMAC-SHA256 over timestamp + "." + raw request body using the copy-once signingSecret returned when the subscription is created.
- Verify timestamp freshness, verify signature with constant-time comparison, and deduplicate by eventId before doing side effects.
- Return 2xx only after durably accepting the event into your queue/database.
- If processing fails, return non-2xx. CtrlLane retries the stored provider event; handlers must remain idempotent because duplicate delivery is possible.
- Subscribe only to required events: whatsapp.message.received, whatsapp.message.sent, whatsapp.message.delivered, whatsapp.message.read, whatsapp.message.failed.
- GET /api/v1/messages/{messageId}?businessId=... is the authoritative polling fallback for delivery state.

Node signature verification:
import crypto from "node:crypto";

function verifyConnectedPushWebhook({ rawBody, timestamp, signature, signingSecret }) {
  const expected = crypto.createHmac("sha256", signingSecret)
    .update(timestamp + "." + rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
}

Integration-owned reply flow:
1. Receive whatsapp.message.received webhook and persist eventId.
2. Load customer context inside the integrating service.
3. Generate the customer-facing reply inside the integrating service.
4. Upload/cache media through POST /api/v1/media when needed.
5. Send text or image through POST /api/v1/messages with an Idempotency-Key.
6. Store returned messageId and consume delivery webhooks or poll GET /api/v1/messages/{messageId}.

Endpoints:
1. GET /api/v1/contracts/whatsapp-automation
   Return the public WhatsApp automation contract version and emitted event types.
   Auth: public

2. GET /api/v1/webhook-subscriptions
   List tenant-scoped WhatsApp and Instagram webhook subscriptions without exposing signing secrets.
   Response: Returns callback URL, events, status, and secretLast4 for each subscription.
   Auth: Bearer API key; scopes: webhooks:write

3. POST /api/v1/webhook-subscriptions
   Subscribe an external automation backend to signed WhatsApp or Instagram messaging events.
   Request: JSON: businessId, channel=whatsapp|instagram, phoneNumberId or providerAccountId, callbackUrl, events, externalAutomation=true.
   Response: Returns active subscription metadata and a copy-once signingSecret.
   Auth: Bearer API key; scopes: webhooks:write, channels:write

4. PATCH /api/v1/webhook-subscriptions/{subscriptionId}
   Update callback URL, subscribed events, or active/paused state for a tenant-owned subscription.
   Request: Path: subscriptionId. JSON: businessId and at least one of callbackUrl, events, status=active|paused.
   Auth: Bearer API key; scopes: webhooks:write

5. DELETE /api/v1/webhook-subscriptions/{subscriptionId}
   Permanently revoke a tenant-owned webhook subscription.
   Request: Path: subscriptionId. Query: businessId.
   Auth: Bearer API key; scopes: webhooks:write

6. POST /api/v1/webhook-subscriptions/{subscriptionId}/rotate-secret
   Replace the signing secret immediately and return the new secret once.
   Request: Path: subscriptionId. JSON: businessId.
   Response: Returns subscription metadata and the copy-once signingSecret. The previous secret stops working immediately.
   Auth: Bearer API key; scopes: webhooks:write

7. POST /api/v1/media
   Upload canonical base64 image bytes to Meta WhatsApp Media API and cache the provider media id.
   Request: JSON: businessId, phoneNumberId, channel=whatsapp, mediaType=image, mimeType, fileName, contentBase64, optional clientAssetId.
   Response: Returns mediaId, providerMediaId, mediaType, mimeType, uploadedAt, expiresAt, contentSha256, providerStatus, and correlationId.
   Auth: Bearer API key; scopes: whatsapp:media
   Idempotency-Key: required

8. POST /api/v1/messages
   Queue a WhatsApp or Instagram message through CtrlLane.
   Request: JSON: businessId, phoneNumberId, channel=whatsapp, to, type=text|image, text or mediaId+caption.
   Response: Returns messageId, taskRef, providerMessageRef, queued, and correlationId.
   Auth: Bearer API key; scopes: communications:write
   Conditional scopes: WhatsApp image messages also require whatsapp:media; Instagram requires instagram:write.
   Idempotency-Key: required

9. GET /api/v1/messages/{messageId}
   Read authoritative message delivery state and provider timestamps.
   Request: Path: messageId returned by POST /api/v1/messages. Query: businessId.
   Response: Returns message status, provider reference/status, sent/delivered/read/failed timestamps, and failure details.
   Auth: Bearer API key; scopes: communications:read

10. GET /api/v1/channels
   List channel keys, labels, live readiness, and assignment mode. WhatsApp and outbound Voice are live.
   Auth: Bearer API key; scopes: channels:read

11. GET /api/v1/channels/instagram
   Read tenant-scoped Instagram professional account connection metadata without exposing its token.
   Auth: Bearer API key; scopes: channels:read, instagram:read

12. DELETE /api/v1/channels/instagram
   Revoke CtrlLane use of the tenant Instagram connection and clear provider account assets.
   Request: JSON: businessId.
   Auth: Bearer API key; scopes: channels:write, instagram:write

13. GET /api/v1/agents
   List business agents, stable reference names, and channel mapping.
   Response: Each agent includes referenceName, a stable generated identifier such as steady-cedar-a1b2c3.
   Auth: Bearer API key; scopes: agents:read

14. POST /api/v1/agents
   Create an agent for WhatsApp, SMS, Voice, or Other. Attach Data Sources, contact categories, declared variables, and provider-neutral voice settings. WhatsApp can be assigned during creation.
   Request: JSON: businessId, channel, name, instructions (maximum 20,000 words), languageCode, optional Data Sources/variables/categories, voiceSettings, and WhatsApp assignment. Header: Idempotency-Key.
   Response: Returns the agent, stable referenceName, channel readiness, and optional WhatsApp assignment.
   Auth: Bearer API key; scopes: agents:write
   Conditional scopes: Creating a WhatsApp assignment also requires channels:write.
   Idempotency-Key: required

15. PATCH /api/v1/agents
   Update a tenant-owned agent using the same channel, prompt, Data Source, and voice-settings contract as creation.
   Request: JSON: businessId, agentId, channel, name, instructions, languageCode, optional Data Sources/variables/categories, and voiceSettings. Header: Idempotency-Key.
   Auth: Bearer API key; scopes: agents:write
   Idempotency-Key: required

16. DELETE /api/v1/agents
   Archive a tenant-owned agent so it can no longer be selected for new conversations.
   Request: JSON: businessId and agentId. Header: Idempotency-Key.
   Auth: Bearer API key; scopes: agents:write
   Idempotency-Key: required

17. GET /api/v1/data-sources
   List the business's structured and unstructured Data Sources, including fields explicitly available for contact variables.
   Response: Returns source id, name, class, declared fields, and item counts. Contact values are never returned.
   Auth: Bearer API key; scopes: data_sources:read

18. POST /api/v1/data-sources
   Create a tenant structured table/JSON source or unstructured text Data Source.
   Auth: Bearer API key; scopes: data_sources:write
   Idempotency-Key: required

19. PATCH /api/v1/data-sources
   Update Data Source metadata, status, or unstructured content.
   Auth: Bearer API key; scopes: data_sources:write
   Idempotency-Key: required

20. DELETE /api/v1/data-sources
   Archive a tenant Data Source.
   Auth: Bearer API key; scopes: data_sources:write
   Idempotency-Key: required

21. POST /api/v1/data-sources/upload
   Upload a CSV, TSV, JSON, XLSX, or XLSM asset for lossless asynchronous ingestion into an immutable version.
   Auth: Bearer API key; scopes: data_sources:write
   Idempotency-Key: required

22. GET /api/v1/data-sources/ingestions
   Inspect ingestion stages, immutable version status, validation issues, and columns requiring review.
   Auth: Bearer API key; scopes: data_sources:read

23. PATCH /api/v1/data-sources/ingestions
   Submit reviewed column interpretations and atomically activate the validated version.
   Auth: Bearer API key; scopes: data_sources:write
   Idempotency-Key: required

24. GET /api/v1/contacts
   List mobile-first tenant contacts with categories and communication rollups.
   Auth: Bearer API key; scopes: contacts:read

25. POST /api/v1/contacts
   Create one mobile-first contact in a tenant-owned category.
   Auth: Bearer API key; scopes: contacts:write
   Idempotency-Key: required

26. DELETE /api/v1/contacts
   Delete selected tenant contacts or every contact in a tenant category.
   Auth: Bearer API key; scopes: contacts:write
   Idempotency-Key: required

27. POST /api/v1/contacts/imports
   Import structured CSV contact data into one or more tenant categories.
   Auth: Bearer API key; scopes: contacts:write
   Idempotency-Key: required

28. GET /api/v1/contacts/categories
   List tenant contact categories with cursor pagination and contact counts.
   Auth: Bearer API key; scopes: contacts:read

29. POST /api/v1/contacts/categories
   Create a tenant contact category.
   Auth: Bearer API key; scopes: contacts:write
   Idempotency-Key: required

30. DELETE /api/v1/contacts/categories
   Delete a tenant category after explicit name confirmation.
   Auth: Bearer API key; scopes: contacts:write
   Idempotency-Key: required

31. GET /api/v1/contacts/{contactId}
   Read one contact with categories and per-channel activity rollups. When campaignId is supplied, include that campaign's shared contact summary.
   Auth: Bearer API key; scopes: contacts:read
   Conditional scopes: Reading campaignSummary with campaignId also requires communications:read.

32. PATCH /api/v1/contacts/{contactId}
   Update one contact, category membership, and communication-blocked state.
   Auth: Bearer API key; scopes: contacts:write
   Idempotency-Key: required

33. GET /api/v1/contacts/{contactId}/timeline
   Read a bounded cross-channel timeline of messages, transcripts, and call outcomes, optionally limited to one campaign. Recording content requires voice:read through the call API.
   Auth: Bearer API key; scopes: communications:read

34. POST /api/v1/channel-participants/{participantId}/link-contact
   Resolve a mobile-first contact and link the participant's prior channel history without OTP verification.
   Auth: Bearer API key; scopes: contacts:write
   Idempotency-Key: required

35. GET /api/v1/channels/assignments
   Read WhatsApp phone-number assignment state and assignable agents.
   Auth: Bearer API key; scopes: channels:read

36. PATCH /api/v1/channels/assignments
   Assign a WhatsApp agent to the connected Meta phone number.
   Auth: Bearer API key; scopes: channels:write

37. GET /api/v1/voice/calls
   Cursor-paginated outbound calls with immutable call IDs, status, duration, charge, transcript and recording availability, plus canonical end attribution.
   Response: Returns call summaries including endedBy and endReason. Recording object storage details are never exposed.
   Auth: Bearer API key; scopes: voice:read

38. POST /api/v1/voice/calls
   Start one outbound call with an active Voice Call agent. Choose exactly one target: a scoped contact or a direct E.164 number.
   Request: JSON: businessId, agentId, and exactly one of contactId or toNumber. Header: Idempotency-Key.
   Response: Returns callId, destination, and lifecycle status. Provider, runtime session, and resolved contact values are never returned.
   Auth: Bearer API key; scopes: voice:write
   Idempotency-Key: required

39. POST /api/v1/voice/calls/batch
   Start calls for 1-20 scoped contacts through the same canonical call service. Each contact resolves its own declared variables and receives an independent billing reservation.
   Request: JSON: businessId, agentId, contactIds. Header: Idempotency-Key.
   Response: Returns accepted calls and per-contact failures. Repeating the same key and body returns the original result.
   Auth: Bearer API key; scopes: voice:write
   Idempotency-Key: required

40. GET /api/v1/voice/calls/resolve
   Resolve the selected agent's allowlisted contact variables and destination before placement. This endpoint exposes contact data and therefore requires both voice and contact read scopes.
   Auth: Bearer API key; scopes: voice:read, contacts:read

41. GET /api/v1/voice/calls/{callId}
   Return one canonical call report with who ended the call, timestamp-ordered transcript, and a short-lived recording URL when the recording is ready.
   Response: Returns call, transcript, playback URL, and attachment download URL. Signed recording URLs expire after five minutes.
   Auth: Bearer API key; scopes: voice:read

42. GET /api/v1/voice/calls/{callId}/transcript
   Download the canonical timestamped call transcript as UTF-8 plain text with an attachment filename.
   Response: Returns text/plain with Content-Disposition attachment. Returns 404 until transcript turns exist.
   Auth: Bearer API key; scopes: voice:read

43. DELETE /api/v1/voice/calls/{callId}
   Durably request cancellation of a queued, ringing, or answered call. Carrier-backed calls remain cancelling until the carrier confirms a terminal result.
   Request: Query: businessId. Header: Idempotency-Key.
   Response: Returns callId and cancelling status while the carrier confirms hangup, or cancelled when no carrier call was created. Provider identifiers are never returned.
   Auth: Bearer API key; scopes: voice:write
   Idempotency-Key: required

44. GET /api/v1/campaigns
   List channel-neutral campaigns and their latest Voice, WhatsApp, or SMS iteration.
   Auth: Bearer API key; scopes: campaigns:read

45. POST /api/v1/campaigns
   Create one campaign objective with an initial channel-specific iteration and immutable audience membership.
   Auth: Bearer API key; scopes: campaigns:write
   Idempotency-Key: required

46. GET /api/v1/campaigns/{campaignId}
   Read one tenant-owned campaign and its current iteration.
   Auth: Bearer API key; scopes: campaigns:read

47. PATCH /api/v1/campaigns/{campaignId}
   Start, pause, or cancel the current campaign iteration.
   Auth: Bearer API key; scopes: campaigns:write
   Idempotency-Key: required

48. GET /api/v1/campaigns/{campaignId}/iterations
   List ordered channel-specific runs under a stable campaign ID.
   Auth: Bearer API key; scopes: campaigns:read

49. POST /api/v1/campaigns/{campaignId}/iterations
   Select contacts from prior outcomes and create a Voice or WhatsApp iteration without creating another campaign.
   Auth: Bearer API key; scopes: campaigns:write
   Idempotency-Key: required

50. GET /api/v1/campaigns/{campaignId}/memberships
   List immutable per-contact iteration membership and outcome history.
   Auth: Bearer API key; scopes: campaigns:read, contacts:read

51. GET /api/v1/campaigns/{campaignId}/activities
   List attributed calls and messages newest first with contact, iteration, status, call duration, and activity time. Filter by contact, iteration, channel, or status.
   Auth: Bearer API key; scopes: campaigns:read, communications:read

52. GET /api/v1/campaigns/{campaignId}/summary-runs
   List append-only AI summary runs and their assessment progress for a campaign.
   Auth: Bearer API key; scopes: campaigns:read, communications:read

53. POST /api/v1/campaigns/{campaignId}/summary-runs
   Calculate a maximum AI assessment charge or confirm that exact estimate before processing.
   Auth: Bearer API key; scopes: campaigns:write, communications:read
   Idempotency-Key: required

54. GET /api/v1/campaigns/{campaignId}/exports
   Stream one campaign conclusion per unique contact with the columns Contact, AI Summary, and Next Action.
   Auth: Bearer API key; scopes: campaigns:read, communications:read, contacts:read

Runnable beginner workflows:
# CtrlLane beginner examples
#
# Run one section at a time. Commands use curl and jq and call the same v1 API
# used by the CtrlLane console. Sending a message or starting a call is a
# real, billable action. Use your own authorised test number.

## 1. Check access
# Confirm that the key and Business ID work.
# Required permissions: channels:read
# Result: Returns WhatsApp, Voice, and Instagram messaging as live channels. SMS remains reserved until its provider route is enabled.
# Run this in Terminal. Replace the API key and test mobile number.
command -v jq >/dev/null || { echo "Install jq first: https://jqlang.org/download/"; exit 1; }
export CP_BASE_URL="https://app.ctrllane.com"
read -rsp "Paste your CtrlLane API key: " CP_API_KEY; echo
export CP_API_KEY
export CP_BUSINESS_ID="paste_business_id_here"
export CP_TEST_MOBILE="+919999999999"

curl --fail-with-body --silent --show-error \
  "$CP_BASE_URL/api/v1/channels?businessId=$CP_BUSINESS_ID" \
  -H "Authorization: Bearer $CP_API_KEY" | jq

## 2. Create a contact
# Save a mobile-first customer in the native Contacts data source.
# Required permissions: contacts:write
# Result: Prints the new contact ID. Re-running the same commands is safe.
# Run this in Terminal. Replace the API key and test mobile number.
command -v jq >/dev/null || { echo "Install jq first: https://jqlang.org/download/"; exit 1; }
export CP_BASE_URL="https://app.ctrllane.com"
read -rsp "Paste your CtrlLane API key: " CP_API_KEY; echo
export CP_API_KEY
export CP_BUSINESS_ID="paste_business_id_here"
export CP_TEST_MOBILE="+919999999999"

# Contacts always belong to a category. This creates or reuses "API leads".
CATEGORY_ID=$(curl --fail-with-body --silent --show-error \
  "$CP_BASE_URL/api/v1/contacts/categories" -X POST \
  -H "Authorization: Bearer $CP_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: beginner-api-leads-category-v1" \
  -d "$(jq -n --arg businessId "$CP_BUSINESS_ID" \
    '{businessId: $businessId, name: "API leads"}')" | jq -er '.data.category.id')

CONTACT_ID=$(curl --fail-with-body --silent --show-error \
  "$CP_BASE_URL/api/v1/contacts" -X POST \
  -H "Authorization: Bearer $CP_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: beginner-contact-${CP_TEST_MOBILE//[^0-9]/}-v1" \
  -d "$(jq -n \
    --arg businessId "$CP_BUSINESS_ID" \
    --arg mobile "$CP_TEST_MOBILE" \
    --arg categoryId "$CATEGORY_ID" \
    '{businessId: $businessId, name: "API test contact", mobile: $mobile, categoryIds: [$categoryId]}')" \
  | jq -er '.data.contact.id')

echo "Contact created: $CONTACT_ID"

## 3. Send WhatsApp
# Find the connected number, send one message, and print its ID.
# Required permissions: channels:read, communications:write
# Result: Queues one real WhatsApp message. The same idempotency key never sends it twice.
# Run this in Terminal. Replace the API key and test mobile number.
command -v jq >/dev/null || { echo "Install jq first: https://jqlang.org/download/"; exit 1; }
export CP_BASE_URL="https://app.ctrllane.com"
read -rsp "Paste your CtrlLane API key: " CP_API_KEY; echo
export CP_API_KEY
export CP_BUSINESS_ID="paste_business_id_here"
export CP_TEST_MOBILE="+919999999999"

# CtrlLane discovers the Meta phone-number ID for this business.
PHONE_NUMBER_ID=$(curl --fail-with-body --silent --show-error \
  "$CP_BASE_URL/api/v1/channels/assignments?businessId=$CP_BUSINESS_ID&channel=whatsapp" \
  -H "Authorization: Bearer $CP_API_KEY" | jq -er '.data.phoneNumberId')

MESSAGE_ID=$(curl --fail-with-body --silent --show-error \
  "$CP_BASE_URL/api/v1/messages" -X POST \
  -H "Authorization: Bearer $CP_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: beginner-whatsapp-${CP_TEST_MOBILE//[^0-9]/}-v1" \
  -d "$(jq -n \
    --arg businessId "$CP_BUSINESS_ID" \
    --arg phoneNumberId "$PHONE_NUMBER_ID" \
    --arg to "$CP_TEST_MOBILE" \
    '{businessId: $businessId, phoneNumberId: $phoneNumberId, to: $to, type: "text", text: "Hello from CtrlLane."}')" \
  | jq -er '.data.messageId')

echo "Message queued: $MESSAGE_ID"

## 4. Start a voice call
# Find a compatible Voice agent and call the test number.
# Required permissions: agents:read, voice:write
# Result: Starts one real call and prints its call ID. Calling requires an active Voice agent and approved number.
# Run this in Terminal. Replace the API key and test mobile number.
command -v jq >/dev/null || { echo "Install jq first: https://jqlang.org/download/"; exit 1; }
export CP_BASE_URL="https://app.ctrllane.com"
read -rsp "Paste your CtrlLane API key: " CP_API_KEY; echo
export CP_API_KEY
export CP_BUSINESS_ID="paste_business_id_here"
export CP_TEST_MOBILE="+919999999999"

# Choose the newest active Voice agent that does not require contact variables.
AGENT_ID=$(curl --fail-with-body --silent --show-error \
  "$CP_BASE_URL/api/v1/agents?businessId=$CP_BUSINESS_ID" \
  -H "Authorization: Bearer $CP_API_KEY" \
  | jq -er '[.data.agents[] | select(.channel == "voice" and .status == "active" and .contactContextRequired == false)] | first | .id')

CALL_ID=$(curl --fail-with-body --silent --show-error \
  "$CP_BASE_URL/api/v1/voice/calls" -X POST \
  -H "Authorization: Bearer $CP_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: beginner-call-${CP_TEST_MOBILE//[^0-9]/}-v1" \
  -d "$(jq -n \
    --arg businessId "$CP_BUSINESS_ID" \
    --arg agentId "$AGENT_ID" \
    --arg toNumber "$CP_TEST_MOBILE" \
    '{businessId: $businessId, agentId: $agentId, toNumber: $toNumber}')" \
  | jq -er '.data.callId')

echo "Call started: $CALL_ID"

## 5. Read contact history
# Find a contact and read its WhatsApp and Voice timeline.
# Required permissions: contacts:read, communications:read
# Result: Returns the newest messages, transcripts, and call outcomes stored for that contact.
# Run this in Terminal. Replace the API key and test mobile number.
command -v jq >/dev/null || { echo "Install jq first: https://jqlang.org/download/"; exit 1; }
export CP_BASE_URL="https://app.ctrllane.com"
read -rsp "Paste your CtrlLane API key: " CP_API_KEY; echo
export CP_API_KEY
export CP_BUSINESS_ID="paste_business_id_here"
export CP_TEST_MOBILE="+919999999999"

CONTACT_ID=$(curl --fail-with-body --silent --show-error \
  "$CP_BASE_URL/api/v1/contacts?businessId=$CP_BUSINESS_ID&search=${CP_TEST_MOBILE//+/%2B}" \
  -H "Authorization: Bearer $CP_API_KEY" \
  | jq -er '.data.contacts | first | .id')

curl --fail-with-body --silent --show-error \
  "$CP_BASE_URL/api/v1/contacts/$CONTACT_ID/timeline?businessId=$CP_BUSINESS_ID" \
  -H "Authorization: Bearer $CP_API_KEY" | jq

Common response shape:
Success:
{
  "ok": true,
  "data": {},
  "meta": { "requestId": "uuid" }
}

Failure:
{
  "ok": false,
  "error": {
    "code": "machine_readable_code",
    "message": "Human readable message",
    "category": "validation | authentication | authorization | billing | not_found | conflict | rate_limit | capacity | configuration | upstream | internal",
    "retryable": false,
    "requestId": "uuid"
  },
  "meta": { "requestId": "uuid" }
}

Operational notes:
- For WhatsApp automation, connect the Meta WhatsApp account first, then assign an active WhatsApp agent to the connected phone number.
- WhatsApp automation is webhook-triggered. Sending through /api/v1/messages queues an outbound message; it does not directly call the AI agent.
- /api/v1/media is the canonical media upload endpoint. Upload media first, then pass the returned mediaId to /api/v1/messages.
- A successful send is queued, not delivered synchronously. Use taskId/jobId for operational tracing and message status webhooks for delivery state.
- External CRM consumers must use these public v1 endpoints for media upload and delivery. They must not import CtrlLane repositories or call Meta directly.
- A CRM asset router should upload approved image bytes to /api/v1/media, cache the returned mediaId, then call /api/v1/messages with type=image and an Idempotency-Key.
- Use separate idempotency keys for upload and send operations, for example nuwud|asset/P01|upload|lead-123 and nuwud|asset/P01|send|lead-123.
- If a natural CRM operation id exceeds 512 visible ASCII characters, hash it before placing it in the Idempotency-Key header.
- AI agent output is customer text only. Media actions, asset labels, captions, and next-question metadata are transport concerns and must never be copied into the customer text body.
- Eligible WhatsApp Business App coexistence onboarding can request Meta's contact and history synchronization after user consent; availability and history depth remain controlled by Meta.
- Treat SMS assignment errors with code channel_assignment_not_supported as expected until its provider is enabled.
- Voice reports require the dedicated voice:read scope. Playback and attachment URLs are short-lived (five minutes), must be treated as sensitive customer data, and never include object storage keys or buckets.
- GET /api/v1/voice/calls/{callId}/transcript downloads the same canonical transcript returned by the call detail API as UTF-8 text.
- /api/v1/campaigns is the canonical channel-neutral campaign API. Memberships preserve internal iteration selection; activities list attributed calls and messages newest first with contact, iteration, outcome, call duration and activity time, with contact/iteration/channel/status filters. Financial usage belongs to Billing rather than Delivery Reports.
- AI summaries use append-only /summary-runs. Estimate first, explicitly confirm the exact maximum charge, then poll progress and stream /exports with the completed or partial summaryRunId. The CSV contains one cross-channel campaign conclusion per unique contact: Contact, AI Summary, and Next Action.
- Start a voice call with voice:write. A contact target resolves only the variables declared by that agent and snapshots them before carrier placement. A direct number is allowed only for agents that do not require contact context.
- Voice campaigns accept only contacts compatible with the selected agent. The dispatcher runs calls in parallel up to the business's purchased line count, then continues sequentially as lines are released. Busy, unanswered, and failed calls follow maxRetries and retryDelayMinutes; completed and operator-cancelled calls are not retried.
- Voice greeting audio may be prepared while the carrier is ringing and is released only after answer. This optimization does not change call/session identity.
- For a non-interruptible greeting, customer input is held until at least 95% of the greeting has actually played. Transport playback receipts, not generated duration, determine completion.
- A model-requested end_call is executed only after ordered assistant audio has drained, then the carrier call is closed.
- DELETE /api/v1/voice/calls/{callId} records durable cancellation intent before contacting the carrier. A cancelling response is accepted, not final; poll the call report for the carrier-confirmed terminal result.
- Voice call reports expose endedBy as customer, agent, operator, system, provider, or unknown. Internal termination intent is preserved and provider CDR hangup_source is reconciled after carrier completion.
- Connected voice calls are billed in started 20-second blocks at the business's active Voice price. Failed or unanswered attempts are charged only when the carrier reports an actual attempt cost.
