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

# Connect a brokerage manually

# Runbook: connect a brokerage by hand (no UI)

Connect a real brokerage account end-to-end through the API only, using the
P2C connection-plane routes. This is the acceptance path for
`POST /brokerages/portal-sessions` → browser → `.../return` → provider
readback, before any Brokerages UI exists (App PR P4B).

**Contract:** app repo
`docs/superpowers/specs/2026-08-15-account-management-snaptrade-engineering-companion.md`
§4.3 (portal sessions), §7.1 (routes), §7.3 (response projection), §9.3
(reconciliation).

**Secrets discipline.** The portal URL is bearer-like: anyone holding it can
complete the connection. It expires in five minutes, it is never stored or
logged, and it must never be pasted into an issue, a PR, a chat thread, or a
terminal recording. Same for the `state` token and the JWT. Every value in this
document is a `<PLACEHOLDER>`.

**Scope.** SnapTrade test key first. A production connection is a real
brokerage authorization: run it against your own account, and never against
someone else's.

***

## 0. Prerequisites

On the compute server, `/opt/sequency/.env` (mode 600) must carry:

| Variable                                          | Meaning                                                                                                                                                                                                        |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SNAPTRADE_CLIENT_ID`                             | SnapTrade Commercial client ID                                                                                                                                                                                 |
| `SNAPTRADE_CONSUMER_KEY`                          | SnapTrade Commercial consumer key (HMAC signing key)                                                                                                                                                           |
| `BROKER_CREDENTIAL_ENCRYPTION_KEYS`               | `1:&lt;64 hex chars>` — AES-256-GCM key(s) for the per-user provider secret                                                                                                                                    |
| `BROKER_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_VERSION` | Key version new ciphertext is written under (`0` = highest configured)                                                                                                                                         |
| `BROKERAGE_PORTAL_RETURN_PATHS`                   | Comma-separated **relative** allowlist, e.g. `/account/brokerages`                                                                                                                                             |
| `BROKERAGE_PORTAL_REDIRECT_BASE_URL`              | Absolute `https://` origin for the redirect, e.g. `https://sequency.sh`. Leave empty to use SnapTrade's own configured redirect — recommended for the first manual run, because there is no UI to land on yet. |

If `BROKERAGE_PORTAL_REDIRECT_BASE_URL` is set, the resulting origin **must**
also be registered with SnapTrade, or the portal will refuse the redirect.

After editing the file:

```bash theme={null}
ssh sequency@compute.sequencyhq.com "sudo systemctl restart sequency-api && sleep 3 && curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8081/health"
```

Expect `200`.

***

## 1. Open a shell next to the API

Every call below runs on the compute server against `localhost:8081`, which
skips Cloudflare and the BFF and talks to FastAPI directly.

```bash theme={null}
ssh sequency@compute.sequencyhq.com
```

Keep this session open; steps 2-7 all run here.

***

## 2. Get a JWT for your own user

Password grant against Supabase. Run this **on your laptop** (it needs no
server access) and copy the token to the server shell.

```bash theme={null}
export SUPABASE_URL='https://<PROJECT_REF>.supabase.co'
export SUPABASE_ANON_KEY='<SUPABASE_ANON_KEY>'

curl -s -X POST "$SUPABASE_URL/auth/v1/token?grant_type=password" \
  -H "apikey: $SUPABASE_ANON_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"email":"<YOUR_EMAIL>","password":"<YOUR_PASSWORD>"}' \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'
```

In the server shell:

```bash theme={null}
export JWT='<PASTE_ACCESS_TOKEN>'
export API='http://localhost:8081/api/graph/v1'
# Strict auth mode rejects POSTs without this header (CSRF guard).
export H_AUTH="Authorization: Bearer $JWT"
export H_CSRF='X-Requested-With: XMLHttpRequest'
export H_JSON='Content-Type: application/json'
```

The token is short-lived (about an hour). If a call starts returning `401`,
re-run this step.

Your Sequency user UUID is the `sub` claim — you will need it for the SQL in
step 7:

```bash theme={null}
python3 - <<'PY'
import base64, json, os
payload = os.environ["JWT"].split(".")[1]
payload += "=" * (-len(payload) % 4)
print(json.loads(base64.urlsafe_b64decode(payload))["sub"])
PY
```

***

## 3. Confirm the deployment is configured

```bash theme={null}
curl -s -H "$H_AUTH" "$API/brokerages/integrations" | head -c 400; echo
```

* A JSON body with `"integrations": [...]` and `"refreshed_at"` → SnapTrade
  readback works.
* `{"error":{"code":"provider_not_configured",...}}` (HTTP 503) → step 0 is
  incomplete. Fix it before going further; nothing below will work.

