# Crypto API keys and BYO-RPC endpoints

> What the bdn, tpu, feeds and read scopes authorise, how to mint and revoke a key, and how per-tenant RPC and Jito endpoints are sealed and used on a submit.

The **API keys** screen in the Crypto console owns two credentials that
every submit path depends on:

- a **scoped bearer token** (API key) that the client presents on the
  QUIC surfaces, and
- a **per-tenant landing endpoint** (your own Solana RPC, plus an
  optional Jito block-engine URL) that FirstByte uses to actually
  land your transaction.

Both are scoped to the organization selected in the console. The screen
disables key minting and endpoint configuration when you are not signed
in or when no organization resolves.

| Surface | tRPC procedure | Input | Returns |
| ------- | -------------- | ----- | ------- |
| List keys | `developers.keys` | `orgId` | key rows |
| List QUIC endpoints | `developers.endpoints` | (none) | endpoint rows |
| Mint a key | `apiKeys.create` | `{ orgId, name, scopes }` | `{ name, token_cleartext }` |
| Revoke a key | `apiKeys.revoke` | `{ orgId, id }` | — |
| Read endpoint config | `bdnRpc.get` | `orgId` | `{ hasRpc, hasJito, rpcHost, jitoHost }` |
| Set endpoints | `bdnRpc.set` | `{ orgId, rpcUrl?, jitoUrl? }` | — |
| Clear endpoints | `bdnRpc.clear` | `{ orgId, which }` | — |

## Scopes: bdn, tpu, feeds, read

A key carries a set of scopes, chosen at creation. The console offers
exactly four, and at least one must be selected — otherwise the create
panel refuses with `Select at least one scope`.

| Scope | Authorises | Used by |
| ----- | ---------- | ------- |
| `bdn` | The BDN submit surface — hand a signed transaction to the network and let FirstByte pick the leader. | `Client::bdn_submit(&signed_tx, Leader::Auto)` |
| `tpu` | The direct TPU land route, where you name the validator yourself via `?host=&port=`. | `POST /tpu?host=<validator-host>&port=<quic-port>` |
| `feeds` | MoQT feed subscription, e.g. `sol/feed/mainnet/slots`. | `bdn.subscribe(...)` |
| `read` | The scope preselected when the create panel opens. It is not one of the three submit/subscribe surfaces above — use it for keys that only read state back. |  |

