Cast API

Send SMS, WhatsApp, and voice messages. Trigger flows. Manage contacts, properties, and events. Create short links. Receive delivery webhooks. All through one REST API. Built for MENA + Africa.

Overview

Base URL. All API endpoints are under:

https://api.cast.wayvzgroup.com

The API speaks JSON: send Content-Type: application/json on POST requests; all responses are JSON. The X-Api-Version: v1 header is included on every response.

Machine-readable spec. The full API is published as an OpenAPI 3.0 document at /v1/openapi. Point your SDK generator, Postman, or an AI coding assistant at it rather than transcribing this page by hand. It needs no API key, and the servers entry reflects whichever host you fetch it from.

Quick test: verify your key

curl "https://api.cast.wayvzgroup.com/v1/ping" \
  -H "Authorization: Bearer sk_live_..."

Endpoint index

MethodPathWhat it does
GET/v1/pingHealth check + auth test
GET/v1/balanceCurrent wallet balance
GET/v1/accountAccount profile + rate limits
POST/v1/messagesSend SMS (single or bulk)
GET/v1/messagesList recent messages
GET/v1/messages/{id}Get a single message by ID
POST/v1/whatsapp/messagesSend a WhatsApp template message
POST/v1/voice/callPlace a voice call (TTS + DTMF)
GET/v1/voice/call/{callSid}Voice call status + duration + DTMF
GET/v1/flowsList your flows + 24h enrollment counts
POST/v1/flows/{id}/triggerStart a flow for a contact (with merge-tag variables)
GET/v1/flows/enrollments/{id}Enrollment detail with step-by-step timeline
POST/v1/contactsCreate or update a contact (idempotent on phone)
GET/v1/contactsList contacts (paginated, filter by group + search)
GET/v1/contacts/{phone}Get contact with properties + groups + tags
GET/v1/contacts/{phone}/propertiesList a contact’s custom properties
POST/v1/contacts/{phone}/propertiesUpsert one or more properties
GET/v1/contacts/{phone}/eventsList recent events for a contact
POST/v1/contacts/{phone}/eventsEmit a custom event
POST/v1/linksCreate a slnk.ai short link
POST/v1/campaignsSend a multi-recipient campaign (sms / whatsapp / voice)
POST/v1/eventsRecord up to 100 contact events in one call
POST/v1/push/devicesRegister a device token for push
GET/v1/audit-logYour account's audit trail, for a key with audit access

Authentication

Cast uses Bearer tokens. Create a key at /developers, then send it on every request:

Authorization: Bearer sk_live_...

Keys start with sk_live_ followed by 32 random characters. They are shown once at creation time. Store them safely. Lost keys can be revoked and replaced from the same page.

Keep keys server-side. Anyone with your key can spend your wallet. Never bundle API keys into a mobile or browser app.

Sandbox keys

A key that starts with sk_test_ reads your real data. It never sends, never charges and never erases a contact. Sends, calls, campaigns, journey enrolments, events, contacts, contact properties and verification checks come back with the same response a live key gets, plus "livemode": false and "simulated": true, and nothing is saved. Every response to a key Cast recognises, refusals included (revoked, IP not allowed, rate limit, idempotency), carries an X-Cast-Mode: live or test header.

Events (POST /v1/events, POST /v1/contacts/{phone}/events), contacts (POST /v1/contacts) and properties (POST /v1/contacts/{phone}/properties) are checked like live ones but not stored, so a test key never starts or feeds a journey. A verification check with a test key never checks the code and never uses an attempt.

Two writes are real even with a test key: creating a short link (POST /v1/links), and registering or unregistering a push device (POST and DELETE /v1/push/devices). A device registered with a test key can receive real pushes later from a live send.

Idempotency

If a POST times out you cannot tell whether it went through. Retrying blindly can send the same message twice, and bill you twice. Send an Idempotency-Key header and retrying is safe: Cast replays the original response instead of doing the work again.

curl -X POST "https://api.cast.wayvzgroup.com/v1/messages" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: order-4471-confirmation" \
  -H "Content-Type: application/json" \
  -d '{"to":"9613123456","from":"Cast","text":"Your order is on its way."}'

Use any string you can regenerate for the same logical action. An order ID works well; a random UUID per attempt does not, because a retry must reuse the key.

SituationWhat happens
First request with the keyRuns normally. The response is stored.
Retry, same key and same bodyThe stored response is returned with Idempotency-Replayed: true. Nothing is sent again.
Retry while the first is still running409 request_in_progress. Wait a moment and retry.
Same key, different body422 idempotency_key_reused. Use a new key. Replaying the first response here would tell you a message was sent that never was.
No key sentNothing changes. The header is entirely optional.

Keys are scoped to your account and expire after 24 hours. Only successful (2xx) responses are stored, so a failed request can always be retried normally, so a transient outage never gets frozen into a permanent error.

Errors

Cast returns conventional HTTP status codes. Error bodies are always JSON:

{
  "error": {
    "code": "missing_field",
    "message": "Required field(s) missing: to"
  }
}

See the full error codes reference below.

Rate limits

Each API key has one hourly window, and every authenticated response tells you where you stand in it:

HeaderMeaning
X-RateLimit-LimitThe requests this key may make in the window. The default is 1,000 an hour.
X-RateLimit-RemainingHow many are left.
X-RateLimit-ResetWhen the window ends, in unix seconds.

Over the limit, Cast answers 429 with the code rate_limit_exceeded and a Retry-After header in seconds. Wait that long and try again. Treat the three headers as optional: an internal key with no limit does not get them. A failed authentication uses none of your window. GET /v1/account returns the same ceiling as rate_limits.requests_per_hour.

Versioning

The major version is in the URL. Today there is one, /v1. A new major version would run beside the old one for at least 12 months.

What can change with no notice

A new endpoint, a new optional request field or parameter, a new response field, a new webhook event, a new field on an event, a new value in a response enum, and a new error code for a new condition. Your code must ignore fields and enum values it does not know.

What needs notice

Removing or renaming an endpoint, a parameter, a response field, an event or an event field. Changing a type. Making an optional field required. Tightening validation so a request that worked now fails. Changing what an existing status code or error code means. Changing how webhooks are signed.

How much notice

At least 180 days between the day a deprecation is announced and the day it is removed. The one exception is a security fix, which can be shorter and is announced the same day with the reason. A surface marked beta gets 30 days.

How you hear about it

An entry on this changelog with the sunset date. deprecated, x-sunset and x-replacement in the OpenAPI spec. Deprecation, Sunset and Link headers on every response from a deprecated endpoint. A deprecated mark in the webhook event catalogue. A note beside the item in the API reference.

OAuth and marketplace

Cast authenticates with API keys. Each key belongs to one account. There are no OAuth apps and no app marketplace today, and no third party can act for several Cast accounts through a single install.

OAuth and a marketplace are on the roadmap, and both wait for a first consumer: a partner that needs to install across many accounts. Until then, API keys stay as they are.

SDKs & tools

Official, dependency-light clients wrap the whole API in idiomatic code. Each mirrors the REST paths (cast.messages.send, cast.whatsapp.send, cast.contacts.upsert, cast.flows.trigger…), detects live vs sandbox from your key, supports the Idempotency-Key, and raises a typed error carrying the stable error code.

LanguageInstallImport
JavaScript / TypeScriptnpm i @cast/sdkconst Cast = require('@cast/sdk')
Pythonpip install cast-sdkfrom cast import Cast
PHPcomposer require cast/sdknew Cast\Client(...)

JavaScript quickstart

const Cast = require('@cast/sdk');
const cast = new Cast('sk_test_...');            // sandbox: nothing sent or charged

const msg = await cast.messages.send({ to: '96170000000', from: 'CAST', text: 'Hello' });
console.log(msg.id, cast.livemode);              // false on a test key

// safe to retry: the same key replays the first response
await cast.messages.send({ to: '96170000000', text: 'Order #42' },
                         { idempotencyKey: 'order-42' });

try {
  await cast.balance();
} catch (e) {
  if (e.code === 'insufficient_balance') { /* top up */ }
}