Note a `slug` you intend to connect (for example `"slug": "<BROKER_SLUG>"`).
Only slugs listed here are accepted in step 4 — there is no local broker list.

***

## 4. Create a portal session

```bash theme={null}
curl -s -X POST "$API/brokerages/portal-sessions" \
  -H "$H_AUTH" -H "$H_CSRF" -H "$H_JSON" \
  -d '{"intent":"connect_read","broker_slug":"<BROKER_SLUG>"}' \
  > /tmp/portal-session.json
```

`broker_slug` is optional — omit it to let the user pick an institution inside
the portal. `intent` defaults to `connect_read`, which is the read-only
default the companion requires.

Read the parts you need without echoing the URL into your scrollback:

```bash theme={null}
python3 - <<'PY'
import json
d = json.load(open("/tmp/portal-session.json"))
print("session_id:", d["session"]["id"])
print("status:    ", d["session"]["status"])       # pending
print("expires_at:", d["session"]["expires_at"])   # &lt;= 5 minutes out
print("state:     ", d["state"])                   # needed in step 6
print("portal_url length:", len(d["portal_url"]))  # not printed on purpose
PY
```

Export what step 6 needs:

```bash theme={null}
export SESSION_ID=$(python3 -c 'import json;print(json.load(open("/tmp/portal-session.json"))["session"]["id"])')
export STATE=$(python3 -c 'import json;print(json.load(open("/tmp/portal-session.json"))["state"])')
```

Failure responses (all HTTP with a `{"error":{"code":...}}` body):

| Code                             | Meaning                                                                                        |
| -------------------------------- | ---------------------------------------------------------------------------------------------- |
| `portal_broker_not_enabled`      | `broker_slug` is not in the step-3 catalogue                                                   |
| `portal_return_path_not_allowed` | `return_path` is not in `BROKERAGE_PORTAL_RETURN_PATHS`                                        |
| `portal_intent_invalid`          | `upgrade_trade`/`reconnect` without a `connection_id`, or a `connection_id` on a plain connect |
| `provider_not_configured`        | step 0 incomplete                                                                              |

***

## 5. Complete the portal in a browser

Copy the URL out of the file **without** printing it to a shared terminal:

```bash theme={null}
# macOS, on your laptop:
ssh sequency@compute.sequencyhq.com "python3 -c 'import json;print(json.load(open(\"/tmp/portal-session.json\"))[\"portal_url\"])'" | pbcopy
```

Paste it into a private browser window and finish the brokerage login and MFA.
You have **five minutes**; after that, delete `/tmp/portal-session.json` and
redo step 4.

The browser will land on SnapTrade's configured redirect (or on
`BROKERAGE_PORTAL_REDIRECT_BASE_URL` + the allowlisted path, which has no UI
yet — a 404 page there is expected and harmless). Nothing in that redirect is
authoritative: the connection is not real until step 6 reads it back from the
provider.

To test the **cancel** path instead, close the portal without authorizing and
continue to step 6 anyway — the expected result is `reconciled: true` with
`"connections": []`.

***

## 6. Return and reconcile

```bash theme={null}
curl -s -X POST "$API/brokerages/portal-sessions/$SESSION_ID/return" \
  -H "$H_AUTH" -H "$H_CSRF" -H "$H_JSON" \
  -d "{\"state\":\"$STATE\"}" | python3 -m json.tool
```

Expected on success:

```json theme={null}
{
  "session": {"status": "reconciled", "returned_at": "...", "reconciled_at": "..."},
  "reconciled": true,
  "connections": [
    {
      "provider": "snaptrade",
      "brokerage": {"slug": "...", "name": "..."},
      "access": "read",
      "state": "active_read",
      "freshness": {"mode": "...", "reconciled_at": "..."},
      "accounts": [{"name": "...", "mask": "1234", "deployment_eligibility": "read_only"}]
    }
  ]
}
```

Interpretation:

* `"reconciled": true` with connections → done.
* `"reconciled": true` with `"connections": []` → the portal was cancelled or
  no authorization was completed. Nothing was created; redo step 4.
* `"reconciled": false` → the provider could not be read *this time*. The
  session stays returnable: run the exact same command again. Repeating it is
  safe by design.
* `portal_state_invalid` (400) → wrong `state`. The session is not consumed;
  re-read `$STATE` from `/tmp/portal-session.json` and retry.
* `portal_session_expired` (410) → more than five minutes elapsed. Redo step 4.

Re-read at any time without touching the provider:

```bash theme={null}
curl -s -H "$H_AUTH" "$API/brokerages/connections" | python3 -m json.tool
```

Force a fresh provider readback for one connection (bounded, idempotent):

