# Credential-less discovery

Let a user find their bills from their **identity** — no per-biller usernames or passwords. They enter a few identity fields, a provider returns the accounts it discovered across billers, and they one-tap to link them into the same Connect pipeline you already use.

> **Warning — Sandbox-only, default-OFF, preview surface**
>
> Credential-less discovery is a **sandbox/mock** feature and is **disabled by default**. The mock aggregator returns synthetic liabilities for a synthetic identity — it never touches a real credit bureau, real accounts, or real PII. The backend endpoint is gated by the `CREDENTIALLESS_DISCOVERY_ENABLED` flag on sandbox-service and returns `404` while off. A real Method / Spinwheel / bureau adapter can slot in later behind the `CredentiallessDiscoveryProvider` seam — but only after the FCRA/GLBA permissible-purpose + legal sign-off called out in [Before production](#before-production).

## What it is & when to use it

The default Connect flow asks the user for the credentials of each biller they want to link. **Credential-less discovery** is a different entry point: the user supplies their **identity** instead, an aggregator returns the accounts it can attribute to that identity, and the user picks which ones to link. "Credential-less" means **no per-biller username/password** — it does *not* mean no link token.

The link token still rides over the SDK `INIT` message exactly like the embedded Connect flow. Once the user selects accounts, the **existing account-selection → link pipeline completes** and fires the `link.completed` webhook — identical to a credentialed link. Nothing downstream changes.

> **Note — It reuses the Connect pipeline you already have**
>
> Discovery injects into the standard sandbox flow at the account-selection step. You mint the same sandbox link token, and the same `link.completed` webhook is the authoritative signal that a link succeeded. See the [Elements SDK](/docs/guides/elements-sdk) and [Webhooks](/docs/guides/webhooks) guides for the pieces that carry over unchanged.

## Quick start

Construct a `BillerApiElements` client, then call `discover()` with a sandbox link token and a host element. Discovery renders **inline** into the element you provide — there is no modal overlay.

**JavaScript**

```javascript
import { BillerApiElements } from 'billerapi-js';

// 1. One client for every flow
const elements = new BillerApiElements({
  clientId: 'your_client_id',
  environment: 'sandbox', // credential-less discovery is sandbox-only
});

// 2. Launch inline discovery with a sandbox link_token from your server
const handler = elements.discover({
  linkToken: 'lt_xxx',                                  // from your server
  hostElement: document.getElementById('billerapi-discover'), // inline mount target
  theme: 'light',                                       // 'light' | 'dark' | ElementsTheme
  onSuccess: (publicToken, metadata) => {
    // Send publicToken to your server to exchange for an access token.
    // The link.completed webhook is the source of truth.
    console.log('Linked!', metadata);
  },
  onExit: (error) => {
    if (error) console.error('Discovery error:', error.code, error.message);
  },
  onEvent: (eventName, metadata) => {
    // Optional lifecycle telemetry
  },
});

// 3. Render the secure iframe inline (no modal). Returns a Promise.
await handler.mount();

// handler.isMounted() -> boolean;  handler.unmount() tears it down.
```

**React**

```react
import { useRef, useEffect } from 'react';
import { useDiscover } from 'billerapi-react';

function DiscoverBills({ linkToken }) {
  const hostRef = useRef(null);

  const { mount, unmount, isMounted, ready, error } = useDiscover({
    linkToken,
    hostElement: hostRef.current, // inline mount target
    onSuccess: async (publicToken) => {
      await fetch('/api/exchange-token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ public_token: publicToken }),
      });
    },
    onExit: (err) => {
      if (err) console.error(err.code, err.message);
    },
  });

  // Mount inline once the SDK is ready and the host element exists.
  useEffect(() => {
    if (ready && hostRef.current) mount();
    return () => unmount();
  }, [ready, mount, unmount]);

  if (error) return <p>Could not start discovery. Try again.</p>;

  return <div ref={hostRef} />;
}
```

> **Note — Packages**
>
> The vanilla SDK is `billerapi-js` (exposes `elements.discover()`); the React bindings are `billerapi-react` (exposes the `useDiscover` hook). Both are the same packages that power the rest of the Elements SDK.

## `discover()` & `useDiscover()`

Both take the same options object. The vanilla factory returns a handler; the hook returns the same handler methods plus reactive `ready` / `error` state.

| Option | Type | Required | Description |
| --- | --- | --- | --- |
| linkToken | string | Yes | Sandbox link token minted on your server; carried over the iframe INIT message. |
| hostElement | HTMLElement | Yes | The element to render the inline iframe into (no modal overlay). |
| onSuccess | (publicToken, metadata) => void | Yes | Fires once the user links their discovered accounts. Exchange the public token server-side. |
| theme | 'light' \| 'dark' \| ElementsTheme | No | Light/dark keyword or a full theme object (same shape as the other flows). |
| onExit | (error?) => void | No | Fires on user-cancel (no argument) or on error (an object with code + message). |
| onEvent | (eventName, metadata) => void | No | Optional lifecycle telemetry stream. |

**Handler** (returned by `discover()`, and surfaced by `useDiscover()`):

- `mount(): Promise<void>` — render the inline iframe into `hostElement`.
- `unmount(): void` — tear it down (idempotent).
- `isMounted(): boolean` — current mount state.

The hook additionally returns `ready` (SDK loaded & safe to `mount()`) and `error`.

## The identity model

The user submits four identity fields — all required, all snake_case on the wire. This mock deliberately collects **no SSN and no date of birth**: it is a PII-light surface by design.

| Field | Type | Notes |
| --- | --- | --- |
| full_name | string | Required. |
| phone | string | Required. |
| email | string | Required; validated as an email address. |
| postal_code | string | Required. |

> **Note — No SSN, no DOB**
>
> A production bureau adapter would collect FCRA-gated identifiers behind its own consent layer — those fields are deliberately out of this mock. Do not extend the sandbox surface to accept them without the sign-off in [Before production](#before-production).

## The sandbox endpoint

The SDK drives this for you, but it maps to a single sandbox endpoint. The **link token in the path is the credential** — there is no `API key` header on this call, mirroring the other link-token routes on the sandbox host.

**discover-by-identity**

```bash
# Sandbox host serves sb_ / bak_test_ tokens.
# The {link_token} in the path is the credential (no secret header).
curl -X POST https://sandbox.api.billerapi.com/v1/link-tokens/lt_xxx/discover-by-identity \
  -H "Content-Type: application/json" \
  -d '{
    "full_name": "Ada Lovelace",
    "phone": "5551234242",
    "email": "ada@example.com",
    "postal_code": "94105"
  }'

# => 202 Accepted
# { "success": true, "discovered_count": 2 }
#
# When the CREDENTIALLESS_DISCOVERY_ENABLED flag is OFF (the default):
# => 404 Not Found
```

On success the sandbox pre-loads the link session with the discovered accounts and emits the standard account-selection step. The user selects accounts, the existing exchange completes, and the `link.completed` webhook fires — the same terminal signal as any credentialed link.

> **Warning — Confirm outcomes server-side**
>
> The `onSuccess` callback is a UX hint that runs in the user's browser. Treat the `link.completed` webhook as the authoritative record before fulfilling anything. See the [Webhooks guide](/docs/guides/webhooks).

## The vendor-swappable provider seam

Discovery is implemented behind a provider interface so the aggregator is a swappable dependency. Today the only binding is a deterministic **mock aggregator** that returns synthetic liabilities. A real **Method / Spinwheel / bureau** adapter can be dropped in later by implementing the same interface — no change to the SDK, the endpoint contract, or the downstream link pipeline.

**CredentiallessDiscoveryProvider (server-side seam)**

```typescript
// The identity the caller submits (PII-light: no SSN / no DOB).
interface DiscoveryIdentity {
  full_name: string;
  phone: string;
  email: string;
  postal_code: string;
}

// A single discovered account/liability the user can one-tap to link.
interface DiscoveredLiability {
  id: string;          // 'sb_disc_acct_<hex>'
  biller_id: string;   // synthetic in the mock, e.g. 'sb_utility'
  biller_name: string; // 'Sandbox Utility'
  account_name: string;
  mask: string;        // '****4242'
  type: string;        // 'utility' | 'telecom' | 'insurance' | ...
}

// The swap point. The mock implements this today; a real vendor adapter
// (Method / Spinwheel / a bureau) implements the same method later.
interface CredentiallessDiscoveryProvider {
  discover(identity: DiscoveryIdentity): Promise<DiscoveredLiability[]>;
}
```

> **Note — Mock behavior**
>
> The mock aggregator is deterministic — it keys off the submitted identity and returns a small set of liabilities across billers. It is stateless and touches no real provider. Swapping in a real adapter is an *additive* binding change on the server, gated by the requirements below.

## Before production

Everything above is sandbox/mock. Pointing the `CredentiallessDiscoveryProvider` seam at a **real** aggregator or credit bureau moves you into regulated territory. A real adapter must not ship until **all** of the following are in place, with legal sign-off:

> **Warning — This gates any real-bureau adapter**
>
> Do not enable a live provider — and do not flip `CREDENTIALLESS_DISCOVERY_ENABLED` in a non-sandbox environment — until the checklist below is satisfied and signed off.

- **FCRA permissible purpose.** Pulling consumer liabilities from a credit bureau (or a bureau-derived aggregator) is a consumer-report activity under the Fair Credit Reporting Act. You need a documented permissible purpose, the user's informed consent/authorization captured before the pull, and the required disclosures.
- **GLBA safeguards.** The Gramm-Leach-Bliley Act privacy + safeguards rules apply to the nonpublic personal information involved. You need privacy notices, access controls, encryption in transit and at rest, retention/deletion policies, and a documented data-handling program.
- **Expanded PII handling.** A real bureau match typically needs FCRA-gated identifiers (e.g. SSN / DOB) that the mock intentionally omits. Any field beyond `full_name`, `phone`, `email`, `postal_code` must go through its own consent layer, minimization review, and secure storage design.
- **Vendor due diligence & contracts.** Selecting a provider (Method, Spinwheel, or a bureau) requires vendor security/compliance review, a data processing agreement, and confirmation the vendor's own permissible-purpose and consumer-consent posture matches your use case.
- **Legal sign-off.** All of the above must be reviewed and approved by legal/compliance before the seam is bound to a live provider in any non-sandbox environment.

## Related

- [Elements SDK](/docs/guides/elements-sdk) — billerapi-js / billerapi-react — the client that hosts discover()
- [Link a Biller Account](/docs/guides/link-account) — The account-selection → link pipeline discovery feeds into
- [Webhooks Guide](/docs/guides/webhooks) — link.completed is the authoritative link signal
- [Go Live Checklist](/docs/guides/go-live) — Production rollout gates
