developers
ES Sign up

Guides

WhatsApp calls

Your customers can call you on WhatsApp at your WhatsApp Cloud number. heysteff logs every call (inbound or outbound, answered or missed, with its duration), leaves a note in the conversation and tells you by webhook. You can also send a button for the customer to call you and ask for permission to call them.

Turn on calls for a number#

PATCH /v1/whatsapp-numbers/{id}/calling (scope calls:write) turns calls on or off. {id} is the number's channel_connection_id. Whatever you leave out stays as it is at Meta, and Meta decides whether the number is eligible (if not, it returns 424 meta_error with the reason).

curl
curl -X PATCH https://app.heysteff.com/api/v1/whatsapp-numbers/3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d/calling \
  -H "Authorization: Bearer $HEYSTEFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "call_icon_visible": true,
    "callback_permission": true,
    "call_hours": {
      "timezone_id": "America/New_York",
      "weekly": [
        { "day": "MONDAY", "open": "0900", "close": "1800" },
        { "day": "TUESDAY", "open": "0900", "close": "1800" }
      ],
      "holidays": [{ "date": "2026-12-25", "start": "0000", "end": "2359" }]
    }
  }'
FieldWhat it is
enabledRequired. Turns the number's calls on or off.
call_icon_visibleShows or hides the call button in the chat.
callback_permissionWhether the customer can allow you to call them back.
call_hoursBusiness hours: timezone_id (IANA zone), weekly with up to 2 ranges per day in 24 h HHMM format, and optional holidays. null removes the hours (calls allowed any time).

GET /v1/whatsapp-numbers/{id}/calling (scope calls:read) returns the live settings from Meta, including sip_enabled. In the console, every WhatsApp Cloud number in the Numbers section has its call settings.

Meta requirement: for events to arrive, the number's Meta app must be subscribed to the calls webhook field. Calls do not work on coexistence numbers (the same number on the phone app) or on WhatsApp via QR.

Receive the events#

Each call notifies once per change with these webhooks. Subscribe to them when you create the webhook (or leave the webhook with no events to receive all of them).

EventWhen it arrives
call.startedA call started: it is ringing or was already answered.
call.endedA call that someone answered ended (with duration_seconds), or it failed (status: "failed", with error).
call.missedIt ended with nobody answering, or the person rejected it (status: "rejected").
JSON
{
  "event": "call.ended",
  "data": {
    "call_id": "7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
    "wa_call_id": "wacid.HBgLMTU1NTAxMDAxOTkVAgARGBI…",
    "channel_connection_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
    "conversation_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "contact_id": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
    "direction": "inbound",
    "status": "completed",
    "customer": "15550100123",
    "started_at": "2026-11-14T15:02:11.000Z",
    "ended_at": "2026-11-14T15:05:40.000Z",
    "duration_seconds": 209,
    "error": null
  }
}

Call log#

GET /v1/calls (scope calls:read) lists calls newest first, filtered by channel_connection_id, status (ringing, answered, completed, missed, rejected, failed) and direction (inbound, outbound). For the next page, send before with the response's next_before. In the console, the Calls section shows the same.

curl
curl "https://app.heysteff.com/api/v1/calls?status=missed&limit=20" \
  -H "Authorization: Bearer $HEYSTEFF_API_KEY"
TypeScript
const { data, next_before } = await heysteff.calls.list({ status: 'missed', limit: 20 });
await heysteff.whatsappNumbers.updateCalling(channelConnectionId, { enabled: true, call_hours: null });

Call button and call permission#

Within the 24-hour window, content_type: "voice_call" sends a message with a button for the customer to call you on WhatsApp. It does not place the call for you: the customer is the one who calls.

json
{
  "channel": "whatsapp_cloud",
  "to": "+15550100123",
  "content_type": "voice_call",
  "voice_call": {
    "body": "Rather talk it through? Call us on WhatsApp.",
    "display_text": "Call now",
    "ttl_minutes": 1440,
    "payload": "case-881"
  }
}

body is required (up to 1,024 characters); display_text, up to 20 characters (WhatsApp's text by default); ttl_minutes, 1 to 43,200 minutes (7 days by default); payload, up to 512 characters.

content_type: "call_permission_request" asks the customer for permission for your business to call them:

json
{
  "channel": "whatsapp_cloud",
  "to": "+15550100123",
  "content_type": "call_permission_request",
  "call_permission": { "body": "Can we call you on WhatsApp to arrange the delivery?" }
}

After 24 hours, use a template with a call button: create it with POST /v1/templates and a button {"type": "VOICE_CALL", "text": "Call", "ttl_minutes": 10080} (in a template, between 1 and 30 days), and send it with this component:

json
{
  "type": "button",
  "sub_type": "voice_call",
  "parameters": [
    { "type": "ttl_minutes", "ttl_minutes": 10080 },
    { "type": "payload", "payload": "case-881" }
  ]
}

All three are WhatsApp Cloud only (on another channel they return 422 whatsapp_cloud_only) and need an sk_live_ key (a test key gets 403 test_key_not_allowed). If Meta rejects the message, it ends up failed and is not replaced with text.

Limits#

What it does not do yet#

heysteff logs calls, but it does not answer them, reject them or carry the audio yet: it is not a media server. If you need to answer calls from your own system today, there are two paths with Meta, on your own:

Answering from heysteff (in the inbox and by API) is on the to-do list.