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

# Connect a platform

> Link a family to an external platform end-to-end — the OAuth connect ceremony, run live against the open sandbox with real requests and responses.

A **platform** (also called a *provider*) is anything Phosra can push a child-safety policy
to — a DNS filter, a router, an app, an operating system. Before Phosra can enforce anything,
a parent has to connect the platform and approve sharing their children's profiles.

This guide walks the full **connect ceremony** — discovery → consent → token → profiles —
against the public sandbox. Every request and response below is **verbatim live output**,
captured while writing this page. No API key, nothing to install.

The four calls chain end to end: **discover** the endpoints (`GET /providers/{did}/connect`) →
send the parent to **consent** (`authorize_url`) → exchange the returned code for a **token**
(`token_url`) → read the approved **profiles** (`profiles_url`). Each step below carries a
**Fields & errors** reference you can expand.

<Info>
  **Sandbox-first.** The reference provider used here — `did:ocss:loopline` — is seeded into the
  partner sandbox at `https://phosra-api-sandbox-production.up.railway.app`. Nothing you connect
  there touches a real family. The endpoint shapes are identical in production; you swap the base
  URL for `https://prodapi.phosra.com` and connect a real provider DID.
</Info>

## Before you start

Set the base URL once so every step is copy-paste:

```bash theme={null}
export PHOSRA_BASE="https://phosra-api-sandbox-production.up.railway.app"
```

You will connect the sandbox's reference provider, **Loopline** (`did:ocss:loopline`). It is a
real, accredited entry on the sandbox [Trust List](/guides/test-in-sandbox) with a live OAuth
surface — the same shape a production provider exposes.

