## Inbound FaceTime calls If you are building your own CRM or softphone, you can receive inbound FaceTime Audio calls on your lines, show a ring screen, and answer or decline from your own UI. Answering returns Agora credentials, so the same join code as outbound calls applies. > **FaceTime-enabled accounts only** > > These endpoints are available only on API accounts with FaceTime Audio enabled. Calls from accounts without the feature return 403 with error code `FACETIME_NOT_ENABLED` — that means the feature isn't active on your account, not that the API is down. Interested in FaceTime Audio? Contact [sales@tryprojectblue.com](mailto:sales@tryprojectblue.com) or reach out to support to upgrade. ### How it fits together 1. A `facetime.inbound.ringing` webhook arrives when a call rings on one of your lines. 2. Your UI shows a ring screen keyed on `call_uuid`. 3. You answer or decline via the API. 4. On answer you join Agora with the returned credentials. 5. `facetime.inbound.answered` and `facetime.inbound.ended` webhooks keep your UI in sync. 6. You hang up with [`/end-facetime-call-api`](https://api.tryprojectblue.com/#facetime-end). ### 1. Turn on call events for a webhook In the Project Blue app go to Settings > API Webhooks, click Add Webhook, tick `Inbound FaceTime calls`, then Test & Save. Existing webhooks cannot be edited, so create a new one or a second one to the same URL. The calls box is off by default, so existing message automations are not affected. ### 2. Handle the ringing event When an inbound FaceTime Audio call starts ringing on one of your lines, Project Blue POSTs `facetime.inbound.ringing`. Key your UI on `call_uuid`. A repeated ringing signal from the Mac produces one ringing webhook per call. - `event` ("facetime.inbound.ringing", required) — Fired when an inbound FaceTime Audio call starts ringing on one of your lines. - `call_uuid` (string, required) — Identifies the call. Key your UI on this value and pass it to answer or decline. - `direction` ("inbound", required) — Always inbound for this event. - `from` (string, required) — The caller in E.164. - `pb_line_id` (string (UUID), required) — The same UUID v4 lineId returned by GET /get-lines. - `linePhoneNumber` (string, required) — The customer line that was called, in E.164. - `ringingAt` (string (ISO 8601), required) — When the call started ringing, UTC. - `receivedAt` (string (ISO 8601), required) — When Project Blue emitted this webhook, UTC. `POST /answer-facetime-call-api` ### Request body - `call_uuid` (string, required) — The call_uuid from the facetime.inbound.ringing webhook. The call must be inbound and ringing. ### Response - `success` (boolean, required) — true when the device accepted the answer. - `call_uuid` (string, required) — Echo of the call you answered. - `agora` (object | null, required) — WebRTC credentials for joining the call audio. Comes from the Mac at answer time; if the Mac returns none, the service falls back to the credentials stored when the call started ringing. Can still be null on a 200. Check for null before joining. - `appId` (string, required) — Agora application id. - `channelName` (string, required) — Channel to join for this call. - `token` (string, required) — RTC token. Join promptly after answering. - `uid` (number, required) — The uid to join as. > **agora can be null on a 200** > > A `200` means the call was answered, not that you can hear it. `agora` comes from the Mac at answer time; if the Mac returns none, the service falls back to the credentials it stored when the call started ringing. It can still be `null` on a `200`. Check for it before touching the SDK. `POST /decline-facetime-call-api` ### Request body - `call_uuid` (string, required) — The call_uuid from the facetime.inbound.ringing webhook. The call must be inbound and ringing. If the caller already hung up between the webhook and the decline, the service treats already disconnected from the Mac as success. ### 3. Keep your UI in sync `facetime.inbound.answered` means someone else picked up. Dismiss the ring UI if you were not the one that answered. - `event` ("facetime.inbound.answered", required) — Fired when the call is answered by anyone: the API, the Project Blue mobile app, or the web app. - `call_uuid` (string, required) — Identifies the call. - `from` (string, required) — The caller in E.164. - `pb_line_id` (string (UUID), required) — The same UUID v4 lineId returned by GET /get-lines. - `linePhoneNumber` (string, required) — The customer line that was called, in E.164. - `answeredAt` (string (ISO 8601), required) — When the call was answered, UTC. - `receivedAt` (string (ISO 8601), required) — When Project Blue emitted this webhook, UTC. `facetime.inbound.ended` means the call is over. Tear down the ring UI and leave the Agora channel. - `event` ("facetime.inbound.ended", required) — Fired when the call reaches a terminal state. - `call_uuid` (string, required) — Identifies the call. - `status` ("ended" | "declined" | "no_answer" | "voicemail" | "busy" | "failed", required) — The terminal state. - `from` (string, required) — The caller in E.164. - `pb_line_id` (string (UUID), required) — The same UUID v4 lineId returned by GET /get-lines. - `linePhoneNumber` (string, required) — The customer line that was called, in E.164. - `answeredAt` (string (ISO 8601) | null, required) — When the call was answered, UTC. null when the call was never answered (declined, no_answer, voicemail, busy, failed). - `endedAt` (string (ISO 8601), required) — When the call reached a terminal state, UTC. - `receivedAt` (string (ISO 8601), required) — When Project Blue emitted this webhook, UTC. ### Events | Event | When it fires | Notes | | --- | --- | --- | | `facetime.inbound.ringing` | An inbound FaceTime Audio call starts ringing on one of your lines. | One ringing event per call, even if the Mac repeats the signal. Key your UI on call_uuid. | | `facetime.inbound.answered` | The call is answered by anyone (the API, the Project Blue mobile app, or the web app). | Dismiss your ring UI if you were not the one that answered. | | `facetime.inbound.ended` | The call reaches a terminal state. | status is one of ended, declined, no_answer, voicemail, busy, failed. answeredAt is null when the call was never answered. | > **No retries, so reconcile on miss** > > If your endpoint misses the ringing event, poll [`/get-facetime-call-status-api`](https://api.tryprojectblue.com/#facetime-status) for any `call_uuid` you learn about later. A call is answerable only while `call_status` is `ringing`. ### Status codes - **200** — Call answered / declined - **400** — Invalid or missing call_uuid - **401** — Missing or invalid API key - **403** — FACETIME_NOT_ENABLED - **404** — Call not found - **409** — Call is not an inbound call that is currently ringing - **429** — Rate limit exceeded - **502** — Device error - **503** — Device unreachable - **500** — Internal server error **Webhook events — Ringing** ```json { "event": "facetime.inbound.ringing", "call_uuid": "ftc_8a2b1c3d4e5f6g7h", "direction": "inbound", "from": "+15551234567", "pb_line_id": "a3f8c2d1-b4e9-4f2a-c8d3-e1f0a2b3c4d5", "linePhoneNumber": "+15559876543", "ringingAt": "2026-09-15T18:04:02.000Z", "receivedAt": "2026-09-15T18:04:02.310Z" } ``` **Webhook events — Answered** ```json { "event": "facetime.inbound.answered", "call_uuid": "ftc_8a2b1c3d4e5f6g7h", "from": "+15551234567", "pb_line_id": "a3f8c2d1-b4e9-4f2a-c8d3-e1f0a2b3c4d5", "linePhoneNumber": "+15559876543", "answeredAt": "2026-09-15T18:04:09.000Z", "receivedAt": "2026-09-15T18:04:09.280Z" } ``` **Webhook events — Ended** ```json { "event": "facetime.inbound.ended", "call_uuid": "ftc_8a2b1c3d4e5f6g7h", "status": "ended", "from": "+15551234567", "pb_line_id": "a3f8c2d1-b4e9-4f2a-c8d3-e1f0a2b3c4d5", "linePhoneNumber": "+15559876543", "answeredAt": "2026-09-15T18:04:09.000Z", "endedAt": "2026-09-15T18:07:41.000Z", "receivedAt": "2026-09-15T18:07:41.190Z" } ``` **Answer — cURL** ```bash curl -X POST https://api.tryprojectblue.com/answer-facetime-call-api \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "call_uuid": "ftc_8a2b1c3d4e5f6g7h" }' ``` **Answer — 200 OK** ```json { "success": true, "call_uuid": "ftc_8a2b1c3d4e5f6g7h", "agora": { "appId": "your-agora-app-id", "channelName": "facetime-channel-abc123", "token": "agora-rtc-token...", "uid": 123456 } } ``` **Answer — 409** Only a ringing inbound call can be answered. ```json { "error": "Call is not ringing" } ``` **Decline — cURL** ```bash curl -X POST https://api.tryprojectblue.com/decline-facetime-call-api \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "call_uuid": "ftc_8a2b1c3d4e5f6g7h" }' ``` **Decline — 200 OK** ```json { "success": true, "call_uuid": "ftc_8a2b1c3d4e5f6g7h" } ``` **Join the call — JavaScript** Tokens are time-limited — join promptly. Check res.agora is non-null first. ```javascript import AgoraRTC from "agora-rtc-sdk-ng"; const client = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" }); await client.join( res.agora.appId, res.agora.channelName, res.agora.token, res.agora.uid, ); const mic = await AgoraRTC.createMicrophoneAudioTrack(); await client.publish([mic]); client.on("user-published", async (user, mediaType) => { await client.subscribe(user, mediaType); if (mediaType === "audio") user.audioTrack.play(); }); ``` **End to end — Server (Node)** ```javascript // Your server. The API key never reaches the browser. const ringing = new Map(); app.post("/pb-webhook", (req, res) => { const payload = req.body; switch (payload.event) { case "facetime.inbound.ringing": ringing.set(payload.call_uuid, payload); broadcast("incoming", payload); // your channel to the browser break; case "facetime.inbound.answered": ringing.delete(payload.call_uuid); broadcast("answered", payload); break; case "facetime.inbound.ended": ringing.delete(payload.call_uuid); broadcast("ended", payload); break; } res.sendStatus(200); }); async function proxyCall(path, callUuid, res) { const upstream = await fetch("https://api.tryprojectblue.com/" + path, { method: "POST", headers: { Authorization: `Bearer ${process.env.PROJECT_BLUE_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ call_uuid: callUuid }), }); const body = await upstream.json().catch(() => ({ error: "Empty upstream response" })); res.status(upstream.status).json(body); } app.post("/calls/:callUuid/answer", (req, res) => proxyCall("answer-facetime-call-api", req.params.callUuid, res), ); app.post("/calls/:callUuid/decline", (req, res) => proxyCall("decline-facetime-call-api", req.params.callUuid, res), ); ``` **End to end — Browser** ```javascript import AgoraRTC from "agora-rtc-sdk-ng"; let client; let mic; onEvent("incoming", ({ from, call_uuid }) => showRing({ from, call_uuid })); document.querySelector("#answer").onclick = async () => { const res = await fetch("/calls/" + currentCallUuid() + "/answer", { method: "POST" }); const body = await res.json(); if (!res.ok) { showError(body.error); return; } // 409 when someone else answered first if (body.agora == null) { showNoAudio(); return; } // answered, but no audio credentials client = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" }); await client.join(body.agora.appId, body.agora.channelName, body.agora.token, body.agora.uid); mic = await AgoraRTC.createMicrophoneAudioTrack(); await client.publish([mic]); client.on("user-published", async (user, mediaType) => { await client.subscribe(user, mediaType); if (mediaType === "audio") user.audioTrack.play(); }); }; document.querySelector("#decline").onclick = async () => { await fetch("/calls/" + currentCallUuid() + "/decline", { method: "POST" }); }; onEvent("answered", () => hideRing()); onEvent("ended", async () => { hideRing(); if (client && mic) await client.unpublish([mic]); if (client) await client.leave(); }); ```