Python quickstart

from cast import Cast, CastError
cast = Cast("sk_test_...")

msg = cast.messages.send(to="96170000000", sender="CAST", text="Hello")
cast.whatsapp.send(to="96170000000", template="welcome")
cast.contacts.set_properties("96170000000", plan="vip")

Postman & OpenAPI

Import the ready-made Postman collection (bearer auth pre-wired, an optional Idempotency-Key on every POST), or point any generator at the /v1/openapi spec to build a client in a language that isn't listed above.

SMS

POST /v1/messages
Send an SMS to one or many recipients. Pass a single number or an array in to for bulk sends (max 1,000 per request).

Request body

FieldTypeNotes
tostring | string[]requiredE.164 number(s). Plus sign optional. Array for bulk (max 1,000).
fromstringrequiredSender ID, up to 11 chars. Alphanumeric senders need per-country registration.
textstringrequiredMessage body.
languageintoptional0 = GSM-7 (default, 160 chars/segment), 1 = Unicode (70 chars/segment).
schedulestringoptionalISO 8601 datetime to defer sending.
curl -X POST "https://api.cast.wayvzgroup.com/v1/messages" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "to": "9613xxxxxxx",
    "from": "Cast",
    "text": "Your code is 123456"
  }'
const res = await fetch(
  'https://api.cast.wayvzgroup.com/v1/messages',
  {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer sk_live_...',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      to: '9613xxxxxxx',
      from: 'Cast',
      text: 'Your code is 123456'
    })
  }
);
const data = await res.json();
console.log(data.id); // "msg_12345"
import requests

resp = requests.post(
    'https://api.cast.wayvzgroup.com/v1/messages',
    headers={
        'Authorization': 'Bearer sk_live_...',
        'Content-Type': 'application/json'
    },
    json={
        'to': '9613xxxxxxx',
        'from': 'Cast',
        'text': 'Your code is 123456'
    }
)
print(resp.json()['id'])  # "msg_12345"
$ch = curl_init('https://api.cast.wayvzgroup.com/v1/messages');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer sk_live_...',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'to'   => '9613xxxxxxx',
        'from' => 'Cast',
        'text' => 'Your code is 123456'
    ])
]);
$data = json_decode(curl_exec($ch), true);
echo $data['id']; // "msg_12345"
Response 201
{
  "id": "msg_12345",
  "object": "message",
  "status": "queued",
  "to": ["9613xxxxxxx"],
  "from": "Cast",
  "language": 0,
  "parts": 1,
  "cost": 0.04,
  "currency": "USD",
  "created_at": "2026-05-01T11:00:00Z"
}
GET /v1/messages
List recent messages, newest first.

Query parameters

limitint1 to 200, default 50.
offsetintOffset for pagination.
tostringFilter by recipient phone number.
statusstringqueued, sent, delivered, read, failed.
from_date / to_datestringISO 8601 range.
cURL example
curl "https://api.cast.wayvzgroup.com/v1/messages?limit=20&status=delivered" \
  -H "Authorization: Bearer sk_live_..."
GET /v1/messages/{id}
Retrieve a single message by ID. The id field from a send response (e.g. msg_12345) works with or without the msg_ prefix.
cURL example
curl "https://api.cast.wayvzgroup.com/v1/messages/msg_12345" \
  -H "Authorization: Bearer sk_live_..."

A message that failed carries a failure object: reason (from a closed list), reason_text (a sentence you can show), code (the carrier's own code, when it sent one) and attempts. It is null when there is nothing to report. GET /v1/messages carries it too.

WhatsApp

WhatsApp messages must use a Meta-approved template. Submit and manage templates from the template editor. See Templates for approval tips.

POST /v1/whatsapp/messages
Send a WhatsApp template message to a single recipient.

Request body

FieldTypeNotes
tostringrequiredE.164 phone number, with or without leading +.
template_namestringrequiredExactly as shown in your approved templates list.
template_languagestringrequirede.g. en_US, ar.
template_categorystringrequiredUTILITY, MARKETING, or AUTHENTICATION. Affects per-message pricing.
template_paramsstring[]optionalBody variable values in order. Required when the template contains {{1}}, {{2}}, etc.
curl -X POST "https://api.cast.wayvzgroup.com/v1/whatsapp/messages" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "to": "9613xxxxxxx",
    "template_name": "order_shipped",
    "template_language": "en_US",
    "template_category": "UTILITY",
    "template_params": ["Hadi", "AWB-12345"]
  }'
const res = await fetch(
  'https://api.cast.wayvzgroup.com/v1/whatsapp/messages',
  {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer sk_live_...',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      to: '9613xxxxxxx',
      template_name: 'order_shipped',
      template_language: 'en_US',
      template_category: 'UTILITY',
      template_params: ['Hadi', 'AWB-12345']
    })
  }
);
const data = await res.json();
console.log(data.meta_message_id);
import requests

resp = requests.post(
    'https://api.cast.wayvzgroup.com/v1/whatsapp/messages',
    headers={
        'Authorization': 'Bearer sk_live_...',
        'Content-Type': 'application/json'
    },
    json={
        'to': '9613xxxxxxx',
        'template_name': 'order_shipped',
        'template_language': 'en_US',
        'template_category': 'UTILITY',
        'template_params': ['Hadi', 'AWB-12345']
    }
)
print(resp.json()['meta_message_id'])
$ch = curl_init('https://api.cast.wayvzgroup.com/v1/whatsapp/messages');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer sk_live_...',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'to'                => '9613xxxxxxx',
        'template_name'     => 'order_shipped',
        'template_language' => 'en_US',
        'template_category' => 'UTILITY',
        'template_params'   => ['Hadi', 'AWB-12345']
    ])
]);
$data = json_decode(curl_exec($ch), true);
echo $data['meta_message_id'];
Response 201
{
  "id": "msg_12346",
  "object": "whatsapp_message",
  "status": "queued",
  "to": "9613xxxxxxx",
  "template_name": "order_shipped",
  "template_language": "en_US",
  "template_category": "UTILITY",
  "cost": 0.014,
  "currency": "USD",
  "meta_message_id": "wamid.HBgL...",
  "created_at": "2026-05-01T11:00:00Z"
}

Templates

WhatsApp requires Meta to approve message templates before they can be sent. Submit them from the template editor; approval typically takes 1 to 10 minutes.

Categories

  • UTILITY: transactional updates tied to a customer action (order shipped, OTP, appointment reminders). Lowest rate.
  • MARKETING: promotions, offers, re-engagement. Higher rate; opt-out compliance required.
  • AUTHENTICATION: one-time passwords. Lowest rate, strict format requirements.

Common rejection reasons

  • Template body looks promotional but was submitted as UTILITY.
  • Variables placed at the very start or end of the body with no surrounding text.
  • Missing or vague example values for body variables.
  • Header image too large, wrong aspect ratio, or text-heavy image.
  • Footer containing URLs, phone numbers, or currency symbols (restricted in some markets).
Tip. Button templates (URL, phone, quick reply, copy code) are supported in the template editor. Variables in button URLs use the same {{1}} syntax as body variables.

Contacts: Properties

Contact properties are arbitrary key-value pairs you store per phone number. They power segment conditions (e.g. plan = "premium", lifetimeSpend > 500) and flow branch logic. Values are always stored as strings; Cast auto-casts to numeric/date types for segment comparisons.

GET /v1/contacts/{phone}/properties
List all stored properties for a contact.
cURL example
curl "https://api.cast.wayvzgroup.com/v1/contacts/9613xxxxxxx/properties" \
  -H "Authorization: Bearer sk_live_..."
Response 200
{
  "object": "contact_properties",
  "phone": "9613xxxxxxx",
  "properties": {
    "plan": "premium",
    "lifetimeSpend": "1250.00",
    "lastSeen": "2026-04-15"
  }
}
POST /v1/contacts/{phone}/properties
Upsert one or more properties for a contact (PATCH semantics: only the keys you send are changed). The body is a flat JSON object of "key": "value" pairs. Max 50 properties per request. Set a value to null to delete a property.

