> ## 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.

# Receive calls

> Verify Calling webhooks and accept an inbound WhatsApp voice call

## Call lifecycle

Complete [Calling setup](/docs/whatsapp/calling/overview) first.

1. Receive a `calls` change with `event: "connect"`, `direction: "USER_INITIATED"`, and an SDP offer.
2. Create one media session for the call ID. Apply the offer and generate a real SDP answer.
3. Start the audio pipeline without speaking and send `pre_accept` with that answer.
4. Send `accept` when your media session is ready. Start the greeting only after `accept` succeeds and media is connected; `pre_accept` alone does not answer the call.
5. Handle `event: "terminate"` by stopping the pipeline and closing the media connection.

The webhook may contain multiple entries, changes, calls, and statuses. Iterate over all of them and check `value.metadata.phone_number_id` before routing. Call statuses such as `RINGING`, `ACCEPTED`, and `REJECTED` arrive in `value.statuses[]` on the `calls` field. Their `id` identifies the call; [outbound calls](/docs/whatsapp/calling/outbound-calls) depend on these updates.

```json theme={null}
{
  "object": "whatsapp_business_account",
  "entry": [{
    "changes": [{
      "field": "calls",
      "value": {
        "metadata": {"phone_number_id": "PHONE_NUMBER_ID"},
        "calls": [{
          "id": "wacid.EXAMPLE",
          "event": "connect",
          "direction": "USER_INITIATED",
          "from_user_id": "US.13491208655302741918",
          "session": {"sdp_type": "offer", "sdp": "GENERATED_SDP_OFFER"}
        }]
      }
    }]
  }]
}
```

This payload illustrates the structure; the SDP placeholder is not usable audio configuration. Preserve `from_user_id` when present. Do not require `from` to contain a phone number; see [business-scoped user IDs](/docs/whatsapp/business-scoped-user-ids).

## Verify the webhook

Kapso signs forwarded Meta webhooks with a **hex HMAC-SHA256** in `X-Webhook-Signature`, using your webhook's `secret_key`. Verify the exact request bytes before decoding JSON. This is not Meta's `X-Hub-Signature-256` scheme and does not use your Meta app secret.

This Express handler verifies and extracts events. Implement `enqueueCallEvent` and `enqueueOtherMetaChange` using your application's queue or router. They must durably accept events before you return `200`; do not start a conversation inside the receiver.

```javascript theme={null}
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = express();

app.post('/webhooks/whatsapp', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res, next) => {
  try {
    if (!Buffer.isBuffer(req.body)) return res.sendStatus(415);
    const signature = req.get('X-Webhook-Signature') ?? '';
    if (!/^[a-f0-9]{64}$/i.test(signature)) return res.sendStatus(401);

    const expected = createHmac('sha256', process.env.WHATSAPP_WEBHOOK_SECRET)
      .update(req.body).digest();
    if (!timingSafeEqual(expected, Buffer.from(signature, 'hex'))) {
      return res.sendStatus(401);
    }

    const payload = JSON.parse(req.body.toString('utf8'));
    const deliveryKey = req.get('X-Idempotency-Key');
    for (const [entryIndex, entry] of (payload.entry ?? []).entries()) {
      for (const [changeIndex, change] of (entry.changes ?? []).entries()) {
        const value = change.value;
        if (value?.metadata?.phone_number_id !== process.env.WHATSAPP_PHONE_NUMBER_ID) continue;
        const changeKey = deliveryKey ? `${deliveryKey}:${entryIndex}:${changeIndex}` : undefined;
        if (change.field !== 'calls') {
          // Preserve messaging and permission-reply handling on a shared receiver.
          await enqueueOtherMetaChange({ key: changeKey, change });
          continue;
        }
        for (const kind of ['calls', 'statuses']) {
          for (const [eventIndex, event] of (value[kind] ?? []).entries()) {
            await enqueueCallEvent({
              key: changeKey ? `${changeKey}:${kind}:${eventIndex}` : undefined, kind, event, value
            });
          }
        }
      }
    }
    res.sendStatus(200);
  } catch (error) {
    next(error);
  }
});
```

Register this raw-body route before a global JSON parser. Return `200` within the 10-second webhook timeout; session startup and the conversation run outside that HTTP request. Preserve existing handling for other Meta fields if you share the receiver with messaging. If normal messages are already processed from a Kapso-format webhook, avoid processing them again from the Meta receiver; still handle `interactive.call_permission_reply` here.

## Pre-accept and accept

Use `X-API-Key` for both actions. `answer.sdp` must come from the WebRTC session that applied this call's offer.

```javascript theme={null}
async function callAction(callId, action, answerSdp) {
  const body = { messaging_product: 'whatsapp', call_id: callId, action };
  if (answerSdp !== undefined) {
    body.session = { sdp_type: 'answer', sdp: answerSdp };
  }

  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(body)
    }
  );
  const text = await response.text();
  if (!response.ok) throw new Error(`Call action failed: HTTP ${response.status}: ${text.slice(0, 300)}`);
  const result = JSON.parse(text);
  if (result.success !== true) throw new Error('Call action was not confirmed');
  return result;
}

// In your call worker, after preparing the media session:
await callAction(call.id, 'pre_accept', answer.sdp);
await callAction(call.id, 'accept', answer.sdp);
```

For the SDK equivalents, see [Calls](/docs/whatsapp/typescript-sdk/calls). Your media server generates `answer.sdp`; see [connecting a voice agent](/docs/whatsapp/calling/voice-agents).

## End or reject a call

```javascript theme={null}
// To decline an unanswered call:
await callAction(call.id, 'reject');

// In a separate path, to end a call from your application:
await callAction(call.id, 'terminate');
```

When the caller hangs up, handle their `terminate` event without starting another session or attempting to accept the call. When your agent ends a call, finish its farewell audio, send `terminate`, and close the media connection. Ending an AI pipeline alone does not end the WhatsApp call.

Deduplicate each queued event using its delivery key plus payload position, as in the receiver above. Separately, guard session creation using the Meta call ID (`call.id` or `status.id`). Keep a terminal call state so a late or repeated `connect` cannot restart a terminated call. With multiple workers, use shared state and route subsequent events to the worker that owns the live media session.

If the delivery header is missing, skip delivery-key deduplication rather than creating keys that collide across requests. The per-call session and terminal-state guards still apply.

The delivery key belongs to the whole webhook body. If you enqueue events separately, combine it with each event's position in the payload; using the header alone as a unique queue key would discard other calls in the same delivery.


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