<Steps>
  <Step title="Discover the provider's connect endpoints">
    Every connectable provider publishes its OAuth endpoints at
    `GET /providers/{did}/connect`. Start there — never hard-code the URLs.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -s "$PHOSRA_BASE/api/v1/providers/did:ocss:loopline/connect"
      ```

      ```typescript TypeScript theme={null}
      const BASE = "https://phosra-api-sandbox-production.up.railway.app";

      const res = await fetch(`${BASE}/api/v1/providers/did:ocss:loopline/connect`);
      const connect = await res.json();
      // connect.authorize_url, connect.token_url, connect.profiles_url, connect.scopes
      ```

      ```python Python theme={null}
      import requests

      BASE = "https://phosra-api-sandbox-production.up.railway.app"

      connect = requests.get(
          f"{BASE}/api/v1/providers/did:ocss:loopline/connect", timeout=30
      ).json()
      # connect["authorize_url"], connect["token_url"], connect["profiles_url"]
      ```

      ```go Go theme={null}
      const base = "https://phosra-api-sandbox-production.up.railway.app"

      res, _ := http.Get(base + "/api/v1/providers/did:ocss:loopline/connect")
      defer res.Body.Close()
      // decode into { AuthorizeURL, TokenURL, ProfilesURL, Scopes, Name }
      ```
    </CodeGroup>

    **Real response (200):**

    ```json theme={null}
    {
      "authorize_url": "https://phosra-api-sandbox-production.up.railway.app/oauth/authorize",
      "token_url": "https://phosra-api-sandbox-production.up.railway.app/oauth/token",
      "profiles_url": "https://phosra-api-sandbox-production.up.railway.app/oauth/profiles",
      "scopes": ["child_profiles.read"],
      "name": "Loopline"
    }
    ```

    <Note>
      A provider that is accredited but has **not** configured a connect surface returns
      `404 provider connect config not available`. In the sandbox, `did:ocss:courier` is deliberately
      left unconfigured so you can test that branch.
    </Note>

    <Accordion title="Fields & errors — GET /providers/{did}/connect">
      **Path parameter**

      | Field | Type   | Required | Description                                                                                                                             |
      | ----- | ------ | :------: | --------------------------------------------------------------------------------------------------------------------------------------- |
      | `did` | string |    yes   | The provider's decentralized identifier, e.g. `did:ocss:loopline`. URL-encode the `:` characters if your client does not do it for you. |

      **Response fields (200)**

      | Field           | Type         | Description                                                                                                                      |
      | --------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------- |
      | `authorize_url` | string (URL) | Where you send the parent's browser to grant consent (step 2).                                                                   |
      | `token_url`     | string (URL) | Where you exchange the authorization `code` for a token (step 4).                                                                |
      | `profiles_url`  | string (URL) | Where you read the approved child profiles with the token.                                                                       |
      | `scopes`        | string\[]    | The OAuth scopes the provider will request. Loopline requests `child_profiles.read` — read-only access to the approved children. |
      | `name`          | string       | Human-readable provider name, safe to show a parent.                                                                             |

      **Errors**

      | Status | `message`                               | When it happens                                                                                                                                  |
      | :----: | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
      |  `404` | `provider connect config not available` | The DID is accredited but has no connect surface configured (e.g. `did:ocss:courier`). Do not retry — the provider must publish endpoints first. |
      |  `404` | `provider not found`                    | The DID is not on the Trust List at all. Check the value against [`GET /.well-known/ocss/trust-list`](/guides/test-in-sandbox).                  |
    </Accordion>
  </Step>

  <Step title="Send the parent to the consent page">
    Redirect the parent's browser to `authorize_url` with your `client_id` (the provider DID),
    a `redirect_uri`, and an opaque `state` you generate:

    ```
    GET /oauth/authorize
          ?client_id=did:ocss:loopline
          &redirect_uri=https://loopline.example/callback
          &state=xyz789
          &response_type=code
    ```

    The sandbox serves a real consent screen. The parent sees exactly which child profiles they
    are about to share, and chooses **Approve** or **Deny**:

    <Frame caption="The live sandbox consent page for did:ocss:loopline — captured from the running server, not a mockup.">
      <img src="https://mintcdn.com/phosra/o0ansqUg4hqAQihW/images/consent-page-live.jpg?fit=max&auto=format&n=o0ansqUg4hqAQihW&q=85&s=7d3a0f000fda958d411b6427ce34bb0f" alt="Phosra sandbox consent page titled 'Connect your family to this app?' listing Mia, Leo, and Ava as child profiles, with Approve and Deny buttons" width="760" height="430" data-path="images/consent-page-live.jpg" />
    </Frame>

    <Accordion title="Fields & errors — GET /oauth/authorize">
      **Query parameters**

      | Field           | Type         | Required | Description                                                                                                                  |
      | --------------- | ------------ | :------: | ---------------------------------------------------------------------------------------------------------------------------- |
      | `client_id`     | string       |    yes   | The provider DID you are connecting, e.g. `did:ocss:loopline`.                                                               |
      | `redirect_uri`  | string (URL) |    yes   | Where Phosra sends the parent back after they decide. Custom schemes (e.g. `myapp://callback`) are accepted for native apps. |
      | `state`         | string       |    yes   | An opaque value you generate. It is echoed back unchanged — compare it on return to defend against CSRF.                     |
      | `response_type` | string       |    yes   | Always `code` for this flow.                                                                                                 |

      **Outcomes**

      | Result              | What Phosra returns                                                                                                                                         |
      | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | Parent **approves** | `302` redirect to `redirect_uri?code=…&state=…` (step 3).                                                                                                   |
      | Parent **denies**   | `302` redirect to `redirect_uri?error=access_denied&state=…` — **no code**. Handle this branch; see [Disconnect & reconnect](/guides/disconnect-reconnect). |

      **Errors**

      | Status | When it happens                                                                                                                  |
      | :----: | -------------------------------------------------------------------------------------------------------------------------------- |
      |  `400` | `redirect_uri` is missing or malformed. Phosra will not redirect to an absent callback, so it fails closed with a `400` instead. |
    </Accordion>
  </Step>

  <Step title="Receive the authorization code">
    On **Approve**, Phosra redirects back to your `redirect_uri` with a short-lived `code` and the
    `state` you sent (verify it matches before continuing):

    ```
    302 Location: https://loopline.example/callback?code=sbxauth_2hg1obaYzTs5XiK80A-NnuVhzOHfktze&state=xyz789
    ```

    On **Deny**, no code is issued — you get `?error=access_denied&state=…` instead. Handle both.
    See [Disconnect & reconnect](/guides/disconnect-reconnect) for the decline path in full.

    <Accordion title="Fields & errors — the callback query string">
      **Query parameters Phosra appends to your `redirect_uri`**

      | Field   | Type   | Present when | Description                                                                                         |
      | ------- | ------ | ------------ | --------------------------------------------------------------------------------------------------- |
      | `code`  | string | approved     | Short-lived authorization code (`sbxauth_…` in the sandbox). Exchange it once at `token_url`.       |
      | `state` | string | always       | The exact `state` you sent. **Reject the response if it does not match** the value you generated.   |
      | `error` | string | denied       | `access_denied` when the parent declined. No `code` is present — do not attempt the token exchange. |
    </Accordion>
  </Step>

  <Step title="Exchange the code for an access token">
    Trade the `code` for a bearer token at `token_url`:

    <CodeGroup>
      ```bash cURL theme={null}
      curl -s -X POST "$PHOSRA_BASE/oauth/token" \
        -H "Content-Type: application/json" \
        -d '{
          "grant_type": "authorization_code",
          "code": "sbxauth_2hg1obaYzTs5XiK80A-NnuVhzOHfktze",
          "client_id": "did:ocss:loopline",
          "redirect_uri": "https://loopline.example/callback"
        }'
      ```

      ```typescript TypeScript theme={null}
      const tok = await fetch(`${BASE}/oauth/token`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          grant_type: "authorization_code",
          code,                                   // from the callback
          client_id: "did:ocss:loopline",
          redirect_uri: "https://loopline.example/callback",
        }),
      }).then((r) => r.json());
      // tok.access_token, tok.expires_in
      ```

      ```python Python theme={null}
      tok = requests.post(f"{BASE}/oauth/token", json={
          "grant_type": "authorization_code",
          "code": code,                           # from the callback
          "client_id": "did:ocss:loopline",
          "redirect_uri": "https://loopline.example/callback",
      }, timeout=30).json()
      # tok["access_token"], tok["expires_in"]
      ```

      ```go Go theme={null}
      body, _ := json.Marshal(map[string]string{
      	"grant_type":   "authorization_code",
      	"code":         code, // from the callback
      	"client_id":    "did:ocss:loopline",
      	"redirect_uri": "https://loopline.example/callback",
      })
      res, _ := http.Post(base+"/oauth/token", "application/json", bytes.NewReader(body))
      defer res.Body.Close()
      // decode { AccessToken, ExpiresIn, Scope, TokenType }
      ```
    </CodeGroup>

    **Real response (200):**

    ```json theme={null}
    {
      "access_token": "sbxtok_ymSQQBw4aPijjuX8xuU5R6ASjrDSXXC6",
      "expires_in": 3600,
      "scope": "profiles",
      "token_type": "Bearer"
    }
    ```

    <Note>
      **On the `scope` value.** Discovery advertises the requested scope as `child_profiles.read`
      (the canonical name), while the issued token echoes the short form `scope: "profiles"`. **Both
      name the same grant** — read-only access to the approved children. Key your logic off the token
      you were issued, not off a hard-coded string, and treat the two as equivalent.
    </Note>

    <Accordion title="Fields & errors — POST /oauth/token">
      **Request body** (`application/json`)

      | Field          | Type   | Required | Description                                                    |
      | -------------- | ------ | :------: | -------------------------------------------------------------- |
      | `grant_type`   | string |    yes   | Always `authorization_code` for this flow.                     |
      | `code`         | string |    yes   | The authorization code from the callback.                      |
      | `client_id`    | string |    yes   | The provider DID — must match the one you sent to `authorize`. |
      | `redirect_uri` | string |    yes   | Must match the `redirect_uri` used at `authorize`.             |

      **Response fields (200)**

      | Field          | Type    | Description                                                                                                                |
      | -------------- | ------- | -------------------------------------------------------------------------------------------------------------------------- |
      | `access_token` | string  | Bearer token (`sbxtok_…`) for `GET /oauth/profiles`.                                                                       |
      | `expires_in`   | integer | Seconds until the token expires (`3600` = 1 hour).                                                                         |
      | `scope`        | string  | The granted scope, returned as `profiles` — equivalent to the `child_profiles.read` scope from discovery (see note above). |
      | `token_type`   | string  | Always `Bearer`. Send it as `Authorization: Bearer <access_token>`.                                                        |

      **Errors**

      | Status | `error`                  | When it happens                                      |
      | :----: | ------------------------ | ---------------------------------------------------- |
      |  `400` | `unsupported_grant_type` | `grant_type` is missing or not `authorization_code`. |
      |  `400` | `invalid_request`        | The body is malformed or a required field is absent. |

      <Warning>
        **Sandbox stub behaviour.** The sandbox `/oauth/token` surface is a **stateless reference stub**:
        it does not persist or validate the `code`, so an expired or reused code still returns a token in
        the sandbox. In production, a provider's real token endpoint returns `400 invalid_grant` for an
        expired, reused, or unknown code — write your error handling for that before you go live.
      </Warning>
    </Accordion>
  </Step>

  <Step title="Read the shared child profiles">
    Call `profiles_url` with the token. You get back exactly the children the parent approved —
    each with a stable `subject_ref` you use for policy and enforcement calls:

    <CodeGroup>
      ```bash cURL theme={null}
      curl -s "$PHOSRA_BASE/oauth/profiles" \
        -H "Authorization: Bearer sbxtok_ymSQQBw4aPijjuX8xuU5R6ASjrDSXXC6"
      ```

      ```typescript TypeScript theme={null}
      const profiles = await fetch(`${BASE}/oauth/profiles`, {
        headers: { Authorization: `Bearer ${tok.access_token}` },
      }).then((r) => r.json());
      // [{ id, displayName, subject_ref, kind }, …]
      ```

      ```python Python theme={null}
      profiles = requests.get(f"{BASE}/oauth/profiles", headers={
          "Authorization": f"Bearer {tok['access_token']}",
      }, timeout=30).json()
      ```

      ```go Go theme={null}
      req, _ := http.NewRequest("GET", base+"/oauth/profiles", nil)
      req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
      res, _ := http.DefaultClient.Do(req)
      defer res.Body.Close()
      // decode []Profile{ ID, DisplayName, SubjectRef, Kind }
      ```
    </CodeGroup>

    **Real response (200):**

    ```json theme={null}
    [
      { "id": "mia", "displayName": "Mia", "subject_ref": "a11ce0fa-0000-4000-8000-0000000000a1", "kind": "child" },
      { "id": "leo", "displayName": "Leo", "subject_ref": "a11ce0fa-0000-4000-8000-0000000000a2", "kind": "child" },
      { "id": "ava", "displayName": "Ava", "subject_ref": "a11ce0fa-0000-4000-8000-0000000000a3", "kind": "child" }
    ]
    ```

    The platform is now connected. Hold onto each `subject_ref` — that is the handle you pass to
    the policy and enforcement endpoints in [Set up a family & kids](/guides/family-and-kids).

    <Accordion title="Fields & errors — GET /oauth/profiles">
      **Request header**

      | Header          | Required | Description                                  |
      | --------------- | :------: | -------------------------------------------- |
      | `Authorization` |    yes   | `Bearer <access_token>` from the token step. |

      **Response fields (200)** — an array, one object per approved child

      | Field         | Type          | Description                                                                                                               |
      | ------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------- |
      | `id`          | string        | Provider-local child id (e.g. `mia`).                                                                                     |
      | `displayName` | string        | Child's display name, safe to show a parent.                                                                              |
      | `subject_ref` | string (UUID) | **Stable cross-system handle.** Pass this to the policy and enforcement endpoints — it does not change across reconnects. |
      | `kind`        | string        | Subject kind, `child` for these entries.                                                                                  |

      **Errors**

      | Status | `error`         | When it happens                     |
      | :----: | --------------- | ----------------------------------- |
      |  `401` | `invalid_token` | No `Authorization` header was sent. |

      <Warning>
        **Sandbox stub behaviour.** Because the sandbox surface is stateless, any non-empty
        `Bearer` value returns the seeded profiles — only a **missing** header yields `401`. A production
        provider validates the token and returns `401 invalid_token` for any expired or forged token, so
        treat `401` as "re-run the connect ceremony" in your client.
      </Warning>
    </Accordion>
  </Step>