Property key rules

  • Must start with a letter.
  • May contain letters, digits, dots (.), underscores (_), or hyphens (-).
  • Max 100 characters.
curl -X POST "https://api.cast.wayvzgroup.com/v1/contacts/9613xxxxxxx/properties" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "plan": "premium",
    "lifetimeSpend": "1250.00",
    "lastSeen": "2026-04-15"
  }'
await fetch(
  'https://api.cast.wayvzgroup.com/v1/contacts/9613xxxxxxx/properties',
  {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer sk_live_...',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      plan: 'premium',
      lifetimeSpend: '1250.00',
      lastSeen: '2026-04-15'
    })
  }
);
requests.post(
    'https://api.cast.wayvzgroup.com/v1/contacts/9613xxxxxxx/properties',
    headers={'Authorization': 'Bearer sk_live_...'},
    json={'plan': 'premium', 'lifetimeSpend': '1250.00', 'lastSeen': '2026-04-15'}
)
Response 200
{
  "object": "contact_properties",
  "phone": "9613xxxxxxx",
  "upserted": 3
}

Contacts: Events

Contact events are timestamped actions (e.g. purchase.completed, form.submitted, subscription.renewed). They power event-triggered automation flows and segment conditions. Event type must follow domain.action format: lowercase letters, digits, and underscores only.

GET /v1/contacts/{phone}/events
List recent events for a contact, newest first.
cURL example
curl "https://api.cast.wayvzgroup.com/v1/contacts/9613xxxxxxx/events" \
  -H "Authorization: Bearer sk_live_..."
POST /v1/contacts/{phone}/events
Record a custom event for a contact. The event is stored on the contact's timeline, where goal and wait_for_event steps of running journeys and segment conditions read it. It does not start event-triggered journeys: only POST /v1/events does. With a sandbox key the event is checked and answered with "simulated": true, but it is not stored and starts no journey.

Request body

FieldTypeNotes
typestringrequiredEvent type in domain.action format. Max 50 chars. Example: purchase.completed.
payloadobjectoptionalArbitrary JSON object with event-specific data. Max 8 KB. Stored with the event and available in flow conditions.
occurred_atstringoptionalISO 8601 datetime. Defaults to now. Use for backfilling historical events.
curl -X POST "https://api.cast.wayvzgroup.com/v1/contacts/9613xxxxxxx/events" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "type": "purchase.completed",
    "payload": {
      "orderId": "ORD-4829",
      "amount": 249.99,
      "currency": "USD"
    }
  }'
await fetch(
  'https://api.cast.wayvzgroup.com/v1/contacts/9613xxxxxxx/events',
  {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer sk_live_...',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      type: 'purchase.completed',
      payload: { orderId: 'ORD-4829', amount: 249.99, currency: 'USD' }
    })
  }
);
requests.post(
    'https://api.cast.wayvzgroup.com/v1/contacts/9613xxxxxxx/events',
    headers={'Authorization': 'Bearer sk_live_...'},
    json={
        'type': 'purchase.completed',
        'payload': {'orderId': 'ORD-4829', 'amount': 249.99, 'currency': 'USD'}
    }
)
Response 201
{
  "object": "contact_event",
  "id": 9001,
  "phone": "9613xxxxxxx",
  "type": "purchase.completed",
  "occurred_at": "2026-05-01T11:00:00Z"
}
Automation tip. This endpoint stores the event on the contact's timeline, where journey goal and wait-for-event steps and segment conditions read it. To start an event-triggered journey, send the event through POST /v1/events instead: an accepted event there enrols the contact in every active journey that fires on purchase.completed within the next processor cycle (roughly 60 seconds), for example send a WhatsApp thank-you, wait 24 hours, then send an upsell message.

Voice

POST /v1/voice/call

Place a one-off voice call with TTS audio and optional DTMF capture. The call is queued immediately, then placed via your configured Voice (SIP) connector. Returns a callSid you can poll for status. Each call is billed against your wallet at the per-minute rate configured for the destination operator (30-second billable floor).

Request body

{
  "to": "+9613197119",
  "ttsText": "Hi {firstname}, your driver is two minutes away.",
  "language": "en",
  "voiceId": "en-US-Wavenet-D",
  "speed": 1.0,
  "messageType": "tts",
  "dtmf": {
    "options": { "1": "Confirmed", "2": "Cancel" },
    "timeoutSec": 15
  },
  "callbackUrl": "https://your-app.example.com/cast/voice-callback",
  "metadata": { "orderId": "4821", "driverId": "D-441" }
}

cURL

curl -X POST "https://api.cast.wayvzgroup.com/v1/voice/call" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+9613197119",
    "ttsText": "Your pickup is at Hamra Street",
    "language": "en",
    "dtmf": { "options": { "1": "Confirmed", "2": "Cancel" }, "timeoutSec": 15 }
  }'

Response 201 Created

{
  "id": "vc_abc12345",
  "callSid": "abc12345def67890",
  "status": "queued",
  "to": "+9613197119",
  "estimatedCost": 0.025,
  "createdAt": "2026-05-16T10:30:00Z"
}

Response 202 Accepted, outcome unknown

Rarely, Cast cannot confirm that the call was queued. It may still be placed, so it is not tried again, and its charge is kept until it settles. status is unknown and message says in plain words whether this was recorded. Look the call up by its callSid; if the message asks you to contact support, quote the callSid. A replay with the same Idempotency-Key returns this answer and places nothing.

{
  "id": "vc_abc12345",
  "callSid": "abc12345def67890",
  "status": "unknown",
  "to": "+9613197119",
  "estimatedCost": 0.025,
  "message": "We could not confirm whether the call was queued. It may still be placed, so it was not tried again, and its charge is kept and recorded until it settles.",
  "createdAt": "2026-05-16T10:30:00Z"
}

Response 500, the call was not placed

The call could not be queued and was not placed. error.code is voice_enqueue_failed and error.message says what happened to the charge: returned (you were not charged), recorded and to be returned or reviewed, or neither, in which case it asks you to contact support and names the callSid to quote. A 500 is not stored against the Idempotency-Key, so a retry, even with the same key, runs the request again: a new charge and a new call. Retry only after reading error.message: retry when it says the charge was returned or recorded; when it asks you to contact support, do that first.

{
  "error": {
    "code": "voice_enqueue_failed",
    "message": "Could not enqueue the voice call. The call was not placed, and its charge could not be returned or recorded. Contact support and quote callSid abc12345def67890."
  }
}
GET /v1/voice/call/{callSid}

Poll a voice call for status, duration, and DTMF response. Returns the actual call cost once the call reaches a terminal state.

cURL

curl "https://api.cast.wayvzgroup.com/v1/voice/call/abc12345def67890" \
  -H "Authorization: Bearer sk_live_..."

Response

{
  "callSid": "abc12345def67890",
  "status": "completed",
  "to": "+9613197119",
  "duration": 42,
  "dtmfResponse": "1",
  "dtmfLabel": "Confirmed",
  "startedAt": "2026-05-16T10:30:00Z",
  "answeredAt": "2026-05-16T10:30:05Z",
  "completedAt": "2026-05-16T10:30:47Z",
  "cost": 0.035
}

Flows

GET /v1/flows

List the flows on your account. Optionally filter by status.

cURL

curl "https://api.cast.wayvzgroup.com/v1/flows?status=active" \
  -H "Authorization: Bearer sk_live_..."

Response

{
  "flows": [
    {
      "id": 42,
      "name": "Driver Notification",
      "status": "active",
      "nodeCount": 6,
      "enrollmentsLast24h": 487,
      "createdAt": "2026-05-01T00:00:00Z"
    }
  ],
  "total": 1
}
POST /v1/flows/{id}/trigger

Start a flow for a contact. The variables you pass become merge tags (like {{location}}) usable in every step of the flow, WhatsApp template variables, SMS bodies, voice TTS scripts. Idempotent on (flowId, phone) within a short window if you re-post with the same body.

Request body

