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

# WS Signaling Proxy

> Proxy WebSocket signaling for a white-labeled integration while keeping WebRTC direct

Your platform runs a WebSocket proxy that sits between the end user and Decart for the control plane (signaling, prompts, session events). Media (video and audio) flows directly between the end user and Decart over WebRTC — your proxy never touches it.

This gives you a white-labeled experience with full media quality and visibility into all control messages.

## When to use this path

* Your platform already runs WebSocket infrastructure
* You want full visibility into the control plane (prompts, images, session events)
* You want white-labeled endpoints — end users never see Decart

<Tip>
  If your platform is HTTP-native and you'd prefer not to run stateful WebSocket connections, see [HTTP Signaling](/integrations/signaling-proxy-http) — same quality, same white-label, but with stateless HTTP proxy instead.
</Tip>

## Characteristics

| Property            | Value                                                     |
| ------------------- | --------------------------------------------------------- |
| White-label         | Yes — end users only see your WS URL                      |
| Frame access        | No — media bypasses your proxy                            |
| Provider visibility | Full — you see every signaling message, prompt, and image |
| Client requirements | Browser or native app with WebRTC (no Decart SDK needed)  |
| Your infrastructure | WebSocket proxy server                                    |

<Check>
  Media quality is identical to using Decart directly. Your WS proxy only handles signaling — video and audio flow peer-to-peer.
</Check>

## Reference implementation

<Card title="ws-signaling-proxy" icon="github" href="https://github.com/DecartAI/sdk/tree/main/examples/ws-signaling-proxy" horizontal>
  A complete working proxy in \~200 lines of TypeScript — message buffering, close-code sanitization, structured logging, and an e2e test.
</Card>

## Architecture

```mermaid theme={null}
sequenceDiagram
    participant U as End User
    participant P as Your WS Proxy
    participant D as Decart

    U->>P: WS connect
    P->>P: Authenticate user
    P->>D: WS connect (your API key)

    rect rgb(240, 245, 255)
    Note over U,D: WebRTC Signaling (proxied)
    U->>P: offer SDP
    P->>D: forward
    D-->>P: answer SDP
    P-->>U: answer SDP
    U->>P: ICE candidates
    P->>D: forward
    D-->>P: ICE candidates
    P-->>U: ICE candidates
    end

    rect rgb(240, 255, 240)
    Note over U,D: Control Messages (proxied)
    U->>P: prompt
    P->>P: Log / modify
    P->>D: forward
    D-->>P: prompt_ack
    P-->>U: prompt_ack
    D-->>P: generation_tick
    P-->>U: generation_tick
    D-->>P: generation_ended
    P-->>U: generation_ended
    end

    Note over U,D: WebRTC media flows directly (P2P)<br/>Video & audio bypass your proxy entirely
    U->>D: WebRTC media (direct via IP:port)
    D->>U: WebRTC media (direct via IP:port)
```

## How it works