Scopes are additive on a single key: a key with `["bdn","feeds"]` can
both submit through the BDN and subscribe to feeds. There is no scope
editing after creation — to change the scope set, mint a replacement
key and revoke the old one (see
[Rotating and revoking without dropping traffic](#rotating-and-revoking-without-dropping-traffic)).

The scopes on each key are rendered in the **Scope** column of the API
keys table, so you can audit which tokens hold submit rights without
looking at the tokens themselves.

## Minting a key

Click **Create key**. The panel asks for two things:

| Field | Validation |
| ----- | ---------- |
| **Name** | Required, at most 64 characters. Shown in the keys table and in the reveal banner. |
| **Scopes** | At least one of `bdn`, `tpu`, `feeds`, `read`. |

On submit the console calls `apiKeys.create({ orgId, name, scopes })`.
The name is trimmed before it is sent. Errors from the mutation are
rendered inline next to the **Create key** button, so a rejected scope
set or duplicate name shows up on the panel rather than as a toast.

Pass the resulting token as a bearer credential on the QUIC surfaces:

```bash
export CLUTCHCALL_API_KEY=…

curl --http3 \
  "https://<crypto-authority>/tpu?host=<validator-host>&port=<quic-port>" \
  -H "Authorization: Bearer $CLUTCHCALL_API_KEY" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @tx.bin
```

The SDKs read the same value:

```ts
import { Bdn } from "@clutchcall/bdn";
const bdn = new Bdn({ key: process.env.CLUTCHCALL_API_KEY });
```

```rust
let c = Client::new("<crypto-authority>")
    .bearer(std::env::var("CLUTCHCALL_API_KEY")?);
```

## The one-time cleartext reveal

`apiKeys.create` returns `token_cleartext` exactly once, in the mutation
response. The console renders it in a highlighted banner immediately
after minting, with a copy button, and the banner is the only place the
full token ever appears. Dismissing the banner discards it from the
client — there is no procedure that reads it back.

After that, the keys table shows only `token_preview` followed by a
mask. Use the preview to correlate a row with a deployed secret; use the
name and tags to correlate it with a workload.

If you lose a token before storing it, the recovery path is to mint a
replacement and revoke the lost one.

## Rotating and revoking without dropping traffic

Revoking is a single call, `apiKeys.revoke({ orgId, id })`, wired to the
trash icon on each row. Revoked keys are not deleted: the row stays in
the table, dimmed, with the action cell replaced by the text `revoked`.
That history is deliberate — it lets you see that a compromised key was
retired rather than silently removed.

Because revocation is immediate and there is no grace period, rotate in
this order:

1. **Mint the replacement** with the same scope set and a name that
   identifies the new generation (for example `prod-submitter-2`).
2. **Deploy it** to the workloads that hold the old token.
3. **Watch `Last used`** on the old row. The keys table renders
   `last_used_at` as a local timestamp, or `never` if the key was never
   presented. Once the old key stops advancing, no traffic is still
   using it.
4. **Revoke the old key.**

Step 3 is the whole reason `last_used_at` is on the table. Revoking
before it goes stale will cut off whatever is still presenting that
token.

## Tagging and filtering keys

Keys are taggable resources of kind `crypto_api_key`. Each row has a tag
cell, and the card header carries a tag filter that reports how many of
the total keys match the current filter. Tags are scoped to the same
organization as the keys.

This is the practical way to answer "which keys does the trading service
hold" once you have more rows than fit on a screen, since the tokens
themselves are masked.

## BYO-RPC: why every tenant lands through its own endpoint

FirstByte does not land your transactions through a shared RPC pool.
Every tenant submits through **its own** endpoint, and the endpoint must
exist before you can submit. The **Your RPC endpoints · BYO-RPC** card
is where you register it.

| Field | Requirement |
| ----- | ----------- |
| **Solana RPC (sendTransaction)** | `https://` URL. This is your own provider endpoint (Helius, Triton, or similar). It may embed a credential in the query string, e.g. `?api-key=…`. |
| **Jito block-engine (bundles)** | Optional `https://` URL. Only needed for MEV bundles. |

Validation is strict about the scheme: both fields reject anything that
is not a valid `https` URL, and the form refuses to save with
`Enter at least one endpoint URL` if both fields are empty.

Saving calls `bdnRpc.set({ orgId, rpcUrl?, jitoUrl? })`. Only the fields
you actually typed are included in the payload — the console omits a
blank field, and the placeholder says so
(`•••••••• (leave blank to keep)`). So you can update the Jito URL
without re-entering the RPC URL, and vice versa.

## Adding a Jito block-engine URL for bundles

The Jito field is the bundle path. If you never submit bundles, leave it
unset; the card will show a `no Jito` pill and that is a valid steady
state. If you do submit bundles, set it to your block-engine bundles
endpoint, for example a URL of the shape
`https://<region>.block-engine.jito.wtf/api/v1/bundles`.

The two endpoints are tracked independently — `bdnRpc.get` returns
`hasRpc` and `hasJito` as separate booleans, and the card renders a
separate pill for each. Having an RPC endpoint set does not imply bundle
capability.

## How sealed endpoints are stored and what is shown back

Endpoint URLs are **sealed at rest and never shown back**. Both inputs
are password fields with `autoComplete="off"`, so the browser does not
retain them either.

What `bdnRpc.get` returns is deliberately narrow:

| Field | Meaning |
| ----- | ------- |
| `hasRpc` | An RPC endpoint is configured. Rendered as the `RPC set` / `no RPC` pill. |
| `hasJito` | A Jito block-engine URL is configured. Rendered as the `Jito set` / `no Jito` pill. |
| `rpcHost` | The host portion of the stored RPC URL, echoed in the field hint as `configured · <host>`. |
| `jitoHost` | The host portion of the stored Jito URL, echoed the same way. |

The host is echoed and nothing else. That is enough to confirm *which
provider* you are landing through without exposing the API key that is
embedded in the query string. If you need to verify the full URL, you
cannot read it back — re-save it.

**Removing endpoints.** The **Remove all** button calls
`bdnRpc.clear({ orgId, which: "all" })` and drops both the RPC and the
Jito URL. The button only appears when at least one of them is set.
After clearing, both pills revert to their `no …` state.

## When no endpoint is configured

The BYO-RPC card is the gate on submitting, so the failure modes cluster
around it:

- **No RPC endpoint set.** The card shows `no RPC`. There is no shared
  fallback endpoint to land through — configure the RPC URL before your
  first submit on the `bdn` or `tpu` surfaces.
- **No Jito URL set.** The card shows `no Jito`. Bundle submission has
  no block engine to reach. Non-bundle submits are unaffected.
- **Not signed in, or no organization.** The card replaces both fields
  with a **Sign in to configure** state, because endpoint configuration
  is scoped to an organization. The **Create key** button is disabled
  for the same reason, with the tooltip naming which of the two
  conditions failed.
- **A rejected save.** `bdnRpc.set` errors are rendered inline beside
  the **Save endpoints** button, using the message the procedure
  returned.
- **A non-HTTPS URL.** Caught client-side before any call is made; the
  offending field is marked invalid.

A key with the right scope and no configured endpoint is not enough to
land a transaction. Both halves of this screen have to be in place.

## QUIC endpoint discovery

The **QUIC endpoints** table is populated by `developers.endpoints` and
lists, for each surface, the HTTP method, the path, and the QUIC
authority to connect to, plus a short note on what the surface does.

Everything reaches clients over QUIC. There is no gRPC and no WebSocket
client surface, and **no fallback to TCP** — a connection that cannot
negotiate QUIC is refused. Clients discover the QUIC port from a DNS
`HTTPS` resource record on the crypto authority, which advertises
`alpn=h3` and the port. The advertised ALPN set covers `h3` and
`solana-tpu`.

## Related

- [Authentication](/concepts/authentication) — how API keys and short-lived tokens divide up
- [Telemetry](/platform/telemetry) — the metric and trace streams that record these requests