{
  "phone": "+9613197119",
  "variables": {
    "location": "Hamra Street",
    "orderId": "4821",
    "driverName": "Ahmad"
  }
}

cURL

curl -X POST "https://api.cast.wayvzgroup.com/v1/flows/42/trigger" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+9613197119",
    "variables": { "location": "Hamra Street", "orderId": "4821" }
  }'

Response 201 Created

{
  "enrollmentId": 4821,
  "flowId": 42,
  "flowName": "Driver Notification",
  "phone": "+9613197119",
  "status": "active",
  "createdAt": "2026-05-16T10:30:00Z"
}
Use case. A dispatch system posts to this endpoint whenever a driver pickup is scheduled. The flow handles the multi-channel notification (WhatsApp first, voice fallback after 3 minutes, DTMF confirmation) without your code needing to know which channel was used.
GET /v1/flows/enrollments/{id}

Returns the enrollment header + step-by-step execution timeline. Each step shows what ran, when it ran, whether it succeeded, and channel-specific details (DTMF response for voice, delivery status for messages).

cURL

curl "https://api.cast.wayvzgroup.com/v1/flows/enrollments/4821" \
  -H "Authorization: Bearer sk_live_..."

Contacts

POST /v1/contacts

Create or update a contact in one call. Idempotent on phone, so it is safe to re-post the same body. Sets properties and adds the contact to one or more groups (groups are created automatically if they don’t already exist).

Request body

{
  "phone": "+9613197119",
  "firstName": "Ahmad",
  "lastName": "Al-Rashid",
  "email": "ahmad@example.com",
  "properties": {
    "role": "driver",
    "city": "Beirut",
    "vehicle": "motorcycle"
  },
  "groups": ["drivers-beirut"]
}

cURL

curl -X POST "https://api.cast.wayvzgroup.com/v1/contacts" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "phone": "+9613197119", "firstName": "Ahmad", "properties": { "role": "driver" } }'

Response

{
  "phone": "+9613197119",
  "firstName": "Ahmad",
  "lastName": "Al-Rashid",
  "created": false,
  "propertiesSet": 3,
  "groupsAdded": 1
}
GET /v1/contacts

Paginated list of contacts. Optional filters: group (by name), search (matches phone / first / last), page, pageSize (default 50, max 100).

cURL

curl "https://api.cast.wayvzgroup.com/v1/contacts?page=1&pageSize=50&group=drivers-beirut" \
  -H "Authorization: Bearer sk_live_..."
GET /v1/contacts/{phone}

Returns a single contact with all properties, groups, tags, and last-activity timestamp.

cURL

curl "https://api.cast.wayvzgroup.com/v1/contacts/+9613197119" \
  -H "Authorization: Bearer sk_live_..."
POST /v1/campaigns

Send a multi-recipient campaign on a single channel. Supports SMS and WhatsApp via this endpoint; for bulk voice campaigns, use /v1/voice/call per recipient or trigger a flow. Per-recipient variables let you personalize each message. A 201 means the messages were accepted, not delivered: failedCount covers refusals before queuing only, so read delivery from message status or webhooks.

One request is one campaign. On WhatsApp the returned campaignId names the campaign that appears on your Campaigns page, once at least one message was queued. Until the messages are dispatched you can cancel it from that page: queued messages stop, their charges are returned, and recipients the request has not reached yet are not queued.

Each WhatsApp message is charged when it is queued. If WhatsApp refuses the template or the number on the first attempt, that message ends as failed and its charge is returned, whether the refusal comes at the moment we send the message or in a failure WhatsApp reports afterwards, unless the message was already marked delivered or read. A provider error is not a refusal: the message is retried and the charge stays.

If WhatsApp or the voice provider does not answer at all, the message or call may have gone out. It ends with the status unknown, shown as Outcome unknown in your message history. It is not sent again and its charge stays.

Request body

{
  "channel": "sms",
  "name": "Driver alert batch",
  "from": "CAST",
  "recipients": ["+9613197119", "+9613197120"],
  "text": "Pickup at {{location}}",
  "variables": {
    "+9613197119": { "location": "Hamra" },
    "+9613197120": { "location": "Verdun" }
  }
}

cURL

curl -X POST "https://api.cast.wayvzgroup.com/v1/campaigns" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "channel": "sms", "name": "Promo", "from": "CAST",
        "recipients": ["+9613197119"], "text": "Hi from Cast" }'

Response 201 Created

{
  "campaignId": "camp_3f1c2b7e-9a44-4d1e-8b52-6d0f6a1c2e90",
  "recipientCount": 2,
  "estimatedCost": 0.04,
  "status": "queued"
}

Account

GET /v1/ping
Health check. Confirms connectivity and that your key is valid.
{
  "ok": true,
  "account_id": 1234,
  "now": "2026-05-01T11:00:00Z"
}
GET /v1/balance
Current wallet balance. Check this before high-volume sends to avoid mid-campaign failure from an empty wallet.
{
  "balance": 124.50,
  "currency": "USD"
}
GET /v1/account
Account profile, enabled channels, and rate limits.

rate_limits.requests_per_hour is the ceiling that applies to your key, or null if it has none, and rate_limits.window_seconds is the window. rate_limits.requests_per_second is deprecated: it holds the key's raw setting, 0 when unset, and is removed on or after 2027-05-01.

Audit log

GET /v1/audit-log
Your account's audit trail: sign-ins, team and role changes, API key creation, billing actions, settings changes and record access. Use it to keep your own copy of the trail, or to feed it to a system that collects security events.

Which keys can call it

Only a live key created with Can read the audit log ticked at /developers. The box is shown to people allowed to export the audit log (account owners and admins by default). Every other key, including every key created before this option existed and every sandbox key, gets 403 insufficient_scope. To give an integration audit access, create a new key for it.

Query parameters

limitint1 to 500, default 100. Values outside the range are clamped.
starting_afterstringThe next_cursor of the previous response. On asc a cursor that cannot be read is 400 invalid_cursor; on desc it is ignored and the list starts from the newest row.
orderstringdesc (newest first, the default) or asc (oldest first).
sincestringISO 8601, inclusive. A value with no offset is read as UTC.
untilstringISO 8601, exclusive.
categorystringExact match, for example Security, Team, Billing.
actionstringExact match, for example auth.login or apikey.create.
actorstringContains match on the acting user's sign-in name or display name.
entity_typestringExact match on the kind of record, for example api_key or contact.

Paging and polling

Pages are keyed on the row id. With order=desc, next_cursor is present while has_more is true. With order=asc, next_cursor is always present once there is a position: the last row of the page, or, on an empty page, the cursor you sent. So a poller keeps the last cursor it received and asks again later, and the next call returns the rows written since. On asc, rows still being saved are held back for five seconds. A page can hold fewer than limit rows when its rows are large; has_more says whether to ask again. Polling once a minute with limit=500 stays well inside the hourly rate limit. Reading the log does not add rows to it.

cURL example
curl "https://api.cast.wayvzgroup.com/v1/audit-log?order=asc&limit=500&starting_after=djE6MTEzNTk3"   -H "Authorization: Bearer sk_live_..."
Response
{
  "object": "list",
  "data": [
    {
      "id": 113598,
      "occurred_at": "2026-10-16T09:14:03.120Z",
      "category": "Security",
      "action": "auth.login",
      "actor": { "username": "amal@example.com", "name": "Amal", "role": "admin", "seat_id": 9651 },
      "entity": { "type": null, "id": null, "label": null },
      "detail": { "seat": 9651 },
      "ip": "203.0.113.9",
      "user_agent": "Mozilla/5.0 ..."
    }
  ],
  "count": 1,
  "limit": 500,
  "order": "asc",
  "has_more": false,
  "next_cursor": "djE6MTEzNTk4"
}

Personal data

Rows carry the acting user's email and name, their IP address and browser, and free text in entity.label and detail that can include a customer's name or phone number. It is the same data account admins already see on the audit log page. A detail longer than 32,000 characters is returned cut, as text, and the row carries "detail_truncated": true (the CSV export cuts it at the same length). Rows older than the account's audit retention, where it is switched on, are no longer returned. Timestamps are UTC.