<Steps>
  <Step title="End user connects to your WebSocket endpoint">
    The end user connects to your platform's WS URL — they never see a Decart endpoint.

    ```
    wss://api.yourplatform.com/v1/realtime?model=lucy-2.1&token=user_token
    ```
  </Step>

  <Step title="Your proxy opens a connection to Decart">
    Authenticate with Decart using your platform API key and forward the model selection.

    ```python theme={null}
    import websockets

    upstream = await websockets.connect(
        f"wss://api3.decart.ai/v1/stream?api_key={DECART_API_KEY}&model={model}"
    )
    ```

    <Info>
      Your proxy connects to Decart with **your** API key.
    </Info>
  </Step>

  <Step title="Forward messages bidirectionally">
    Pump JSON messages between the client and Decart. Every message has a `type` field. You can log, validate, or modify messages as they pass through — see the [message reference](#message-reference) for the full protocol.

    ```python theme={null}
    import asyncio
    import json

    async def client_to_upstream(client_ws, upstream_ws):
        async for raw_msg in client_ws:
            msg = json.loads(raw_msg)

            if msg["type"] == "prompt":
                log_prompt(msg["prompt"])
                # Optional: validate or modify before forwarding

            elif msg["type"] == "set_image":
                log_image_upload()
                # Optional: validate image size / content

            await upstream_ws.send(raw_msg)

    async def upstream_to_client(upstream_ws, client_ws):
        async for raw_msg in upstream_ws:
            msg = json.loads(raw_msg)

            if msg["type"] == "session_id":
                log_session(msg["session_id"])

            elif msg["type"] == "prompt_ack" and not msg["success"]:
                log_moderation_rejection(msg["error"])

            elif msg["type"] == "generation_ended":
                log_session_end(msg["seconds"], msg["reason"])

            await client_ws.send(raw_msg)

    await asyncio.gather(
        client_to_upstream(client_ws, upstream),
        upstream_to_client(upstream, client_ws),
    )
    ```
  </Step>

  <Step title="WebRTC connects directly">
    When the end user's browser receives the SDP answer and ICE candidates through your proxy, it establishes a direct WebRTC connection to Decart via the IP:port in the ICE candidates. Your proxy is not involved in media.
  </Step>

  <Step title="Handle disconnects gracefully">
    When either side disconnects, close the other connection. Your proxy should handle both directions:

    ```python theme={null}
    async def proxy_session(client_ws, model):
        upstream = await websockets.connect(
            f"wss://api3.decart.ai/v1/stream?api_key={DECART_API_KEY}&model={model}"
        )
        try:
            await asyncio.gather(
                client_to_upstream(client_ws, upstream),
                upstream_to_client(upstream, client_ws),
            )
        except websockets.ConnectionClosed:
            pass
        finally:
            await upstream.close()
    ```
  </Step>
</Steps>

## Message reference

Your proxy forwards JSON messages between the client and Decart. Every message has a `type` field that identifies it.

<Tabs>
  <Tab title="Client → Decart">
    Messages your proxy receives from the client and forwards to Decart.

    <AccordionGroup>
      <Accordion title="offer — SDP offer to establish WebRTC">
        ```json theme={null}
        { "type": "offer", "sdp": "v=0\r\no=- 123 2 IN IP4 127.0.0.1\r\n..." }
        ```

        The `sdp` field is a string containing the full SDP (same format as `RTCSessionDescription.sdp`). Forward as-is.
      </Accordion>

      <Accordion title="ice-candidate — ICE candidate for connectivity">
        ```json theme={null}
        {
          "type": "ice-candidate",
          "candidate": {
            "candidate": "candidate:1 1 UDP 2130706431 192.168.1.5 54321 typ host",
            "sdpMLineIndex": 0,
            "sdpMid": "0",
            "usernameFragment": "ab12"
          }
        }
        ```

        Forward as-is. The candidate format matches `RTCIceCandidate.toJSON()` — `usernameFragment` is optional and may be present depending on the browser. Signal end-of-candidates by sending `null`:

        ```json theme={null}
        { "type": "ice-candidate", "candidate": null }
        ```
      </Accordion>

      <Accordion title="prompt — Text prompt for the model">
        ```json theme={null}
        { "type": "prompt", "prompt": "Anime style portrait", "enhance_prompt": true }
        ```

        | Field            | Type      | Description                                                                                                                          |
        | ---------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------ |
        | `prompt`         | `string`  | The text prompt to apply                                                                                                             |
        | `enhance_prompt` | `boolean` | When `true` (default), the server enhances the prompt automatically before applying it. Set to `false` to use the exact prompt text. |

        You **can** validate, filter, or replace this message before forwarding.
      </Accordion>

      <Accordion title="set_image — Reference image with optional prompt">
        ```json theme={null}
        {
          "type": "set_image",
          "image_data": "<base64-encoded image>",
          "prompt": "Transform into this character",
          "enhance_prompt": true
        }
        ```

        | Field            | Type             | Description                                                                                 |
        | ---------------- | ---------------- | ------------------------------------------------------------------------------------------- |
        | `image_data`     | `string \| null` | Base64-encoded image (max 10 MB decoded). Send `null` to clear the current reference image. |
        | `prompt`         | `string`         | Optional. Set a prompt alongside the image.                                                 |
        | `enhance_prompt` | `boolean`        | Optional. Enhance the prompt automatically (default: `true`).                               |

        You **can** validate or reject this message before forwarding.

        <Warning>
          Reference images must be under 10 MB (decoded). Oversized images are rejected by the server.
        </Warning>
      </Accordion>
    </AccordionGroup>
  </Tab>

  <Tab title="Decart → Client">
    Messages your proxy receives from Decart and forwards to the client.

    <AccordionGroup>
      <Accordion title="answer — SDP answer for WebRTC">
        ```json theme={null}
        { "type": "answer", "sdp": "v=0\r\no=- 456 2 IN IP4 34.102.85.17\r\n..." }
        ```

        Forward as-is. The client applies this with `pc.setRemoteDescription({ type: "answer", sdp: msg.sdp })`.
      </Accordion>

      <Accordion title="ice-candidate — ICE candidate from server">
        ```json theme={null}
        {
          "type": "ice-candidate",
          "candidate": {
            "candidate": "candidate:1 1 UDP 2130706431 34.102.85.17 12345 typ host",
            "sdpMLineIndex": 0,
            "sdpMid": "0",
            "usernameFragment": "ab12"
          }
        }
        ```

        Forward as-is. A `null` candidate signals end-of-candidates from the server side.

        <Info>
          ICE candidates are high-frequency during connection setup. Avoid logging individual candidates — log connection state changes instead.
        </Info>
      </Accordion>

      <Accordion title="session_id — Session identifier for tracking">
        ```json theme={null}
        {
          "type": "session_id",
          "session_id": "abc123",
          "server_ip": "34.102.85.17",
          "server_port": 12345
        }
        ```

        Log the `session_id` for tracking and support. The `server_ip` and `server_port` indicate which Decart server is handling this session.
      </Accordion>

      <Accordion title="prompt_ack — Prompt accepted or rejected">
        ```json theme={null}
        { "type": "prompt_ack", "prompt": "Anime style portrait", "success": true, "error": null }
        ```

        When moderation rejects a prompt:

        ```json theme={null}
        { "type": "prompt_ack", "prompt": "...", "success": false, "error": "Content violates our Terms of Service" }
        ```

        Forward to the client so they know whether their prompt was applied. The `prompt` field echoes back the original text.
      </Accordion>

      <Accordion title="set_image_ack — Image accepted or rejected">
        ```json theme={null}
        { "type": "set_image_ack", "success": true, "error": null }
        ```

        When moderation rejects an image:

        ```json theme={null}
        { "type": "set_image_ack", "success": false, "error": "Content violates our Terms of Service" }
        ```

        Forward to the client so they know whether their image was applied.
      </Accordion>

      <Accordion title="generation_started — Model is producing frames">
        ```json theme={null}
        { "type": "generation_started" }
        ```

        Media will start flowing over the WebRTC connection after this message.
      </Accordion>

      <Accordion title="generation_tick — Periodic billing update">
        ```json theme={null}
        { "type": "generation_tick", "seconds": 30 }
        ```

        The `seconds` field is the total elapsed generation time (cumulative, not a delta). Use this for usage tracking and billing.
      </Accordion>

      <Accordion title="generation_ended — Session complete">
        ```json theme={null}
        { "type": "generation_ended", "seconds": 120, "reason": "disconnect" }
        ```

        The WebSocket connection closes shortly after this message. The `reason` field indicates why the session ended:

        | Reason                 | Description                                    |
        | ---------------------- | ---------------------------------------------- |
        | `disconnect`           | Client disconnected                            |
        | `timeout`              | Session reached the maximum duration           |
        | `moderation_violation` | Content policy violation terminated the stream |
        | `error`                | Server error                                   |
        | `insufficient_credits` | Account has insufficient credits               |
      </Accordion>

      <Accordion title="error — Error occurred">
        ```json theme={null}
        { "type": "error", "error": "Connection failed" }
        ```

        The `error` field is a human-readable error string. Forward to the client or handle in your proxy.
      </Accordion>

      <Accordion title="ice-restart — ICE restart with new TURN credentials">
        ```json theme={null}
        {
          "type": "ice-restart",
          "turn_config": {
            "username": "...",
            "credential": "...",
            "server_url": "turn:turn.decart.ai:3478"
          }
        }
        ```

        When present, `turn_config` provides fresh TURN credentials. Forward to the client — they should reconfigure their peer connection with the new TURN server and create a new SDP offer.

        <Note>
          ICE restarts are server-initiated. If the restart fails, the session ends with a `generation_ended` message (reason: `"error"`).
        </Note>
      </Accordion>
    </AccordionGroup>
  </Tab>
</Tabs>

## Moderation

Prompts and images are moderated server-side before being applied to the model. Your proxy sees the result as acknowledgment messages:

* **Prompt accepted** → `prompt_ack` with `success: true`
* **Prompt rejected** → `prompt_ack` with `success: false` and an `error` string
* **Image accepted** → `set_image_ack` with `success: true`
* **Image rejected** → `set_image_ack` with `success: false` and an `error` string

Rejected content is not applied — the model continues with the previous prompt or image. Your proxy can add its own moderation layer before forwarding messages to Decart.

## Usage tracking

Track usage by watching the generation lifecycle messages:

```python theme={null}
async def upstream_to_client(upstream_ws, client_ws, session):
    async for raw_msg in upstream_ws:
        msg = json.loads(raw_msg)

        if msg["type"] == "generation_started":
            session.mark_started()

        elif msg["type"] == "generation_tick":
            session.record_usage(seconds=msg["seconds"])

        elif msg["type"] == "generation_ended":
            session.finalize(
                total_seconds=msg["seconds"],
                reason=msg["reason"],
            )

        await client_ws.send(raw_msg)
```

## Proxy example

A complete WebSocket proxy using Python and `websockets`. This example authenticates the client, connects upstream, pumps messages with logging, and handles disconnects:

```python theme={null}
import asyncio
import json
import websockets

DECART_API_KEY = "your-api-key"  # Load from environment in production

async def handle_client(client_ws):
    # 1. Authenticate the client and extract model from query params
    model = parse_model_from_query(client_ws.request)
    user = authenticate_user(client_ws.request)
    if not user:
        await client_ws.close(4001, "Unauthorized")
        return

    # 2. Open upstream connection to Decart
    upstream_url = f"wss://api3.decart.ai/v1/stream?api_key={DECART_API_KEY}&model={model}"
    async with websockets.connect(upstream_url) as upstream_ws:
        session_id = None

        async def client_to_upstream():
            async for raw_msg in client_ws:
                msg = json.loads(raw_msg)

                if msg["type"] == "prompt":
                    print(f"[{session_id}] prompt: {msg['prompt'][:80]}")

                elif msg["type"] == "set_image":
                    print(f"[{session_id}] set_image (has_prompt={bool(msg.get('prompt'))})")

                await upstream_ws.send(raw_msg)

        async def upstream_to_client():
            nonlocal session_id
            async for raw_msg in upstream_ws:
                msg = json.loads(raw_msg)

                if msg["type"] == "session_id":
                    session_id = msg["session_id"]
                    print(f"Session started: {session_id}")

                elif msg["type"] == "prompt_ack":
                    if not msg["success"]:
                        print(f"[{session_id}] prompt rejected: {msg['error']}")

                elif msg["type"] == "set_image_ack":
                    if not msg["success"]:
                        print(f"[{session_id}] image rejected: {msg['error']}")

                elif msg["type"] == "generation_started":
                    print(f"[{session_id}] generation started")

                elif msg["type"] == "generation_ended":
                    print(f"[{session_id}] ended: {msg['reason']} ({msg['seconds']}s)")
                    record_billing(user, session_id, msg["seconds"])

                elif msg["type"] == "error":
                    print(f"[{session_id}] error: {msg['error']}")

                await client_ws.send(raw_msg)

        # 3. Pump messages in both directions
        try:
            await asyncio.gather(
                client_to_upstream(),
                upstream_to_client(),
            )
        except websockets.ConnectionClosed:
            pass

    print(f"[{session_id}] connection closed")

async def main():
    async with websockets.serve(handle_client, "0.0.0.0", 8080):
        await asyncio.Future()  # Run forever

asyncio.run(main())
```

<Info>
  This proxy is stateless — it holds no session state between connections. Each WebSocket connection is an independent pass-through. You can scale horizontally by running multiple proxy instances behind a load balancer.
</Info>

## Client implementation

Your client connects to the proxy over WebSocket and handles WebRTC with standard browser APIs.

```javascript theme={null}
const ws = new WebSocket(
  "wss://api.yourplatform.com/v1/realtime?model=lucy-2.1&token=user_token"
);

const pc = new RTCPeerConnection({
  iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});

const stream = await navigator.mediaDevices.getUserMedia({
  video: { width: 1280, height: 720, frameRate: 20 },
  audio: true,
});
stream.getTracks().forEach((track) => pc.addTrack(track, stream));

pc.ontrack = (event) => {
  document.getElementById("remote-video").srcObject = event.streams[0];
};

pc.onicecandidate = ({ candidate }) => {
  ws.send(JSON.stringify({ type: "ice-candidate", candidate }));
};

ws.addEventListener("open", async () => {
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  ws.send(JSON.stringify({ type: "offer", sdp: offer.sdp }));
});

ws.addEventListener("message", async (event) => {
  const msg = JSON.parse(event.data);

  switch (msg.type) {
    case "answer":
      await pc.setRemoteDescription({ type: "answer", sdp: msg.sdp });
      break;

    case "ice-candidate":
      if (msg.candidate) await pc.addIceCandidate(msg.candidate);
      break;

    case "ice-restart":
      // Server rotated TURN credentials — reconfigure and restart ICE
      pc.setConfiguration({
        iceServers: [
          { urls: "stun:stun.l.google.com:19302" },
          {
            urls: msg.turn_config.server_url,
            username: msg.turn_config.username,
            credential: msg.turn_config.credential,
          },
        ],
      });
      const restartOffer = await pc.createOffer({ iceRestart: true });
      await pc.setLocalDescription(restartOffer);
      ws.send(JSON.stringify({ type: "offer", sdp: restartOffer.sdp }));
      break;

    case "generation_started":
      showLiveIndicator();
      break;

    case "generation_tick":
      updateUsageDisplay(msg.seconds);
      break;

    case "generation_ended":
      finalizeSession(msg.seconds, msg.reason);
      break;

    case "prompt_ack":
      if (!msg.success) console.warn("Prompt rejected:", msg.error);
      break;

    case "set_image_ack":
      if (!msg.success) console.warn("Image rejected:", msg.error);
      break;

    case "error":
      console.error("Server error:", msg.error);
      break;
  }
});
```

Once the session is running, send control messages at any time:

```javascript theme={null}
ws.send(JSON.stringify({
  type: "prompt",
  prompt: "Cyberpunk neon city",
  enhance_prompt: true,
}));

stream.getTracks().forEach((t) => t.stop());
ws.close();
pc.close();
```

<Note>
  Message types and fields match the [message reference](#message-reference) above. In production, add reconnection logic for WebSocket drops and monitor `pc.connectionState` for WebRTC failures.
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="HTTP Signaling" icon="globe" href="/integrations/signaling-proxy-http">
    Same architecture with stateless HTTP instead of WS proxy
  </Card>

  <Card title="Authentication" icon="key" href="/getting-started/authentication">
    API key management and client tokens
  </Card>
</CardGroup>
