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.
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.
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
| Field | Type | Notes |
|---|
messageId | string | The provider's id for the message. |
smsLogId | string | Cast's id for the message. Only on whatsapp, rcs. |
to | string | The recipient. |
channel | string | sms, whatsapp or rcs. |
templateName | string, or null | The template sent. Only on whatsapp. |
templateLanguage | string, or null | The template language. Only on whatsapp. |
sentAt | datetime, or null | When Cast accepted the send. Only on whatsapp. |
deliveredAt | datetime, or null | When it was delivered. Only on whatsapp. |
readAt | datetime, or null | When it was read. Only on whatsapp. |
failedAt | datetime, or null | When it failed. Only on whatsapp. |
failureCode | string, or null | The provider's error code. Only on whatsapp. |
failureReason | string, or null | The provider's error text. Only on whatsapp. |
status | string | delivered, read or failed. Only on rcs. |
occurredAt | datetime | When it happened. Only on rcs. |
campaignId | string, or null | The campaign, when the message was part of one. Only on sms. |
message_id | string | Use messageId. Only on sms. Deprecated. Use messageId. Removed on or after 2027-05-01. |
campaign_id | string | Use 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
| Field | Type | Notes |
|---|
messageId | string | The provider's id for the message. |
smsLogId | string | Cast's id for the message. Only on whatsapp, rcs. |
to | string | The recipient. |
channel | string | sms, whatsapp or rcs. |
templateName | string, or null | The template sent. Only on whatsapp. |
templateLanguage | string, or null | The template language. Only on whatsapp. |
sentAt | datetime, or null | When Cast accepted the send. Only on whatsapp. |
deliveredAt | datetime, or null | When it was delivered. Only on whatsapp. |
readAt | datetime, or null | When it was read. Only on whatsapp. |
failedAt | datetime, or null | When it failed. Only on whatsapp. |
failureCode | string, or null | The provider's error code. Only on whatsapp. |
failureReason | string, or null | The provider's error text. Only on whatsapp. |
status | string | delivered, read or failed. Only on rcs. |
occurredAt | datetime | When it happened. Only on rcs. |
campaignId | string, or null | The campaign, when the message was part of one. Only on sms. |
message_id | string | Use messageId. Only on sms. Deprecated. Use messageId. Removed on or after 2027-05-01. |
campaign_id | string | Use 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
| Field | Type | Notes |
|---|
messageId | string | The provider's id for the message. |
smsLogId | string | Cast's id for the message. Only on whatsapp, rcs. |
to | string | The recipient. |
channel | string | sms, whatsapp or rcs. |
templateName | string, or null | The template sent. Only on whatsapp. |
templateLanguage | string, or null | The template language. Only on whatsapp. |
sentAt | datetime, or null | When Cast accepted the send. Only on whatsapp. |
deliveredAt | datetime, or null | When it was delivered. Only on whatsapp. |
readAt | datetime, or null | When it was read. Only on whatsapp. |
failedAt | datetime, or null | When it failed. Only on whatsapp. |
failureCode | string, or null | The provider's error code. Only on whatsapp. |
failureReason | string, or null | The provider's error text. Only on whatsapp. |
status | string | delivered, read or failed. Only on rcs. |
occurredAt | datetime | When it happened. Only on rcs. |
campaignId | string, or null | The campaign, when the message was part of one. Only on sms. |
message_id | string | Use messageId. Only on sms. Deprecated. Use messageId. Removed on or after 2027-05-01. |
campaign_id | string | Use campaignId. Only on sms. Deprecated. Use campaignId. Removed on or after 2027-05-01. |
reason | string | A normalised reason from a closed list, the same one as failure.reason on GET /v1/messages. Only on sms. |
code | string, or null | The 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
| Field | Type | Notes |
|---|
messageId | string | The provider's id for the message. |
channel | string | Always whatsapp. |
from | string | The customer's number. |
to | string | Your WhatsApp phone number id. |
type | string | The message type: text, image, audio and so on. |
body | string, or null | The text, when the message has text. |
isOptOut | boolean | True when the message is an opt-out keyword. |
isHelpRequest | boolean | True when the message is a help keyword. |
receivedAt | datetime | When 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
| Field | Type | Notes |
|---|
customerPhone | string | The conversation key: the phone for WhatsApp, channel:id for other channels. |
channel | string | The conversation's channel. |
status | integer | The status number. |
statusName | string | open, pending, resolved or spam. |
isPinned | boolean | True when the conversation is pinned. |
changedByAgentID | integer, or null | The agent who changed it. |
reason | string | agent, pin_changed, inbound_message or bulk. |
changedAt | datetime | When 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
| Field | Type | Notes |
|---|
customerPhone | string | The conversation key: the phone for WhatsApp, channel:id for other channels. |
channel | string | The conversation's channel. |
action | string | assigned or unassigned. |
agentID | integer, or null | The agent, null when unassigned. |
agentName | string, or null | The agent's name. |
byUserID | string, or null | The user who made the change. |
source | string | agent, routing, rebalance or bulk. |
changedAt | datetime | When 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
| Field | Type | Notes |
|---|
noteID | integer | The note's id. |
customerPhone | string | The conversation key: the phone for WhatsApp, channel:id for other channels. |
channel | string | The conversation's channel. |
agentID | integer, or null | The agent who wrote it. |
body | string | The note's text. |
createdAt | datetime | When 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
| Field | Type | Notes |
|---|
jobId | string | The bulk run. |
action | string | What the run did. |
status | string | How it ended. |
total | integer | Conversations in scope. |
done | integer | Conversations processed. |
updated | integer | Conversations changed. |
skipped | integer | Conversations skipped. |
failed | integer | Conversations that failed. |
failReason | string, or null | Why the run stopped, when it did. |
perConversationEvents | integer | How many per-conversation events the run sent. |
perConversationLimit | integer | The most a run sends. |
finishedAt | datetime | When 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
| Field | Type | Notes |
|---|
channel | string | Always email. |
to | string | The recipient's address. |
smsLogId | string, or null | Cast's id for the message. Null when the provider's report names only the address. |
campaignId | string, or null | The campaign, when the message was part of one. |
occurredAt | datetime | When 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
| Field | Type | Notes |
|---|
channel | string | Always email. |
to | string | The recipient's address. |
smsLogId | string, or null | Cast's id for the message. Null when the provider's report names only the address. |
campaignId | string, or null | The campaign, when the message was part of one. |
occurredAt | datetime | When Cast received the event. |
url | string | The 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
| Field | Type | Notes |
|---|
channel | string | Always email. |
to | string | The recipient's address. |
smsLogId | string, or null | Cast's id for the message. Null when the provider's report names only the address. |
campaignId | string, or null | The campaign, when the message was part of one. |
occurredAt | datetime | When Cast received the event. |
reason | string, or null | What the provider reported. |
bounceType | string, or null | Always 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
| Field | Type | Notes |
|---|
channel | string | Always email. |
to | string | The recipient's address. |
smsLogId | string, or null | Cast's id for the message. Null when the provider's report names only the address. |
campaignId | string, or null | The campaign, when the message was part of one. |
occurredAt | datetime | When Cast received the event. |
reason | string, or null | What 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
| Field | Type | Notes |
|---|
channel | string | Always email. |
to | string | The recipient's address. |
smsLogId | string, or null | Cast's id for the message. Null when the provider's report names only the address. |
campaignId | string, or null | The campaign, when the message was part of one. |
occurredAt | datetime | When Cast received the event. |
reason | string, or null | How 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
| Field | Type | Notes |
|---|
conversionId | integer | Cast's id for the conversion. |
offerId | integer | The offer it belongs to. |
clickId | string | The tracked click it is credited to. |
eventType | string | The conversion event, for example sale, lead, cta_click, form_submit or dcb. |
status | string | The conversion status. |
payout | number, or null | The payout. |
currency | string | Three-letter currency code. |
convertedAt | datetime | When the conversion happened. |
source | string | dcb 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. |
campaignId | string, or null | The 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
| Field | Type | Notes |
|---|
conversionId | integer | Cast's id for the conversion. |
offerId | integer | The offer it belongs to. |
clickId | string | The tracked click it is credited to. |
eventType | string | The conversion event, for example sale, lead, cta_click, form_submit or dcb. |
status | string | The conversion status. |
payout | number, or null | The payout. |
currency | string | Three-letter currency code. |
convertedAt | datetime | When the conversion happened. |
source | string | dcb 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. |
campaignId | string, or null | The 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
| Field | Type | Notes |
|---|
segmentId | integer | The segment. |
segmentName | string | Its name. |
rule | string | size_below, size_above or change_pct. |
threshold | number | The rule's limit. |
size | integer | The segment's size now. |
state | string | firing, or recovered when it came back inside the limit. |
windowHours | integer | The 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
| Field | Type | Notes |
|---|
prev_last_id | integer, or null | The last audit id of the batch before this one. |
first_id | integer, or null | The first audit id in this batch. |
last_id | integer, or null | The last audit id in this batch. |
count | integer | How many entries. |
events | object | The 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
| Field | Type | Notes |
|---|
message | string | A fixed sentence. |
test | boolean | Always 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:
- Read the raw request body (do not parse first).
- Parse
t and v1 from the header.
- Reject if
t is more than 5 minutes old (replay protection).
- 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", ... }
]
}
}
| Field | Meaning |
data.events | Up 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_id | The ids of the first and last entry in this batch. |
data.prev_last_id | The 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.count | The 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.