Webhooks

Cast can POST a JSON event to your URL whenever something happens to a message. Configure endpoints at /developers/webhooks.

Note. Events are signed with your endpoint's secret and retried with backoff for 20 hours 35 minutes.

Every request has the same envelope: id, type, created (unix seconds), apiVersion and data. Ignore fields and values you do not recognise: Cast can add them at any time. The same list is at /v1/webhook-events as JSON. Deprecated fields are marked below.

Event types

message.delivered

A message reached the recipient's device.

Sent for SMS, WhatsApp and RCS.

On by default for a new endpoint.

Fields and sample
FieldTypeNotes
messageIdstringThe provider's id for the message.
smsLogIdstringCast's id for the message. Only on whatsapp, rcs.
tostringThe recipient.
channelstringsms, whatsapp or rcs.
templateNamestring, or nullThe template sent. Only on whatsapp.
templateLanguagestring, or nullThe template language. Only on whatsapp.
sentAtdatetime, or nullWhen Cast accepted the send. Only on whatsapp.
deliveredAtdatetime, or nullWhen it was delivered. Only on whatsapp.
readAtdatetime, or nullWhen it was read. Only on whatsapp.
failedAtdatetime, or nullWhen it failed. Only on whatsapp.
failureCodestring, or nullThe provider's error code. Only on whatsapp.
failureReasonstring, or nullThe provider's error text. Only on whatsapp.
statusstringdelivered, read or failed. Only on rcs.
occurredAtdatetimeWhen it happened. Only on rcs.
campaignIdstring, or nullThe campaign, when the message was part of one. Only on sms.
message_idstringUse messageId. Only on sms. Deprecated. Use messageId. Removed on or after 2027-05-01.
campaign_idstringUse campaignId. Only on sms. Deprecated. Use campaignId. Removed on or after 2027-05-01.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "message.delivered",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "messageId": "wamid.HBgLOTYxNzAxMjM0NTY3",
    "smsLogId": "12345",
    "to": "96170123456",
    "channel": "whatsapp",
    "templateName": "order_shipped",
    "templateLanguage": "en_US",
    "sentAt": "2026-05-01T10:59:58Z",
    "deliveredAt": "2026-05-01T11:00:05Z",
    "readAt": null,
    "failedAt": null,
    "failureCode": null,
    "failureReason": null
  }
}

message.read

The recipient opened a message.

Sent for WhatsApp, when the recipient has read receipts on, and for RCS.

On by default for a new endpoint.

Fields and sample
FieldTypeNotes
messageIdstringThe provider's id for the message.
smsLogIdstringCast's id for the message. Only on whatsapp, rcs.
tostringThe recipient.
channelstringsms, whatsapp or rcs.
templateNamestring, or nullThe template sent. Only on whatsapp.
templateLanguagestring, or nullThe template language. Only on whatsapp.
sentAtdatetime, or nullWhen Cast accepted the send. Only on whatsapp.
deliveredAtdatetime, or nullWhen it was delivered. Only on whatsapp.
readAtdatetime, or nullWhen it was read. Only on whatsapp.
failedAtdatetime, or nullWhen it failed. Only on whatsapp.
failureCodestring, or nullThe provider's error code. Only on whatsapp.
failureReasonstring, or nullThe provider's error text. Only on whatsapp.
statusstringdelivered, read or failed. Only on rcs.
occurredAtdatetimeWhen it happened. Only on rcs.
campaignIdstring, or nullThe campaign, when the message was part of one. Only on sms.
message_idstringUse messageId. Only on sms. Deprecated. Use messageId. Removed on or after 2027-05-01.
campaign_idstringUse campaignId. Only on sms. Deprecated. Use campaignId. Removed on or after 2027-05-01.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "message.read",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "messageId": "wamid.HBgLOTYxNzAxMjM0NTY3",
    "smsLogId": "12345",
    "to": "96170123456",
    "channel": "whatsapp",
    "templateName": "order_shipped",
    "templateLanguage": "en_US",
    "sentAt": "2026-05-01T10:59:58Z",
    "deliveredAt": "2026-05-01T11:00:05Z",
    "readAt": null,
    "failedAt": null,
    "failureCode": null,
    "failureReason": null
  }
}

message.failed

A message could not be delivered.

Sent for SMS, WhatsApp and RCS. WhatsApp carries failureCode and failureReason; SMS carries reason and code.

On by default for a new endpoint.

Fields and sample
FieldTypeNotes
messageIdstringThe provider's id for the message.
smsLogIdstringCast's id for the message. Only on whatsapp, rcs.
tostringThe recipient.
channelstringsms, whatsapp or rcs.
templateNamestring, or nullThe template sent. Only on whatsapp.
templateLanguagestring, or nullThe template language. Only on whatsapp.
sentAtdatetime, or nullWhen Cast accepted the send. Only on whatsapp.
deliveredAtdatetime, or nullWhen it was delivered. Only on whatsapp.
readAtdatetime, or nullWhen it was read. Only on whatsapp.
failedAtdatetime, or nullWhen it failed. Only on whatsapp.
failureCodestring, or nullThe provider's error code. Only on whatsapp.
failureReasonstring, or nullThe provider's error text. Only on whatsapp.
statusstringdelivered, read or failed. Only on rcs.
occurredAtdatetimeWhen it happened. Only on rcs.
campaignIdstring, or nullThe campaign, when the message was part of one. Only on sms.
message_idstringUse messageId. Only on sms. Deprecated. Use messageId. Removed on or after 2027-05-01.
campaign_idstringUse campaignId. Only on sms. Deprecated. Use campaignId. Removed on or after 2027-05-01.
reasonstringA normalised reason from a closed list, the same one as failure.reason on GET /v1/messages. Only on sms.
codestring, or nullThe carrier's own error code, when it sent one. Only on sms.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "message.failed",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "messageId": "wamid.HBgLOTYxNzAxMjM0NTY3",
    "smsLogId": "12345",
    "to": "96170123456",
    "channel": "whatsapp",
    "templateName": "order_shipped",
    "templateLanguage": "en_US",
    "sentAt": "2026-05-01T10:59:58Z",
    "deliveredAt": "2026-05-01T11:00:05Z",
    "readAt": null,
    "failedAt": null,
    "failureCode": null,
    "failureReason": null
  }
}

message.inbound

A customer sent a message to your WhatsApp number.

WhatsApp only.

Off until you tick it on an endpoint.

Fields and sample
FieldTypeNotes
messageIdstringThe provider's id for the message.
channelstringAlways whatsapp.
fromstringThe customer's number.
tostringYour WhatsApp phone number id.
typestringThe message type: text, image, audio and so on.
bodystring, or nullThe text, when the message has text.
isOptOutbooleanTrue when the message is an opt-out keyword.
isHelpRequestbooleanTrue when the message is a help keyword.
receivedAtdatetimeWhen Cast received it.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "message.inbound",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "messageId": "wamid.HBgLOTYxNzAxMjM0NTY3",
    "channel": "whatsapp",
    "from": "96170123456",
    "to": "102938475610293",
    "type": "text",
    "body": "Hello, I need help with my order",
    "isOptOut": false,
    "isHelpRequest": false,
    "receivedAt": "2026-05-01T11:00:05Z"
  }
}

conversation.status_changed

A conversation in the inbox was opened, set to pending, resolved or marked as spam.

A bulk action sends one event per conversation for its first 1,000 changes.

Off until you tick it on an endpoint.

Fields and sample
FieldTypeNotes
customerPhonestringThe conversation key: the phone for WhatsApp, channel:id for other channels.
channelstringThe conversation's channel.
statusintegerThe status number.
statusNamestringopen, pending, resolved or spam.
isPinnedbooleanTrue when the conversation is pinned.
changedByAgentIDinteger, or nullThe agent who changed it.
reasonstringagent, pin_changed, inbound_message or bulk.
changedAtdatetimeWhen it changed.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "conversation.status_changed",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "customerPhone": "96170123456",
    "channel": "whatsapp",
    "status": 2,
    "statusName": "resolved",
    "isPinned": false,
    "changedByAgentID": 17,
    "reason": "agent",
    "changedAt": "2026-05-01T11:00:05Z"
  }
}

