# DinastiAPI — Connector: complete LLM build guide
> Paste this whole file into any LLM (Claude, GPT, Gemini, …) and ask it to embed the
> **DinastiAPI Connector** — a widget that pairs a WhatsApp number (QR, phone code, or
> passkey) inside any web app, without ever exposing the real instance token to the
> browser. It is self-contained: the embed, the server-side token mint (Node, Next.js,
> PHP, Laravel, Go), the full endpoint reference, events, theming/i18n, the passkey
> solver, security rules, and the exact wire shapes are all here.
>
> - **Two placeholders**: `{BASE_URL}` (your DinastiAPI host, e.g. `https://your-host`)
> and `{REAL_TOKEN}` (the per-instance user token — server-side only, NEVER in a browser).
> - **Golden rule**: the browser only ever holds an **ephemeral** token (`ct_…`).
---
## 0. Mental model (one paragraph)
Your backend calls `POST {BASE_URL}/session/connector/token` with the **real** instance
token and gets back a short-lived **ephemeral** token (`ct_…`, default 10 min). You send
only that ephemeral token to the browser. The browser loads
`
```
`window.ZZ.connector` (alias `window.ZZGConnector`):
| Method | Description |
|--------|-------------|
| `open(opts) → Promise` | Opens the modal; resolves `true` when paired, `false` if closed first. |
| `close()` | Tear down the modal. |
| `on(ev, fn)` / `off(ev, fn)` | Events: `open`, `status`, `qr`, `connected`, `error`, `close`. |
**Options**: `token` (required), `baseUrl`, `theme`, `locale`, `methods{qr,phone,passkey}`,
`messages` (override i18n strings), `container` (CSS selector/element; default body),
`pollIntervalMs` (2000), `subscribe` (["Message"]), `autoConnect` (true — set false when the
instance is already connected), `extensionInstallUrl {chrome,firefox}`, `bookmarkletUrl`.
Each event `ev` also maps to an `on` callback in `open(opts)` (e.g. `onConnected`).
The widget renders in a Shadow DOM (style-isolated) and is dependency-free.
---
## 3. Server-side mint — the only code you write
The one integration point is a backend route that mints the ephemeral token and returns
only that to the browser. Full runnable examples: `examples/connector/{node-express,nextjs,php,laravel,go}`.
**Node / Express**
```js
app.post("/connector-token", async (_req, res) => {
const r = await fetch(`${process.env.DINASTIAPI_URL}/session/connector/token`, {
method: "POST",
headers: { "Content-Type": "application/json", token: process.env.DINASTIAPI_TOKEN },
});
const env = await r.json();
const data = env.data ?? env;
res.json({ token: data.token, expiresIn: data.expiresIn });
});
```
**Next.js (App Router)** — `app/api/connector-token/route.ts`
```ts
export async function POST() {
const r = await fetch(`${process.env.DINASTIAPI_URL}/session/connector/token`, {
method: "POST",
headers: { "Content-Type": "application/json", token: process.env.DINASTIAPI_TOKEN! },
cache: "no-store",
});
const env = await r.json();
const data = env?.data ?? env;
return Response.json({ token: data.token, expiresIn: data.expiresIn });
}
```
**PHP**
```php
$ch = curl_init(getenv('DINASTIAPI_URL')."/session/connector/token");
curl_setopt_array($ch, [CURLOPT_POST=>true, CURLOPT_RETURNTRANSFER=>true,
CURLOPT_HTTPHEADER=>['Content-Type: application/json','token: '.getenv('DINASTIAPI_TOKEN')]]);
$env = json_decode(curl_exec($ch), true); $data = $env['data'] ?? $env;
echo json_encode(['token'=>$data['token'],'expiresIn'=>$data['expiresIn']]);
```
**Laravel**
```php
$resp = Http::withHeaders(['token' => env('DINASTIAPI_TOKEN')])
->post(env('DINASTIAPI_URL')."/session/connector/token");
$data = $resp->json()['data'] ?? $resp->json();
return response()->json(['token' => $data['token'], 'expiresIn' => $data['expiresIn']]);
```
**Go (stdlib)**
```go
req, _ := http.NewRequest("POST", zuckURL+"/session/connector/token", nil)
req.Header.Set("token", zuckTok)
resp, _ := http.DefaultClient.Do(req)
body, _ := io.ReadAll(resp.Body)
var env map[string]any; json.Unmarshal(body, &env)
data, _ := env["data"].(map[string]any)
json.NewEncoder(w).Encode(map[string]any{"token": data["token"], "expiresIn": data["expiresIn"]})
```
---
## 4. Passkey — "covers all cases" for free
Some accounts require a **passkey** instead of QR/phone. WebAuthn binds the credential to
its RP origin: WhatsApp's passkey is bound to `whatsapp.com`, and the browser only runs
`navigator.credentials.get()` from a page whose origin is `https://web.whatsapp.com`.
Neither the dashboard nor an embed on your origin can run it. So the connector is a bridge:
1. **Probe** — `POST /connector/passkey/challenge` forces WhatsApp to hand over the
WebAuthn options on demand (deterministic; `503 passkey_unavailable` for non-passkey
accounts).
2. **Bridge** — a hidden iframe (`{BASE_URL}/connector.html`) relays to the DinastiAPI
passkey extension (content script on `web.whatsapp.com`).
3. **Solve** — the extension runs `navigator.credentials.get()` with the server's
`publicKey` options **verbatim** and returns the assertion (base64url).
4. **Submit** — `POST /connector/passkey/response`, then `confirm` (or auto when
`skipHandoffUX`), then the poll loop sees `loggedIn`.
Because the server's options are passed straight to the browser, the device presents
whatever it supports — Touch ID, Windows Hello, a security key, or a phone over
hybrid/Bluetooth. No per-case handling; the universality is the WebAuthn API's.
**Solvers**: (1) the `zzg-passkey-extension/` (MV3 Chrome/Edge/Brave, `.xpi` Firefox) —
keep a `web.whatsapp.com` tab open; (2) the `/passkey-bookmarklet.html` bookmarklet
(zero install, any browser/OS incl. Windows; accepts `ct_…` tokens; deep-linkable with
`?apiUrl=…&token=…`). The widget detects the extension and, if absent, shows install links
+ the bookmarklet.
---
## 5. Security (do this)
- **Mint server-side.** The real token authenticates the mint and stays on your backend.
Only the ephemeral `ct_…` token reaches the browser; it works only on `/connector/*`.
- **CORS allowlist.** For cross-origin embeds, add your app's origin to
`CONNECTOR_CORS_ORIGINS` on the DinastiAPI host (comma-separated; `*` = dev only).
Non-allow-listed origins get no CORS headers and the browser blocks them.
- **Env** (DinastiAPI host): `CONNECTOR_CORS_ORIGINS`, `CONNECTOR_TOKEN_TTL` (default 600s),
`CONNECTOR_TOKEN_RATE_PER_MIN` (default 60).
- **Never** put the real token, or `NEXT_PUBLIC_*`-style secrets, in client code.
---
## 6. Operational notes
- **In-memory state** (ephemeral tokens + passkey state) is per-process — lost on restart,
not shared across replicas. For multi-replica, pin connector traffic to one replica or
move the store to Redis (future work).
- **Handoff window** ~5 min: if the passkey handoff proof lapses, reconnect for a fresh
challenge.
- **Passkey response** is rate-limited (1 / 5s per user); wait a moment before retrying a
cancelled prompt.
- Passkey server-side acceptance is validated live; the bookmarklet fallback always works.
---
## 7. Copy-paste task prompt for your LLM
> Using the DinastiAPI Connector guide above, add "Connect WhatsApp" to my app.
> Backend `{stack}` at `{BASE_URL}` with instance token in env. 1) Add a server route
> `POST /connector-token` that mints an ephemeral token via
> `POST {BASE_URL}/session/connector/token` (real token server-side) and returns only
> `{ token }`. 2) On the page, load `{BASE_URL}/connector.js`, fetch `/connector-token`,
> and call `ZZ.connector.open({ token, baseUrl: "{BASE_URL}", methods:{qr:true,phone:true,passkey:true} })`.
> 3) Remind me to add my app's origin to `CONNECTOR_CORS_ORIGINS` on the DinastiAPI host.
> Output the full files for `{stack}`.
Full reference: `/docs/connector.md`. OpenAPI: `/api/spec.yml` (tag **Connector**).