> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kapso.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Make outbound calls

> Check Calling permission and initiate a WhatsApp voice call through Kapso

## Check permission before dialing

Complete [Calling setup](/docs/whatsapp/calling/overview) and connect a working media server first. Inbound success does not prove outbound Calling is available for the number or recipient.

For a recipient identified by phone number, use the international number with country code, without `+` or separators:

```bash theme={null}
curl --fail-with-body --get \
  "https://api.kapso.ai/meta/whatsapp/$META_GRAPH_VERSION/$WHATSAPP_PHONE_NUMBER_ID/call_permissions" \
  -H "X-API-Key: $KAPSO_API_KEY" \
  --data-urlencode "user_wa_id=$RECIPIENT_PHONE_NUMBER"
```

For a user identified only by a [business-scoped user ID](/docs/whatsapp/business-scoped-user-ids), use `recipient=BSUID` instead of `user_wa_id` in the permission query, and `recipient` instead of `to` when dialing. Preserve that identity from the webhook.

Check the returned action, not just `permission.status`:

```javascript theme={null}
const canCall = permissionResponse.actions?.some(
  action => action.action_name === 'start_call' && action.can_perform_action === true
);
```

If false, do not dial. Have the recipient grant Calling permission through WhatsApp's supported permission flow, then check again. Limits can prevent a call even while a permission is present. Follow [Meta's permission documentation](https://developers.facebook.com/documentation/business-messaging/whatsapp/calling/user-call-permissions) for the current request flows and limits.

<Note>
  The TypeScript SDK uses camelCase fields: `actionName` and `canPerformAction`. The REST response uses snake\_case. See [SDK calls](/docs/whatsapp/typescript-sdk/calls).
</Note>

## Request permission in WhatsApp

During an open customer-service window, check that `send_call_permission_request` has `can_perform_action: true`, then send a free-form permission request:

```bash theme={null}
jq -n --arg to "$RECIPIENT_PHONE_NUMBER" \
  '{messaging_product:"whatsapp",to:$to,type:"interactive",interactive:{
    type:"call_permission_request",action:{name:"call_permission_request"},
    body:{text:"May we call you to confirm your appointment?"}
  }}' \
  | curl --fail-with-body \
    "https://api.kapso.ai/meta/whatsapp/$META_GRAPH_VERSION/$WHATSAPP_PHONE_NUMBER_ID/messages" \
    -H "X-API-Key: $KAPSO_API_KEY" \
    -H "Content-Type: application/json" \
    --data-binary @-
```

For BSUID-only users, use `recipient` instead of `to` in this message request too.

Permission replies arrive on the `messages` field as `interactive.call_permission_reply`; keep that handler alongside your Calling receiver. Match the reply using the message's `from` and/or `from_user_id`; a phone number can be omitted. Sending or reading the message does not grant permission. Wait for the recipient to approve it, then check `start_call` again. Outside the customer-service window, use the [template permission flow](https://developers.facebook.com/documentation/business-messaging/whatsapp/calling/user-call-permissions#free-form-vs-template-call-permission-request-message). Create a template through `/meta/whatsapp/{version}/{waba_id}/message_templates` with a contextual `BODY` and a `call_permission_request` component. After Meta approves it, send that template through the number's `/messages` endpoint. A normal quick-reply button does not grant Calling permission.

## Send a real SDP offer

Create a WebRTC peer connection with an audio sender and receiver. Generate and set its local SDP offer, including the ICE candidates needed by your deployment. Keep that connection alive while the call rings.

In this example, `offerSdp` is the offer produced by your media server, and `recipientPhoneNumber` is the recipient whose permission you just checked:

```javascript theme={null}
const attemptId = crypto.randomUUID();
// Persist attemptId with your pending media session before sending the request.
const response = await fetch(
  `https://api.kapso.ai/meta/whatsapp/${process.env.META_GRAPH_VERSION}/${process.env.WHATSAPP_PHONE_NUMBER_ID}/calls`,
  {
    method: 'POST',
    headers: { 'X-API-Key': process.env.KAPSO_API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      messaging_product: 'whatsapp',
      action: 'connect',
      to: recipientPhoneNumber,
      session: { sdp_type: 'offer', sdp: offerSdp },
      biz_opaque_callback_data: attemptId
    })
  }
);