conversation.assigned

A conversation in the inbox was assigned to an agent or unassigned.

A bulk action sends one event per conversation for its first 1,000 changes.

Off until you tick it on an endpoint.

Fields and sample
FieldTypeNotes
customerPhonestringThe conversation key: the phone for WhatsApp, channel:id for other channels.
channelstringThe conversation's channel.
actionstringassigned or unassigned.
agentIDinteger, or nullThe agent, null when unassigned.
agentNamestring, or nullThe agent's name.
byUserIDstring, or nullThe user who made the change.
sourcestringagent, routing, rebalance or bulk.
changedAtdatetimeWhen it changed.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "conversation.assigned",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "customerPhone": "96170123456",
    "channel": "whatsapp",
    "action": "assigned",
    "agentID": 17,
    "agentName": "Lina",
    "byUserID": "c0a80164-0000-4000-8000-000000000001",
    "source": "agent",
    "changedAt": "2026-05-01T11:00:05Z"
  }
}

note.added

An agent added an internal note to a conversation.

Off until you tick it on an endpoint.

Fields and sample
FieldTypeNotes
noteIDintegerThe note's id.
customerPhonestringThe conversation key: the phone for WhatsApp, channel:id for other channels.
channelstringThe conversation's channel.
agentIDinteger, or nullThe agent who wrote it.
bodystringThe note's text.
createdAtdatetimeWhen it was written.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "note.added",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "noteID": 981,
    "customerPhone": "96170123456",
    "channel": "whatsapp",
    "agentID": 17,
    "body": "Called about an invoice.",
    "createdAt": "2026-05-01T11:00:05Z"
  }
}

bulk.completed

An inbox bulk action finished, was cancelled or stopped.

Sent once per run to every endpoint subscribed to a conversation status or assignment event. A run past 1,000 changes sends no more per-conversation events; read perConversationEvents here and fetch anything else you need from the API.

Follows the conversation status and assignment events; it has no checkbox of its own.

Fields and sample
FieldTypeNotes
jobIdstringThe bulk run.
actionstringWhat the run did.
statusstringHow it ended.
totalintegerConversations in scope.
doneintegerConversations processed.
updatedintegerConversations changed.
skippedintegerConversations skipped.
failedintegerConversations that failed.
failReasonstring, or nullWhy the run stopped, when it did.
perConversationEventsintegerHow many per-conversation events the run sent.
perConversationLimitintegerThe most a run sends.
finishedAtdatetimeWhen it finished.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "bulk.completed",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "jobId": "7a1f4c1e-2b1d-4c1a-9e0b-3c1d2e4f5a6b",
    "action": "set_status",
    "status": "completed",
    "total": 1500,
    "done": 1500,
    "updated": 1480,
    "skipped": 20,
    "failed": 0,
    "failReason": null,
    "perConversationEvents": 1000,
    "perConversationLimit": 1000,
    "finishedAt": "2026-05-01T11:00:05Z"
  }
}

email.opened

A tracked email was opened.

Sent on every open, not only the first. Treat it as an estimate: mail clients and privacy proxies can load images without a person opening the email.

Off until you tick it on an endpoint.

Fields and sample
FieldTypeNotes
channelstringAlways email.
tostringThe recipient's address.
smsLogIdstring, or nullCast's id for the message. Null when the provider's report names only the address.
campaignIdstring, or nullThe campaign, when the message was part of one.
occurredAtdatetimeWhen Cast received the event.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "email.opened",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "channel": "email",
    "to": "lina@example.com",
    "smsLogId": "12345",
    "campaignId": "3f2b8c1e-6a0d-4b57-9a11-5f0c2e7d1a44",
    "occurredAt": "2026-05-01T11:00:05Z"
  }
}

email.clicked

A link in a tracked email was clicked.

Sent on every click. Security scanners can follow links before a person does.

Off until you tick it on an endpoint.

Fields and sample
FieldTypeNotes
channelstringAlways email.
tostringThe recipient's address.
smsLogIdstring, or nullCast's id for the message. Null when the provider's report names only the address.
campaignIdstring, or nullThe campaign, when the message was part of one.
occurredAtdatetimeWhen Cast received the event.
urlstringThe link's real destination.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "email.clicked",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "channel": "email",
    "to": "lina@example.com",
    "smsLogId": "12345",
    "campaignId": "3f2b8c1e-6a0d-4b57-9a11-5f0c2e7d1a44",
    "occurredAt": "2026-05-01T11:00:05Z",
    "url": "https://example.com/offer"
  }
}

email.bounced

An email could not be delivered and the address is now suppressed.

Sent for permanent bounces only, to the account that sent the message. A temporary bounce does not suppress the address and sends no event. An address that has been erased sends nothing. If the report cannot be tied to one message and more than one account has mailed the address, the address is suppressed and no event is sent.

Off until you tick it on an endpoint.

Fields and sample
FieldTypeNotes
channelstringAlways email.
tostringThe recipient's address.
smsLogIdstring, or nullCast's id for the message. Null when the provider's report names only the address.
campaignIdstring, or nullThe campaign, when the message was part of one.
occurredAtdatetimeWhen Cast received the event.
reasonstring, or nullWhat the provider reported.
bounceTypestring, or nullAlways hard today.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "email.bounced",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "channel": "email",
    "to": "lina@example.com",
    "smsLogId": "12345",
    "campaignId": "3f2b8c1e-6a0d-4b57-9a11-5f0c2e7d1a44",
    "occurredAt": "2026-05-01T11:00:05Z",
    "reason": "SES Permanent",
    "bounceType": "hard"
  }
}

email.complained

A recipient marked an email as spam and the address is now suppressed.

Sent to the account that sent the message. An address that has been erased sends nothing. If the report cannot be tied to one message and more than one account has mailed the address, the address is suppressed and no event is sent.

Off until you tick it on an endpoint.

Fields and sample
FieldTypeNotes
channelstringAlways email.
tostringThe recipient's address.
smsLogIdstring, or nullCast's id for the message. Null when the provider's report names only the address.
campaignIdstring, or nullThe campaign, when the message was part of one.
occurredAtdatetimeWhen Cast received the event.
reasonstring, or nullWhat the provider reported.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "email.complained",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "channel": "email",
    "to": "lina@example.com",
    "smsLogId": "12345",
    "campaignId": "3f2b8c1e-6a0d-4b57-9a11-5f0c2e7d1a44",
    "occurredAt": "2026-05-01T11:00:05Z",
    "reason": "SES Complaint"
  }
}

email.unsubscribed

A recipient unsubscribed from your email.

Sent when the link is followed or your email provider reports it. A second visit to the link sends it again. Nothing is sent for an address that has been erased.

Off until you tick it on an endpoint.

Fields and sample
FieldTypeNotes
channelstringAlways email.
tostringThe recipient's address.
smsLogIdstring, or nullCast's id for the message. Null when the provider's report names only the address.
campaignIdstring, or nullThe campaign, when the message was part of one.
occurredAtdatetimeWhen Cast received the event.
reasonstring, or nullHow they unsubscribed.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "email.unsubscribed",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "channel": "email",
    "to": "lina@example.com",
    "smsLogId": "12345",
    "campaignId": "3f2b8c1e-6a0d-4b57-9a11-5f0c2e7d1a44",
    "occurredAt": "2026-05-01T11:00:05Z",
    "reason": "one-click"
  }
}

conversion.recorded

A conversion was credited to a tracked click.

Sent to the account that owns the offer, for an approved conversion. A rejected one, a duplicate and a tracking pixel event send nothing.

Off until you tick it on an endpoint.