</Steps>

## Which platform supports which rule?

Not every platform can enforce every rule. Discovery endpoints let you pick the right one
before you ask a parent to connect:

<CodeGroup>
  ```bash Find by capability theme={null}
  # Every platform that can do DNS-level web filtering
  curl -s "$PHOSRA_BASE/api/v1/platforms/by-capability?capability=web_filtering"
  # → [ "fire_tablet", "apple", "microsoft", "cleanbrowsing", "controld", "nextdns" ]
  ```

  ```bash Find by category theme={null}
  # Every DNS platform
  curl -s "$PHOSRA_BASE/api/v1/platforms/by-category?category=dns"
  # → [ "cleanbrowsing", "controld", "nextdns" ]
  ```

  ```bash Inspect one platform theme={null}
  curl -s "$PHOSRA_BASE/api/v1/platforms/nextdns"
  # → { "id": "nextdns", "category": "dns", "enforcement_mode": "dns",
  #     "auth_type": "api_key", "capabilities": ["web_filtering", "safe_search", …],
  #     "rule_support": { … per-rule "supported" | "unsupported" … } }
  ```
</CodeGroup>

Check each platform's **`enforcement_mode`**: `dns`, `device`, and `oauth2` platforms are
applied programmatically, while `manual_attested` platforms require the parent to complete a
guided step by hand. See [Platforms & enforcement modes](/concepts/platforms).

## Production notes

<Note>
  In production, connecting an **account-linked** platform (one where you hold a per-family
  credential rather than an OAuth grant) uses `POST /compliance` with your `phosra_live_…` key —
  see [First-time setup](/guides/first-time-setup). Those endpoints are family-scoped and require
  an authenticated caller who is a member of the family, so they cannot be exercised
  anonymously against the open sandbox. The OAuth ceremony above is the anonymous, fully
  runnable path.
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="Set up a family & kids" icon="users" href="/guides/family-and-kids">
    Build a family, add children, and get an age-appropriate policy in one call.
  </Card>

  <Card title="Disconnect & reconnect" icon="plug-circle-xmark" href="/guides/disconnect-reconnect">
    Handle the decline path, tear a link down, and re-approve cleanly.
  </Card>
</CardGroup>