if (!response.ok) throw new Error(`Calling failed: HTTP ${response.status}`);
const result = await response.json();
const callId = result.calls?.[0]?.id;
if (!callId) throw new Error('No call ID returned; check the provider outcome before retrying');
```

Store the call ID and associate it with your pending media session. A successful `connect` response means the request was accepted; it does not prove the recipient answered or that audio flows. Do not automatically retry a dial request after a timeout or uncertain response: it may already have created a paid call.

## Apply the answer webhook

Use the same signed Meta receiver as for inbound calls. Route by `direction` and SDP type:

| Event | Your action |
| - | - |
| `connect`, `USER_INITIATED`, SDP `offer` | Create an inbound session and send `pre_accept` / `accept`. |
| `connect`, `BUSINESS_INITIATED`, SDP `answer` | Find the pending outbound session by `call.id` and set its remote SDP answer. |
| `statuses[]`: `RINGING` | Keep the pending session silent. |
| `statuses[]`: `ACCEPTED` | Match `status.id` to the call ID, clear the ringing timeout, and greet when media is connected. |
| `statuses[]`: `REJECTED` | Release the pending session without starting an agent. |
| `statuses[]`: `COMPLETED` | Record the terminal outcome; this does not prove pickup. |
| `terminate` | Stop the matching session and release media resources. |

Do not treat an outbound answer as a new inbound offer. If an answer or status arrives before the dial response returns the call ID, buffer it briefly by call ID and apply it once the pending session is associated. Ignore duplicate or late events after termination. Keep a timeout for unanswered calls, then clean up the pending session; clear this timer on `ACCEPTED` so it cannot cut off an answered call.

The SDP answer can arrive while the handset is still ringing. Apply it to the pending connection, but wait for the matching call status `ACCEPTED` before starting the agent's greeting. Handle `REJECTED` and `terminate` without starting an agent.

For appointment confirmations, say which business is calling and why, ask whether now is a good time, and check the caller's actual booking before reading its details. End politely if it is the wrong recipient. A verbal confirmation is not an external calendar update unless your tool performs that update.

## Test outbound calls

Use a consenting test recipient and check these cases separately:

1. Revoke permission in WhatsApp. Confirm your application blocks dialing when `start_call` is unavailable. A deliberate API rejection test must be limited to the consenting test recipient.
2. Send a permission request, approve it on the handset, and check `start_call` again.
3. Answer a call. Verify two-way audio, the intended tool, and caller hangup.
4. Answer another call and let the agent end it. Verify your bridge sends `terminate` and releases the pipeline and media connection.
5. Decline a call, then leave another unanswered. Neither should start an agent; both must release the pending media connection.
6. Read each saved call through [List calls](/api/meta/whatsapp/calls/list-calls) and match it to the webhook sequence by call ID.

Do not use `COMPLETED` alone as proof of pickup. In handset tests, Meta also sent that terminal status for declined and unanswered calls, without start/end times or duration. Track `RINGING`, `ACCEPTED`, and `REJECTED` separately from termination, and retain the events needed to distinguish those outcomes.

## Billing and troubleshooting

Meta Calling permission, outbound country availability, and Kapso billing readiness are separate checks. A valid permission does not bypass an insufficient-balance or billing-readiness error from the proxy. Inbound call handling and termination use separate actions; do not remove the receiver to troubleshoot an outbound billing error.

Before enabling automated callbacks, verify a controlled test on the handset: ringing, answering, two-way audio, recipient identity, and termination. Log the call ID and action status without recording API keys or full credentials.

See [Call actions](/api/meta/whatsapp/calls/perform-call-action), [Call permission state](/api/meta/whatsapp/calls/get-call-permission-state), and [Meta business-initiated calls](https://developers.facebook.com/documentation/business-messaging/whatsapp/calling/business-initiated-calls).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.