Fields and sample
FieldTypeNotes
conversionIdintegerCast's id for the conversion.
offerIdintegerThe offer it belongs to.
clickIdstringThe tracked click it is credited to.
eventTypestringThe conversion event, for example sale, lead, cta_click, form_submit or dcb.
statusstringThe conversion status.
payoutnumber, or nullThe payout.
currencystringThree-letter currency code.
convertedAtdatetimeWhen the conversion happened.
sourcestringdcb for a carrier billing subscription; postback for everything else: an advertiser's call, and the cta_click and form_submit events from a landing page.
campaignIdstring, or nullThe campaign behind the click.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "conversion.recorded",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "conversionId": 8123,
    "offerId": 42,
    "clickId": "9d6b1f3a-2c44-4e0a-8f51-1b2c3d4e5f60",
    "eventType": "sale",
    "status": "approved",
    "payout": 12.5,
    "currency": "USD",
    "convertedAt": "2026-05-01T11:00:05Z",
    "source": "postback",
    "campaignId": "3f2b8c1e-6a0d-4b57-9a11-5f0c2e7d1a44"
  }
}

conversion.reversed

A conversion was reversed, for example after a refund.

Sent to the account that owns the offer. A second reversal of the same conversion sends nothing.

Off until you tick it on an endpoint.

Fields and sample
FieldTypeNotes
conversionIdintegerCast's id for the conversion.
offerIdintegerThe offer it belongs to.
clickIdstringThe tracked click it is credited to.
eventTypestringThe conversion event, for example sale, lead, cta_click, form_submit or dcb.
statusstringThe conversion status.
payoutnumber, or nullThe payout.
currencystringThree-letter currency code.
convertedAtdatetimeWhen the conversion happened.
sourcestringdcb for a carrier billing subscription; postback for everything else: an advertiser's call, and the cta_click and form_submit events from a landing page.
campaignIdstring, or nullThe campaign behind the click.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "conversion.reversed",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "conversionId": 8123,
    "offerId": 42,
    "clickId": "9d6b1f3a-2c44-4e0a-8f51-1b2c3d4e5f60",
    "eventType": "sale",
    "status": "reversed",
    "payout": 12.5,
    "currency": "USD",
    "convertedAt": "2026-05-01T11:00:05Z",
    "source": "postback",
    "campaignId": "3f2b8c1e-6a0d-4b57-9a11-5f0c2e7d1a44"
  }
}

segment.alert

A segment crossed an alert rule, or came back.

Sent when a rule first fires, again after 24 hours if it is still firing, and once when it recovers. Only for rules that ask for the webhook.

Off until you tick it on an endpoint.

Fields and sample
FieldTypeNotes
segmentIdintegerThe segment.
segmentNamestringIts name.
rulestringsize_below, size_above or change_pct.
thresholdnumberThe rule's limit.
sizeintegerThe segment's size now.
statestringfiring, or recovered when it came back inside the limit.
windowHoursintegerThe rule's window.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "segment.alert",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "segmentId": 42,
    "segmentName": "VIP customers",
    "rule": "size_below",
    "threshold": 1000,
    "size": 900,
    "state": "firing",
    "windowHours": 24
  }
}

audit.batch

New entries from your account's audit log, up to 100 per request.

Turned on with the audit log stream option on an endpoint, not with the event list.

Turned on with the audit log stream option.

Fields and sample
FieldTypeNotes
prev_last_idinteger, or nullThe last audit id of the batch before this one.
first_idinteger, or nullThe first audit id in this batch.
last_idinteger, or nullThe last audit id in this batch.
countintegerHow many entries.
eventsobjectThe audit entries.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "audit.batch",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "prev_last_id": 113597,
    "first_id": 113602,
    "last_id": 113602,
    "count": 1,
    "events": []
  }
}

test

Sent when you press Send test on an endpoint.

Sent only by the Send test button.

Fields and sample
FieldTypeNotes
messagestringA fixed sentence.
testbooleanAlways true.
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "test",
  "created": 1777633205,
  "apiVersion": "v1",
  "data": {
    "message": "This is a Cast webhook test event. Your integration is working.",
    "test": true
  }
}

Signature verification

Every request includes a Cast-Signature header:

Cast-Signature: t=1746090000,v1=<hex>

Where v1 is hex(HMAC_SHA256(secret, t + "." + body)). To verify:

  1. Read the raw request body (do not parse first).
  2. Parse t and v1 from the header.
  3. Reject if t is more than 5 minutes old (replay protection).
  4. Compute hex(HMAC_SHA256(secret, t + "." + body)) and compare to v1 using constant-time comparison.
const crypto = require('crypto');

function verifyCastSignature(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(',').map(kv => kv.split('=')));
  const t = parts.t;
  const v1 = parts.v1;
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(t + '.' + rawBody)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(v1, 'hex'));
}
import hmac, hashlib, time

def verify_cast_signature(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split('=', 1) for p in header.split(','))
    t, v1 = parts.get('t'), parts.get('v1')
    if not t or not v1: return False
    if abs(time.time() - int(t)) > 300: return False

    expected = hmac.new(
        secret.encode(),
        f"{t}.{raw_body.decode()}".encode(),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, v1)
function verifyCastSignature(string $rawBody, string $header, string $secret): bool {
    parse_str(str_replace(',', '&', $header), $parts);
    if (empty($parts['t']) || empty($parts['v1'])) return false;
    if (abs(time() - intval($parts['t'])) > 300) return false;

    $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1']);
}

Retry policy

If your endpoint returns anything other than 2xx, Cast retries with exponential backoff:

  • +30 seconds after attempt 1
  • +5 minutes after attempt 2
  • +30 minutes after attempt 3
  • +2 hours after attempt 4
  • +6 hours after attempt 5
  • +12 hours after attempt 6
  • After 7 attempts (20 hours 35 minutes in all) the event is marked dead and leaves the retry queue.

Each request includes Cast-Event-Id, Cast-Event-Type, and Cast-Delivery-Attempt headers for deduplication and tracing.

Idempotency. The same Cast-Event-Id may arrive more than once on retries. Store the event ID and ignore duplicates.

Audit log stream (audit.batch)

Tick Send audit events to this endpoint on an endpoint at /developers/webhooks and Cast sends it the new entries of your audit log as they are written, in batches. It suits any system that accepts a signed HTTPS POST. Up to 3 endpoints per account can take the stream. The option, and changing the address of an endpoint that takes it, pausing, resuming or deleting it, and rotating its secret, are for people allowed to export the audit log (account owners and admins by default). Each of these is recorded in the audit log (audit.siem_enable, audit.siem_disable, audit.siem_destination_change). Turning the stream off, or deleting the endpoint, cancels the batches still waiting for it: nothing more is sent to it.

The stream starts from the moment it is turned on: earlier entries are not sent, and turning it off and on again, pausing and resuming the endpoint, or deleting the endpoint and adding a new one, starts again from that moment. Batches that were waiting from before a pause are not sent on resume. To fetch history, use GET /v1/audit-log. A batch usually arrives within a couple of minutes of the entry being written.

{
  "id": "evt_9b41d7...",
  "type": "audit.batch",
  "created": 1746354660,
  "data": {
    "prev_last_id": 113597,
    "first_id": 113602,
    "last_id": 113655,
    "count": 2,
    "events": [
      { "id": 113602, "occurred_at": "2026-05-04T10:30:12.417Z", "category": "Security", "action": "auth.login", ... },
      { "id": 113655, "occurred_at": "2026-05-04T10:30:40.090Z", "category": "Team", "action": "team.role_change", ... }
    ]
  }
}
FieldMeaning
data.eventsUp to 100 entries, oldest first, each in exactly the shape GET /v1/audit-log returns (a detail over 32,000 characters is cut and the entry carries "detail_truncated": true). A request body is at most 256 KB.
data.first_id, data.last_idThe ids of the first and last entry in this batch.
data.prev_last_idThe last_id of the batch sent before this one. For the first batch after the stream was turned on, the id of your newest entry at that moment (0 if the account had no entries yet).
data.countThe number of entries in events.

Signing and retries are the same as every other event: a Cast-Signature header over the raw body, Cast-Event-Type: audit.batch, and the retry schedule above (about 20 hours).

