docs: update NIP-AC with full implementation guidance

Major additions to the NIP specification:

- Gift wrap expiration: documented that outer gift wraps MUST
  include expiration tags so relays can garbage-collect signaling
- Event structures: added full JSON examples with all required
  fields (pubkey, id, sig, expiration, alt tags)
- ICE candidate buffering: documented that candidates MUST be
  buffered while ringing and NOT cleared on accept
- Staleness and deduplication: documented 20s staleness check
  and event ID dedup as spam prevention requirements
- NAT traversal: added TURN server guidance for same-WiFi and
  restrictive network fallback (~20% of cases)
- Audio routing: documented MODE_IN_COMMUNICATION, earpiece/
  speaker/Bluetooth SCO cycling, ringback tone, ringtone
- Platform integration: foreground service (microphone type),
  proximity wake lock, PiP mode, runtime permissions
- Error handling: SDP creation failures, ICE_CONNECTION_FAILED,
  session creation errors
- JSON escaping: noted that ICE candidate SDP strings MUST be
  properly escaped in the content JSON

https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
This commit is contained in:
Claude
2026-04-02 12:59:59 +00:00
parent b891bc7bb9
commit 117cb68b9d
@@ -8,7 +8,7 @@ WebRTC Calls
This NIP defines a protocol for establishing private peer-to-peer voice and video calls between Nostr This NIP defines a protocol for establishing private peer-to-peer voice and video calls between Nostr
users using WebRTC, with Nostr relays serving as the signaling transport and public STUN servers for users using WebRTC, with Nostr relays serving as the signaling transport and public STUN servers for
NAT traversal — no custom server infrastructure is required NAT traversal — no custom server infrastructure is required.
## Overview ## Overview
@@ -44,6 +44,8 @@ All signaling events MUST include:
|---------------|-------------------------------------------------------|----------| |---------------|-------------------------------------------------------|----------|
| `p` | Hex pubkey of the recipient | YES | | `p` | Hex pubkey of the recipient | YES |
| `call-id` | UUID identifying the call session | YES | | `call-id` | UUID identifying the call session | YES |
| `expiration` | Unix timestamp ([NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md)), SHOULD be `created_at + 20` seconds | YES |
| `alt` | Human-readable description ([NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md)) | YES |
Additional tags for **Call Offer** (kind 25050): Additional tags for **Call Offer** (kind 25050):
@@ -57,16 +59,21 @@ Additional tags for **Call Offer** (kind 25050):
The `content` field contains the SDP offer string. The `content` field contains the SDP offer string.
```yaml ```json
{ {
"kind": 25050, "kind": 25050,
"pubkey": "<caller-hex-pubkey>",
"created_at": 1234567890,
"content": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n...", "content": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n...",
"tags": [ "tags": [
["p", "<callee-hex-pubkey>"], ["p", "<callee-hex-pubkey>"],
["call-id", "550e8400-e29b-41d4-a716-446655440000"], ["call-id", "550e8400-e29b-41d4-a716-446655440000"],
["call-type", "video"] ["call-type", "video"],
["expiration", "1234567910"],
["alt", "WebRTC call offer"]
], ],
# other fields "id": "<event-id>",
"sig": "<signature>"
} }
``` ```
@@ -74,31 +81,41 @@ The `content` field contains the SDP offer string.
The `content` field contains the SDP answer string. The `content` field contains the SDP answer string.
```yaml ```json
{ {
"kind": 25051, "kind": 25051,
"pubkey": "<callee-hex-pubkey>",
"created_at": 1234567895,
"content": "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\n...", "content": "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\n...",
"tags": [ "tags": [
["p", "<caller-hex-pubkey>"], ["p", "<caller-hex-pubkey>"],
["call-id", "550e8400-e29b-41d4-a716-446655440000"], ["call-id", "550e8400-e29b-41d4-a716-446655440000"],
["expiration", "1234567915"],
["alt", "WebRTC call answer"]
], ],
# other fields "id": "<event-id>",
"sig": "<signature>"
} }
``` ```
### ICE Candidate (kind 25052) ### ICE Candidate (kind 25052)
The `content` field contains the ICE candidate as a JSON string with the fields `candidate`, `sdpMid`, and `sdpMLineIndex`. The `content` field contains the ICE candidate as a JSON string with the fields `candidate`, `sdpMid`, and `sdpMLineIndex`. Special characters in the SDP string MUST be properly JSON-escaped.
```yaml ```json
{ {
"kind": 25052, "kind": 25052,
"pubkey": "<sender-hex-pubkey>",
"created_at": 1234567896,
"content": "{\"candidate\":\"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0\",\"sdpMid\":\"0\",\"sdpMLineIndex\":0}", "content": "{\"candidate\":\"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0\",\"sdpMid\":\"0\",\"sdpMLineIndex\":0}",
"tags": [ "tags": [
["p", "<peer-hex-pubkey>"], ["p", "<peer-hex-pubkey>"],
["call-id", "550e8400-e29b-41d4-a716-446655440000"], ["call-id", "550e8400-e29b-41d4-a716-446655440000"],
["expiration", "1234567916"],
["alt", "WebRTC ICE candidate"]
], ],
# other fields "id": "<event-id>",
"sig": "<signature>"
} }
``` ```
@@ -106,15 +123,20 @@ The `content` field contains the ICE candidate as a JSON string with the fields
The `content` field MAY contain a human-readable reason or be empty. The `content` field MAY contain a human-readable reason or be empty.
```yaml ```json
{ {
"kind": 25053, "kind": 25053,
"pubkey": "<sender-hex-pubkey>",
"created_at": 1234568000,
"content": "", "content": "",
"tags": [ "tags": [
["p", "<peer-hex-pubkey>"], ["p", "<peer-hex-pubkey>"],
["call-id", "550e8400-e29b-41d4-a716-446655440000"], ["call-id", "550e8400-e29b-41d4-a716-446655440000"],
["expiration", "1234568020"],
["alt", "WebRTC call hangup"]
], ],
# other fields "id": "<event-id>",
"sig": "<signature>"
} }
``` ```
@@ -122,15 +144,20 @@ The `content` field MAY contain a human-readable reason or be empty.
The `content` field MAY contain a reason or be empty. The `content` field MAY contain a reason or be empty.
```yaml ```json
{ {
"kind": 25054, "kind": 25054,
"pubkey": "<callee-hex-pubkey>",
"created_at": 1234567893,
"content": "", "content": "",
"tags": [ "tags": [
["p", "<caller-hex-pubkey>"], ["p", "<caller-hex-pubkey>"],
["call-id", "550e8400-e29b-41d4-a716-446655440000"], ["call-id", "550e8400-e29b-41d4-a716-446655440000"],
["expiration", "1234567913"],
["alt", "WebRTC call rejection"]
], ],
# other fields "id": "<event-id>",
"sig": "<signature>"
} }
``` ```
@@ -138,15 +165,20 @@ The `content` field MAY contain a reason or be empty.
Used for mid-call changes such as toggling video on/off. The `content` field contains a new SDP offer. Used for mid-call changes such as toggling video on/off. The `content` field contains a new SDP offer.
```yaml ```json
{ {
"kind": 25055, "kind": 25055,
"pubkey": "<sender-hex-pubkey>",
"created_at": 1234568100,
"content": "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n...", "content": "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n...",
"tags": [ "tags": [
["p", "<peer-hex-pubkey>"], ["p", "<peer-hex-pubkey>"],
["call-id", "550e8400-e29b-41d4-a716-446655440000"], ["call-id", "550e8400-e29b-41d4-a716-446655440000"],
["expiration", "1234568120"],
["alt", "WebRTC call renegotiation"]
], ],
# other fields "id": "<event-id>",
"sig": "<signature>"
} }
``` ```
@@ -156,13 +188,15 @@ All signaling events MUST be delivered using [NIP-59](https://github.com/nostr-p
1. **Sign** the signaling event with the sender's key 1. **Sign** the signaling event with the sender's key
2. **Gift-wrap** the signed event directly using `GiftWrapEvent` (kind 1059) with NIP-44 encryption 2. **Gift-wrap** the signed event directly using `GiftWrapEvent` (kind 1059) with NIP-44 encryption
3. **Publish** the gift wrap to the recipient's relay list 3. **Set expiration on the gift wrap** the outer gift wrap MUST include an `expiration` tag matching the inner event's expiration (adjusted for the randomized `created_at`). This allows relays to garbage-collect ephemeral signaling data.
4. **Publish** the gift wrap to the recipient's relay list
The seal layer (`SealedRumorEvent`) is NOT used. The gift wrap already provides: The seal layer (`SealedRumorEvent`) is NOT used. The gift wrap already provides:
- **NIP-44 encryption** — content is unreadable to relay operators - **NIP-44 encryption** — content is unreadable to relay operators
- **Random ephemeral pubkey** — the relay cannot identify the sender - **Random ephemeral pubkey** — the relay cannot identify the sender
- **`p` tag** — reveals only the recipient (necessary for delivery) - **`p` tag** — reveals only the recipient (necessary for delivery)
- **`expiration` tag** — allows relays to delete the wrap after the signaling window
Recipients unwrap the gift, verify the inner event's signature against the sender's pubkey, and then process the signaling message. Recipients unwrap the gift, verify the inner event's signature against the sender's pubkey, and then process the signaling message.
@@ -188,6 +222,10 @@ Caller Relay Callee
| (relay no longer involved) | | (relay no longer involved) |
``` ```
### ICE Candidate Buffering
ICE candidates may arrive before the WebRTC peer connection is ready (e.g., the callee is still ringing). Clients MUST buffer incoming ICE candidates and apply them after `setRemoteDescription()` succeeds. Candidates buffered while ringing MUST NOT be cleared when accepting the call.
### Ending a Call ### Ending a Call
Either party may send a `CallHangup` (kind 25053) at any time. The recipient SHOULD close the WebRTC peer connection and release media resources upon receiving it. Either party may send a `CallHangup` (kind 25053) at any time. The recipient SHOULD close the WebRTC peer connection and release media resources upon receiving it.
@@ -201,25 +239,55 @@ The callee may send a `CallReject` (kind 25054) instead of a `CallAnswer`. The c
Clients SHOULD implement call filtering: Clients SHOULD implement call filtering:
- **Follow-gated ringing**: Only display incoming call notifications for users in the recipient's follow list. Calls from non-followed users SHOULD be silently ignored. - **Follow-gated ringing**: Only display incoming call notifications for users in the recipient's follow list. Calls from non-followed users SHOULD be silently ignored.
- **Staleness check**: Clients MUST discard signaling events older than the expiration window (20 seconds). This prevents old cached events from triggering phantom calls on app restart or relay reconnect.
- **Deduplication**: Clients MUST track processed event IDs to prevent the same signaling event (delivered by multiple relays) from being processed twice.
- **Rate limiting**: Clients SHOULD ignore duplicate call offers from the same pubkey within a short window. - **Rate limiting**: Clients SHOULD ignore duplicate call offers from the same pubkey within a short window.
- **Expiration enforcement**: Clients MUST check the `expiration` tag and discard signaling events that have expired.
## NAT Traversal ## NAT Traversal
This NIP does not mandate specific STUN or TURN servers. Clients SHOULD: This NIP does not mandate specific STUN or TURN servers. Clients SHOULD:
- Ship with a default set of public STUN servers (e.g., `stun:stun.l.google.com:19302`) - Ship with a default set of public STUN servers (e.g., `stun:stun.l.google.com:19302`)
- Ship with default TURN servers for relay fallback when direct P2P fails (~20% of cases, including devices on the same WiFi network where hairpin NAT is not supported)
- Allow users to configure custom TURN servers for restrictive network environments - Allow users to configure custom TURN servers for restrictive network environments
- Use trickle ICE (sending candidates as they are discovered) rather than waiting for all candidates before sending the offer/answer - Use trickle ICE (sending candidates as they are discovered) rather than waiting for all candidates before sending the offer/answer
- Use `GATHER_CONTINUALLY` policy for ongoing ICE candidate discovery
## Implementation Notes ## Implementation Notes
### Event Lifecycle
- The `call-id` tag MUST be a UUID that is unique per call session. All signaling events for the same call share the same `call-id`. - The `call-id` tag MUST be a UUID that is unique per call session. All signaling events for the same call share the same `call-id`.
- Events SHOULD have short expiration times (~20 seconds) since signaling data is ephemeral and has no long-term value. - Both inner events and outer gift wraps SHOULD have short expiration times (~20 seconds) since signaling data is ephemeral and has no long-term value. This allows relays to garbage-collect call signaling events.
- Clients SHOULD implement a ringing timeout (e.g., 60 seconds). If no answer is received, the call transitions to a "timed out" state. - Clients SHOULD implement a ringing timeout (e.g., 60 seconds). If no answer is received, the call transitions to a "timed out" state.
- Clients SHOULD use a foreground service or equivalent mechanism to keep calls active when the app is backgrounded. - After a call ends, the call state SHOULD auto-reset to idle after a brief display period (e.g., 2 seconds) to ensure the client is ready for subsequent calls.
### WebRTC Configuration
- The WebRTC `PeerConnection` SHOULD use Unified Plan SDP semantics. - The WebRTC `PeerConnection` SHOULD use Unified Plan SDP semantics.
- Clients MAY support call renegotiation (kind 25055) for toggling video on/off mid-call without tearing down the connection. - Clients MAY support call renegotiation (kind 25055) for toggling video on/off mid-call without tearing down the connection.
- ICE candidate JSON content MUST be properly escaped — SDP strings can contain quotes and backslashes that break naive string interpolation.
### Audio and Media
- Clients SHOULD switch `AudioManager` to `MODE_IN_COMMUNICATION` when a call connects and restore to `MODE_NORMAL` when the call ends.
- Clients SHOULD support audio routing between earpiece, speaker, and Bluetooth SCO headsets. If a Bluetooth headset disconnects mid-call, the client SHOULD fall back to earpiece automatically.
- Clients SHOULD play a ringback tone (e.g., `TONE_SUP_RINGTONE`) for the caller while waiting for the callee to answer.
- Clients SHOULD play the device's default ringtone and vibrate when an incoming call arrives from a followed user.
### Platform Integration
- Clients SHOULD use a foreground service (type `microphone`) to keep calls alive when the app is backgrounded.
- Clients SHOULD acquire a proximity wake lock during active calls to turn off the screen when held to the ear.
- Clients SHOULD keep the screen on during active calls.
- Clients MAY enter Picture-in-Picture mode when the user navigates away from the call screen during an active call.
- Clients SHOULD request `RECORD_AUDIO` permission (and `CAMERA` for video calls) at runtime before initiating or accepting a call.
### Error Handling
- If WebRTC session creation fails, the client SHOULD display an error to the user and transition to an ended state.
- If SDP offer/answer creation fails, the client SHOULD surface the error instead of hanging silently.
- Clients SHOULD handle `ICE_CONNECTION_FAILED` state by ending the call and notifying the user of a connection failure.
## References ## References