```bash theme={null}
curl -s -X POST "$API/brokerages/connections/<CONNECTION_ID>/reconcile" \
  -H "$H_AUTH" -H "$H_CSRF" | python3 -m json.tool
```

Then clean up the local copy of the URL:

```bash theme={null}
shred -u /tmp/portal-session.json 2>/dev/null || rm -f /tmp/portal-session.json
```

***

## 7. Verify the durable rows in Postgres

From the compute server (vRack address, per `CLAUDE.md`):

```bash theme={null}
export USER_UUID='<SUB_CLAIM_FROM_STEP_2>'
PGPASSWORD=sequency psql -h 10.0.0.2 -U sequency -d sequency -v USER_UUID="$USER_UUID"
```

`-v USER_UUID=...` is what makes `:'USER_UUID'` work inside psql; without it
every query below errors on an undefined variable.

**Provider identity — exactly one row per (user, provider).** The encrypted
secret is deliberately not selected; never `SELECT user_secret_enc`.

```sql theme={null}
SELECT id, provider, external_user_id, secret_key_version, state,
       registered_at, rotated_at
FROM broker_integration_users
WHERE user_id = :'USER_UUID';
```

Expect one row, `provider = 'snaptrade'`, `state = 'active'`,
`external_user_id` equal to your Sequency UUID (never an email).

**Portal session — single use, terminal, and holding no URL.**

```sql theme={null}
SELECT id, intent, broker_slug, return_path, status,
       created_at, expires_at, returned_at, reconciled_at,
       octet_length(state_hash) AS state_hash_bytes
FROM broker_portal_sessions
WHERE user_id = :'USER_UUID'
ORDER BY created_at DESC
LIMIT 5;
```

Expect `status = 'reconciled'`, `returned_at`/`reconciled_at` set,
`state_hash_bytes = 32` (SHA-256 digest — the raw token is never stored), and
`expires_at - created_at &lt;= 5 minutes`. The table has no portal-URL column at
all; confirm with `\d broker_portal_sessions`.

**Connection — state mapped from provider readback.**

```sql theme={null}
SELECT id, provider, external_connection_id, brokerage_slug, brokerage_name,
       access_type, state, data_freshness_mode,
       connected_at, last_reconciled_at, last_error_code,
       disabled_at, deletion_requested_at, deleted_at
FROM broker_connections
WHERE user_id = :'USER_UUID'
ORDER BY created_at DESC;
```

Expect `access_type = 'read'` and `state = 'active_read'` for a read-only
connect, `last_reconciled_at` recent, `last_error_code` NULL. `state =
'disabled'` means the provider reports the authorization disabled;
`state = 'error'` with `last_error_code = 'connection_access_unknown'` means
SnapTrade returned an access type this deployment does not recognize — capture
it and report it rather than working around it.

**Accounts — provider projection plus local preferences.**

```sql theme={null}
SELECT a.id, a.external_account_id, a.name, a.account_type, a.account_mask,
       a.is_hidden, a.is_portfolio_default, a.deployment_eligibility,
       a.holdings_sync_state, a.last_reconciled_at
FROM broker_accounts a
JOIN broker_connections c ON c.id = a.connection_id
WHERE a.user_id = :'USER_UUID'
ORDER BY a.created_at;
```

Expect one row per brokerage account, `account_mask` at most four characters
(a full account number is never stored), `deployment_eligibility = 'read_only'`
and `holdings_sync_state = 'unknown'` — the connection plane never marks an
account execution-eligible and never claims holdings are synced.

**Idempotency check.** Re-run the step-6 return command, then re-run the
connection and account queries: row counts and `id` values must be unchanged,
with only `last_reconciled_at` moving forward.

***

## 8. If something went wrong

Sanitized service logs — codes and statuses only, never provider bodies or
URLs:

```bash theme={null}
ssh sequency@compute.sequencyhq.com \
  "sudo journalctl -u sequency-api --since '15 min ago' --no-pager | grep -E 'brokerage (portal|identity|reconciliation)'"
```

A line reads `brokerage portal create outcome=<outcome> provider=snaptrade
code=<stable code>`. If you ever see a `snaptrade.com` URL or a `userSecret`
in these logs, stop and treat it as a security incident: THREATMODEL
assumption 7's SnapTrade exception (2026-08-17, data#1924) forbids it.

Metrics for the same events:

* `sequency_brokerage_portal_events_total{event,outcome}`
* `sequency_brokerage_reconciliation_events_total{event,outcome}`
* `sequency_brokerage_identity_events_total{event,outcome}`

**Not available yet (do not look for them):** webhooks (`P3A`), manual refresh
and disconnect (`P3C`), account preference updates (`P3C`), and any execution
path — a SnapTrade connection is deliberately not reachable as an execution
provider (companion §12.1, data#1848).