When an endpoint falls behind. While an endpoint that is keeping up has three batches waiting to be delivered, Cast pauses the stream and queues nothing new, so nothing is lost while it catches up. An endpoint that is failing (its oldest waiting batch has already failed once) does not pause the others. If every endpoint is failing, the stream pauses too. Once a batch gets through again, that endpoint's other waiting batches are retried straight away. What can still be lost: a batch the retry schedule gives up on (about 20 hours of failures), and, while another endpoint keeps the stream moving, new batches for a failing endpoint that already has 10 waiting. Both show up as a gap in the chain below.

Ordering and gaps. Delivery is at least once, and after a retry batches can arrive out of order. De-duplicate entries by id and sort them by id or occurred_at. Ids are unique but not consecutive (one sequence is shared by every Cast account), so use the chain to detect a gap: each batch's prev_last_id equals the last_id of the batch before it. If you see a prev_last_id you have no batch for, fetch the missing range with GET /v1/audit-log?order=asc&starting_after=.... Entries are held back for five seconds after they are written so that ones still being saved are not passed over; an entry that takes longer than that to be saved can be missed by the stream, so reconcile against the API by time range from time to time if you need every entry.

Send test on an endpoint that takes the stream sends a sample audit.batch with one entry whose action is audit.test, "test": true on the batch and the entry, and null chain ids. It is not part of the chain and is not written to your audit log.

Entries carry the same personal data as the audit log itself: the acting user's email and name, their IP address and browser, and free text that can include a customer's name or phone number. Point the stream only at a system you are allowed to store that data in.

HTTP status codes

200Read/list/upsert succeeded.
201Resource created (message accepted, event inserted).
204No content (CORS preflight response).
400Validation error. Body has error.code.
401Auth missing or invalid.
402Insufficient wallet balance, or your plan's active contact limit is reached.
403Account or API access disabled.
404Endpoint or resource not found.
405Method not allowed for this endpoint.
413Payload too large (contact properties or event payload).
429Hourly request ceiling exceeded; wait Retry-After seconds. See Rate limits.
500Internal error; safe to retry.

Error codes

HTTPcodeCause & fix
400invalid_jsonBody could not be parsed as JSON. Check syntax and Content-Type: application/json.
400missing_fieldA required field is absent or empty. The message names which field.
400missing_bodyPOST request had an empty body.
400missing_idMessage ID is required for this request.
400invalid_idMessage ID format is invalid.
400invalid_phoneThe phone query parameter is missing or contains fewer than 7 digits.
400invalid_languagelanguage must be 0 (GSM-7) or 1 (Unicode).
400sender_too_longSender ID must be 11 characters or fewer.
400invalid_scheduleschedule must be ISO 8601.
400invalid_property_keyProperty key fails the naming rules (start with letter, letters/digits/dot/underscore/hyphen, max 100 chars).
400no_propertiesAt least one property is required on a POST.
400too_many_propertiesMaximum 50 properties per request.
400invalid_event_typeEvent type must be domain.action format, lowercase, max 50 chars.
400invalid_payloadEvent payload could not be serialized as JSON.
400invalid_occurred_atoccurred_at must be a valid ISO 8601 datetime.
400invalid_orderorder must be asc or desc.
400invalid_since / invalid_untilThe value is not an ISO 8601 date-time.
400invalid_rangesince must be earlier than until.
400invalid_cursorOn an order=asc read, starting_after is not a cursor the API handed out. Pass the last next_cursor you received.
400invalid_actor / invalid_entity_typeThe filter is longer than 256 / 80 characters.
401authentication_requiredMissing Authorization header.
401invalid_api_keyKey not found. Check the key is copied in full.
401api_key_revokedKey was revoked. Generate a new one at /developers.
402insufficient_balanceWallet does not cover the request. Top up at /billing.
402mac_limit_reachedCreating a new contact would go past your plan's active contact limit. Updates to existing contacts still work. Upgrade at /billing.
403account_inactiveAccount is suspended. Every endpoint answers this until Cast lifts it. Your data is kept.
503sending_pausedCast has paused sending on this account. Nothing was sent. Anything already queued waits and goes out when sending resumes. Retry after the Retry-After header, or contact us.
402payment_holdThe account is on a payment hold. Sends and billed actions are refused, reads still work. Retry after the hold is cleared.
503sending_unavailableCast could not confirm the account can send. Nothing was sent. Retry shortly.
403account_not_foundAccount no longer exists.
403api_access_disabledAPI access is turned off for this account.
403insufficient_scopeThe key cannot read the audit log. Create a live key with audit access at /developers.
403ip_not_allowedThe request came from an address outside the account's API allowlist. The message names the address; ask an account admin to review the list.
403plan_requiredThe feature is not included in the account's plan.
404endpoint_not_foundThe resource query parameter does not match any endpoint.
405method_not_allowedWrong HTTP method. The error message states which methods are accepted.
413payload_too_largeProperties body exceeds 16 KB, or event payload exceeds 8 KB.
400template_not_foundThis WhatsApp template and language are not an approved template on the account, so nothing was sent or charged. If it was just approved, sync your WhatsApp templates and try again.
500internal_errorUnexpected server error. Safe to retry after a short delay.
503charge_unavailableA voice call was not placed because the charge could not be taken right now. This is not a balance problem. Retry after the seconds in Retry-After.

Conversion postback

A signed POST /api/postback.ashx from an advertiser records a conversion against a click. When the conversion carries money, the JSON answer says what was written:

FieldTypeMeaning
payoutnumberThe amount recorded for this conversion.
currencystringThree letter currency of payout.
payout_sourcestringpostback when the request sent the amount, offer_default when the offer's fixed amount applied because the request sent none.

The three fields are left out when no money was recorded. In the request, a missing or null payout can take the offer's fixed amount if the offer names that event as payable. An explicit 0 means no payout. A value that is not a number records no payout and never falls back to the offer.

Changelog

Added 2026-11-02 API 1.4.0

POST /v1/voice/call can answer 202 with status unknown, and its 500 voice_enqueue_failed answer is now in the contract.

A 202 means the call could not be confirmed as queued. It may still be placed, so it is not tried again and its charge stays until it settles. The body has the same fields as a queued call plus message, which says whether this was recorded and, if not, asks you to contact support with the callSid. Look the call up with GET /v1/voice/call/{callSid}. A replay with the same Idempotency-Key returns the same 202 and places nothing. This case used to answer 500 and say you were not charged. A 500 voice_enqueue_failed now means the call was not placed, and message says what happened to the charge. A 500 is not stored against the Idempotency-Key, so a retry with the same key runs the request again: read message before you retry. Nothing about a successful call changed.

Changed 2026-11-02 API 1.3.0

A WhatsApp failure reported after the send now returns its charge.

Applies to the same refusals as a refusal at the moment we send: a bad or unapproved template, an invalid number, a blocked recipient, on the first attempt. A message already marked delivered or read keeps its charge. The message stays failed.

Changed 2026-11-02 API 1.3.0

A voice call or queued WhatsApp message with no answer from the provider now ends as unknown.

It used to be retried, which could send it twice. It now ends with the status unknown, its charge stays, and it is not sent again. A voice call can still move on if the provider's status callback arrives. A provider error that is an answer, such as a 5xx or a 429, is retried as before.

Added 2026-11-02 API 1.3.0

Two refusals are now in the contract.

POST /v1/voice/call can answer 503 charge_unavailable when the charge could not be taken right now. Retry after the seconds in Retry-After. POST /v1/campaigns answers 400 template_not_found when the template is not approved on the account. Both are in the OpenAPI document. Nothing about a successful call changed.

Changed 2026-11-02 API 1.3.0

A WhatsApp message refused at the moment we send it now returns its charge.

Applies to queued template messages when WhatsApp refuses the template or the number on the first attempt. The message ends as failed and is not retried. A timeout or a provider error is not a refusal: the message is retried and the charge stays. A failure WhatsApp reports later, after it accepted the message, is not returned automatically. The response of the send is unchanged.

The full list and the versioning policy are at /developers/changelog.

Need something this page doesn’t cover? Email cast@wayvzgroup.com.