# Tempo Console

Tempo Console is the web app where developers manage organizations, projects, API keys, usage, billing, and team access for the Tempo API.

> [!NOTE]
> The reference layouts on this page compose library components with `style`. They are not shipped components, so `@tempoxyz/ds` exports no `ApiKeysPage` or `CreateKeyForm`. Copy the composition into your app and adapt it.

## Overview

Tempo Console runs at [console.tempo.xyz](https://console.tempo.xyz) and is invite-only. You log in with an email address, a Google account, or a passkey, then choose an organization.

| Resource     | What it holds                                                        |
| ------------ | -------------------------------------------------------------------- |
| Organization | The team, projects, billing, and aggregate usage                     |
| Project      | One app or integration, and the API keys attributed to it            |
| Environment  | Production or sandbox credentials, usage, and billing, kept separate |
| API key      | A project-scoped credential for calling Tempo APIs                   |

People who use it:

* **Developers** who create keys, test in sandbox, and check usage by project.
* **Owners** who manage billing, change roles, and remove members.
* **Admins** who invite members and review pending invitations.

## Surfaces

The console is a web app, built with `@tempoxyz/ds`. No mobile app is documented. The Tempo API exposes the same organization resources through management endpoints, which keys with the `management:read` and `management:write` scopes can call.

## Key Screens

### API Keys

```tsx
import { AlertDialog, AppShell, Banner, Button, Empty, Identicon, Menu, Select, SidebarMenu, style, Surface, Table } from '@tempoxyz/ds'
import {
  Analytics,
  CreditCard,
  Folder,
  Key,
  LogOut,
  MoreHorizontal,
  Plus,
  Sort,
  Users,
} from '@tempoxyz/ds/icons'
import { useState } from 'react'

export const fill = true
type Environment = 'production' | 'sandbox'

const prefixes = { production: 'tempo:sk:', sandbox: 'tempo_sandbox:sk:' }

const projects = {
  all: 'All projects',
  'checkout-service': 'checkout-service',
  'payouts-worker': 'payouts-worker',
}

const keys = [
  {
    created: 'Oct 14, 2026',
    environment: 'production',
    id: 'key_01',
    last4: 'a91c',
    lastUsed: '2 minutes ago',
    name: 'checkout-service production',
    project: 'checkout-service',
  },
  {
    created: 'Sep 30, 2026',
    environment: 'production',
    id: 'key_02',
    last4: '07e1',
    lastUsed: 'Yesterday',
    name: 'payouts-worker production',
    project: 'payouts-worker',
  },
  {
    created: 'Oct 18, 2026',
    environment: 'sandbox',
    id: 'key_03',
    last4: '5a22',
    lastUsed: '1 hour ago',
    name: 'checkout-service CI',
    project: 'checkout-service',
  },
  {
    created: 'Oct 19, 2026',
    environment: 'sandbox',
    id: 'key_04',
    last4: 'c0d9',
    lastUsed: 'Never',
    name: 'jake local development',
    project: 'payouts-worker',
  },
]

export function Demo() {
  const [environment, setEnvironment] = useState<Environment>('sandbox')
  const [project, setProject] = useState<string | null>('all')
  const [revoked, setRevoked] = useState<readonly string[]>([])
  const [target, setTarget] = useState<(typeof keys)[number]>()

  const visible = keys.filter(
    (key) =>
      key.environment === environment &&
      !revoked.includes(key.id) &&
      (project === 'all' || key.project === project),
  )

  return (
    <div {...styles.frame()}>
      <AppShell.Root style={{ height: '100%', minHeight: 0 }}>
        <AppShell.Sidebar aria-label="Console" style={{ height: '100%' }}>
          <AppShell.SidebarHeader>
            <Menu.Root>
              <Menu.Trigger
                aria-label="Organization and environment"
                render={<SidebarMenu.Item render={<button type="button" />} />}
              >
                <Identicon
                  address="0x71C7656EC7ab88b098defB751B7401B5f6d8976F"
                  aria-hidden
                  size="32"
                />
                <span {...styles.stack()}>
                  <span {...styles.name()}>Acme Payments</span>
                  <span {...styles.caption()}>
                    {environment === 'sandbox' ? 'Sandbox' : 'Production'}
                  </span>
                </span>
                <Sort />
              </Menu.Trigger>
              <Menu.Popup align="start">
                <Menu.Group>
                  <Menu.GroupLabel>Environment</Menu.GroupLabel>
                  <Menu.RadioGroup
                    onValueChange={(value) => setEnvironment(value as Environment)}
                    value={environment}
                  >
                    <Menu.RadioItem value="production">Production</Menu.RadioItem>
                    <Menu.RadioItem value="sandbox">Sandbox</Menu.RadioItem>
                  </Menu.RadioGroup>
                </Menu.Group>
              </Menu.Popup>
            </Menu.Root>
          </AppShell.SidebarHeader>
          <AppShell.SidebarBody>
            <SidebarMenu.Root>
              <SidebarMenu.Group aria-label="Organization">
                <SidebarMenu.Item href="#projects">
                  <Folder />
                  Projects
                </SidebarMenu.Item>
                <SidebarMenu.Item active href="#api-keys">
                  <Key />
                  API Keys
                </SidebarMenu.Item>
                <SidebarMenu.Item href="#usage">
                  <Analytics />
                  Usage
                </SidebarMenu.Item>
                <SidebarMenu.Item href="#billing">
                  <CreditCard />
                  Billing
                </SidebarMenu.Item>
                <SidebarMenu.Item href="#team">
                  <Users />
                  Team
                </SidebarMenu.Item>
              </SidebarMenu.Group>
            </SidebarMenu.Root>
          </AppShell.SidebarBody>
          <AppShell.SidebarFooter>
            <Menu.Root>
              <Menu.Trigger
                aria-label="Account menu"
                render={<SidebarMenu.Item render={<button type="button" />} />}
              >
                <Identicon
                  address="0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
                  aria-hidden
                  size="24"
                />
                <span {...styles.grow()}>jake@acme.dev</span>
              </Menu.Trigger>
              <Menu.Popup side="top">
                <Menu.Item>
                  <LogOut />
                  Log out
                </Menu.Item>
              </Menu.Popup>
            </Menu.Root>
          </AppShell.SidebarFooter>
        </AppShell.Sidebar>

        <AppShell.Main style={{ overflowY: 'auto' }}>
          <AppShell.Header>
            <AppShell.Title>API Keys</AppShell.Title>
            <div {...styles.actions()}>
              <Select.Root items={projects} onValueChange={setProject} value={project}>
                <Select.Trigger aria-label="Project" size="small">
                  <Select.Value />
                  <Select.Icon />
                </Select.Trigger>
                <Select.Popup size="small">
                  {Object.entries(projects).map(([value, label]) => (
                    <Select.Item key={value} label={label} value={value}>
                      <Select.ItemText>{label}</Select.ItemText>
                    </Select.Item>
                  ))}
                </Select.Popup>
              </Select.Root>
              <Button size="small">
                <Plus />
                New key
              </Button>
              <AppShell.Trigger />
            </div>
          </AppShell.Header>

          {environment === 'sandbox' && (
            <Banner intent="warning" {...styles.banner()}>
              <Banner.Content>
                Sandbox. Keys here reach only non-mainnet chains, such as testnet.
              </Banner.Content>
            </Banner>
          )}

          <Surface>
            {visible.length === 0 ? (
              <Empty.Root>
                <Empty.Icon>
                  <Key />
                </Empty.Icon>
                <Empty.Title>No API keys</Empty.Title>
                <Empty.Description>
                  Create a key to call Tempo APIs from this project.
                </Empty.Description>
                <Empty.Actions>
                  <Button size="small">New key</Button>
                </Empty.Actions>
              </Empty.Root>
            ) : (
              <div {...styles.scroll()}>
                <Table.Root>
                  <Table.Caption>
                    {environment === 'sandbox' ? 'Sandbox' : 'Production'} keys,{' '}
                    {project === 'all' ? 'all projects' : project}
                  </Table.Caption>
                  <Table.Head>
                    <Table.Row>
                      <Table.HeaderCell>Name</Table.HeaderCell>
                      <Table.HeaderCell>Project</Table.HeaderCell>
                      <Table.HeaderCell>Key</Table.HeaderCell>
                      <Table.HeaderCell>Created</Table.HeaderCell>
                      <Table.HeaderCell>Last used</Table.HeaderCell>
                      <Table.HeaderCell justify="end">
                        <span {...styles.hidden()}>Actions</span>
                      </Table.HeaderCell>
                    </Table.Row>
                  </Table.Head>
                  <Table.Body>
                    {visible.map((key) => (
                      <Table.Row key={key.id}>
                        <Table.Cell>{key.name}</Table.Cell>
                        <Table.Cell>{key.project}</Table.Cell>
                        <Table.Cell mono>
                          {prefixes[environment]}…{key.last4}
                        </Table.Cell>
                        <Table.Cell>{key.created}</Table.Cell>
                        <Table.Cell>{key.lastUsed}</Table.Cell>
                        <Table.Cell justify="end">
                          <Menu.Root>
                            <Menu.Trigger
                              aria-label={`Actions for ${key.name}`}
                              render={<Button shape="icon" size="small" variant="text" />}
                            >
                              <MoreHorizontal />
                            </Menu.Trigger>
                            <Menu.Popup align="end">
                              <Menu.Item intent="negative" onClick={() => setTarget(key)}>
                                Revoke key
                              </Menu.Item>
                            </Menu.Popup>
                          </Menu.Root>
                        </Table.Cell>
                      </Table.Row>
                    ))}
                  </Table.Body>
                </Table.Root>
              </div>
            )}
          </Surface>
        </AppShell.Main>
      </AppShell.Root>

      <AlertDialog.Root
        onOpenChange={(open) => {
          if (!open) setTarget(undefined)
        }}
        open={target !== undefined}
      >
        <AlertDialog.Popup>
          <AlertDialog.Title>Revoke {target?.name}?</AlertDialog.Title>
          <AlertDialog.Description>
            Requests that use this key stop authenticating. You can't undo this.
          </AlertDialog.Description>
          <div {...styles.confirm()}>
            <AlertDialog.Close render={<Button variant="secondary" />}>Keep key</AlertDialog.Close>
            <AlertDialog.Close
              onClick={() => {
                if (target) setRevoked([...revoked, target.id])
              }}
              render={<Button variant="negative" />}
            >
              Revoke key
            </AlertDialog.Close>
          </div>
        </AlertDialog.Popup>
      </AlertDialog.Root>
    </div>
  )
}

namespace styles {
  export const actions = style({ alignItems: 'center', columnGap: '2', display: 'flex' })

  export const banner = style({ marginBlockEnd: '4' })

  export const caption = style({ color: 'gray.900', margin: '0', typography: 'label.12' })

  export const confirm = style({
    columnGap: '2',
    display: 'flex',
    justifyContent: 'flex-end',
    marginTop: '8',
  })

  // The transform makes the frame the containing block for the mobile drawer.
  export const frame = style({
    borderRadius: '3xl',
    height: '36rem !custom',
    overflow: 'hidden',
    transform: 'translateZ(0) !custom',
    selectors: {
      // The maximized gallery preview shows the shell at full window height.
      ':root[data-maximized] &': { borderRadius: '0px !custom', height: '100vh !custom' },
    },
  })

  export const grow = style({ flex: '1 !custom', minWidth: '0 !custom' })

  // Visually hidden text still names the column for screen readers.
  export const hidden = style({
    clipPath: 'inset(50%) !custom',
    height: '1px !custom',
    overflow: 'hidden',
    position: 'absolute',
    whiteSpace: 'nowrap',
    width: '1px !custom',
  })

  export const name = style({ margin: '0', typography: 'label.14' })

  // Cells keep one line and the table scrolls sideways, so key prefixes never break mid-token.
  export const scroll = style({
    overflowX: 'auto',
    selectors: { '& :is(td, th)': { whiteSpace: 'nowrap' } },
  })

  export const stack = style({ display: 'flex', flex: '1 !custom', flexDirection: 'column' })
}
```

[View example source](https://github.com/tempoxyz/ds/blob/main/docs/src/examples/demos/Products/ConsoleKeys.tsx)

API Keys lists the keys in the selected environment, organization-wide by default. Switch to sandbox in the organization menu to see the banner and sandbox keys, or revoke a key from its row menu.

* `AppShell` and `SidebarMenu` hold the console navigation: Projects, API Keys, Usage, Billing, and Team.
* The organization menu in `AppShell.SidebarHeader` switches environment through `Menu.RadioGroup`.
* A `warning` `Banner` stays below the page header while sandbox is active.
* A `Select` in `AppShell.Header` filters keys by project, beside the `primary` New key button.
* `Table` lists each key's name, project, prefix and last four characters in a `mono` cell, created date, and last use.
* Each row's `Menu` holds Revoke key with `intent="negative"`, which opens an `AlertDialog`.
* `Empty` replaces the table when no key matches the filters.

This screen follows these patterns:

* [Tables and Lists](/docs/patterns/tables-and-lists) for the key table, its filter, and row actions.
* [Destructive Actions](/docs/patterns/destructive-actions) for revoking a key behind a confirmation.
* [Empty States](/docs/patterns/empty-states) for a project or environment with no keys.

### Create Key

```tsx
import { Button, Callout, Checkbox, CheckboxGroup, Field, Input, Rows, SecretValue, Select, style, Surface, Tag } from '@tempoxyz/ds'
import { useState } from 'react'

export const fill = true
const projects = { 'checkout-service': 'checkout-service', 'payouts-worker': 'payouts-worker' }

// The self-serve scopes and descriptions from the Tempo API scope catalog (`GET /v1/scopes`).
const scopes = [
  ['data:read', 'Read public chain data: tokens, transactions, transfers, blocks, and receipts.'],
  ['indexer:query', 'Run ad-hoc queries against the raw indexer passthrough.'],
  ['management:read', 'Read organization resources across projects and environments.'],
  [
    'management:write',
    'Create, update, and delete organization resources across projects and environments.',
  ],
  ['mpp:write', 'Validate and broadcast MPP credentials through Tempo.'],
  ['routes:read', 'Read organization route transfers, deposit addresses, and deposits.'],
  ['rpc-relay:read', 'Fill and submit transactions through the relay.'],
  ['rpc-relay:sponsor', 'Sponsor transaction fees through the relay as the managed fee payer.'],
] as const

export function Demo() {
  const [created, setCreated] = useState(false)
  const [keyName, setKeyName] = useState('')
  const [open, setOpen] = useState(false)
  const [project, setProject] = useState<string | null>('checkout-service')
  const [selected, setSelected] = useState<string[]>(['data:read', 'rpc-relay:read'])
  const [submitted, setSubmitted] = useState(false)

  const missing = submitted && keyName.trim() === ''

  const reset = () => {
    setCreated(false)
    setKeyName('')
    setSubmitted(false)
  }

  return (
    <div {...styles.page()}>
      <Surface {...styles.panel()}>
        <Surface.Header>
          <Surface.Title>{created ? 'Key created' : 'New key'}</Surface.Title>
          <Tag intent="warning" size="small">
            Sandbox
          </Tag>
        </Surface.Header>

        {created ? (
          <div {...styles.form()}>
            <Callout intent="warning">
              <Callout.Icon />
              <Callout.Content>
                Copy this key now and store it in a secret manager. You can't view it again.
              </Callout.Content>
            </Callout>
            <SecretValue label="API key" value="tempo_sandbox:sk:demo_8QnV2kXbT4mL9wRz7cYp3fHs" />
            <Rows.Root>
              <Rows.Row spacing="none">
                <Rows.Label>Name</Rows.Label>
                {keyName}
              </Rows.Row>
              <Rows.Row spacing="none">
                <Rows.Label>Project</Rows.Label>
                {project}
              </Rows.Row>
              <Rows.Row spacing="none">
                <Rows.Label>Scopes</Rows.Label>
                <span {...styles.scope()}>{selected.join(', ') || 'None'}</span>
              </Rows.Row>
            </Rows.Root>
            <div {...styles.actions()}>
              <Button onClick={reset}>Done</Button>
            </div>
          </div>
        ) : (
          <form
            noValidate
            onSubmit={(event) => {
              event.preventDefault()
              setSubmitted(true)
              if (keyName.trim() !== '') setCreated(true)
            }}
            {...styles.form()}
          >
            <Field.Root>
              <Field.Label>Project</Field.Label>
              <Select.Root items={projects} onValueChange={setProject} value={project}>
                <Select.Trigger>
                  <Select.Value placeholder="Select a project" />
                  <Select.Icon />
                </Select.Trigger>
                <Select.Popup>
                  {Object.entries(projects).map(([value, label]) => (
                    <Select.Item key={value} label={label} value={value}>
                      <Select.ItemText>{label}</Select.ItemText>
                    </Select.Item>
                  ))}
                </Select.Popup>
              </Select.Root>
            </Field.Root>

            <Field.Root invalid={missing}>
              <Field.Label>Name</Field.Label>
              <Input.Group>
                <Input.Control
                  autoComplete="off"
                  onChange={(event) => setKeyName(event.target.value)}
                  placeholder="checkout-service CI"
                  value={keyName}
                />
              </Input.Group>
              <Field.Description>
                Name the deployment or owner, so you can find the key when you rotate it.
              </Field.Description>
              <Field.Error match={missing}>Enter a name for the key.</Field.Error>
            </Field.Root>

            <Rows.Root>
              <Rows.Row
                action="expand"
                aria-expanded={open}
                onClick={() => setOpen(!open)}
                spacing="none"
              >
                <Rows.Info>
                  <Rows.Title>View scopes</Rows.Title>
                  <Rows.Description>
                    {selected.length} of {scopes.length} selected
                  </Rows.Description>
                </Rows.Info>
              </Rows.Row>
              {open && (
                <Rows.Panel>
                  <CheckboxGroup aria-label="Scopes" onValueChange={setSelected} value={selected}>
                    {scopes.map(([scope, description]) => (
                      <Field.Root key={scope}>
                        <Field.Item>
                          <Checkbox value={scope} />
                          <Field.Label>
                            <span {...styles.scope()}>{scope}</span>
                          </Field.Label>
                        </Field.Item>
                        <Field.Description {...styles.hint()}>{description}</Field.Description>
                      </Field.Root>
                    ))}
                  </CheckboxGroup>
                </Rows.Panel>
              )}
            </Rows.Root>

            {selected.length === 0 && (
              <Callout intent="warning">
                <Callout.Icon />
                <Callout.Content>A key with no scopes can't access scoped APIs.</Callout.Content>
              </Callout>
            )}

            <div {...styles.actions()}>
              <Button type="button" variant="secondary">
                Cancel
              </Button>
              <Button type="submit">Create key</Button>
            </div>
          </form>
        )}
      </Surface>
    </div>
  )
}

namespace styles {
  export const actions = style({
    columnGap: '2',
    display: 'flex',
    justifyContent: 'flex-end',
    '@media (max-width: 40rem)': {
      flexDirection: 'column-reverse',
      rowGap: '2',
    },
  })

  export const form = style({ display: 'flex', flexDirection: 'column', rowGap: '6' })

  // Aligns each scope's description with its label, past the 20px checkbox and the 16px gap.
  export const hint = style({ paddingInlineStart: '9' })

  export const page = style({
    backgroundColor: 'background.primary',
    display: 'flex',
    justifyContent: 'center',
    padding: '4',
  })

  export const panel = style({ maxWidth: '32rem !custom', width: '100% !custom' })

  export const scope = style({ typography: 'label.13.mono' })
}
```

[View example source](https://github.com/tempoxyz/ds/blob/main/docs/src/examples/demos/Products/ConsoleCreateKey.tsx)

New key asks for a project, a name, and scopes. The console shows the token once, right after you create it, so the layout switches to a reveal state with the key masked.

* `Field` with `Select` picks the project. `Field` with `Input` names the key, with `Field.Error` when the name is empty.
* An `expand` row labelled View scopes reveals a `CheckboxGroup`. Each scope shows its identifier and its description from the Tempo API scope catalog.
* A `warning` `Callout` appears when no scope is selected, since such a key can't access scoped APIs.
* A `warning` `Tag` names the environment on the form.
* After Create key, `SecretValue` holds the token masked. Copying works without revealing it.

This screen follows these patterns:

* [Forms](/docs/patterns/forms) for labels, descriptions, and the submit action.
* [Errors and Validation](/docs/patterns/errors-and-validation) for the missing name and the empty scope set.

### Webhooks

Webhook subscriptions deliver signed onchain events to an HTTPS URL, a Slack incoming webhook, or a Better Stack source. The Tempo docs describe them through the API (`/v1/webhooks`) and show no console screen, so this page has no webhook layout yet.

When you build one, reuse the parts above:

* `Table` for subscriptions, with the event type, destination, and status.
* `SecretValue` for the `whsec_` signing secret, which is shown once at creation.
* `Table` with `numeric` cells for deliveries, showing attempt, response status, and response time.

## Content

### Show the Key's Identity, Never the Secret

After creation, identify a key by its prefix and last four characters, such as `tempo:sk:…a91c`. Show the full token once, masked in `SecretValue`, and tell people to copy it into a secret manager now.

**Do:** Say what happens when the reveal state closes: You can't view this key again.

**Don't:** Offer to show an existing key again. The console can't reveal it, so a lost key needs a
replacement.

### Name the Environment Wherever It Changes Meaning

Sandbox and production keys differ in prefix and in the chains they reach. Keep the environment visible on every screen that creates, lists, or bills for keys.

| Environment | Key prefix          | Chains                 |
| ----------- | ------------------- | ---------------------- |
| Production  | `tempo:sk:`         | Any, including mainnet |
| Sandbox     | `tempo_sandbox:sk:` | Non-mainnet only       |

### Name Keys After a Deployment or Owner

Suggest names such as checkout-service production or checkout-service CI in the placeholder and description. A descriptive name tells you which integration breaks when you revoke the key.

### Write Scopes as Identifiers

Show each scope as its identifier in monospace, such as `data:read` or `rpc-relay:sponsor`, with the catalog's description beside it. Scopes can't change on an existing key, so say so where you pick them.

### State Side Effects, Including Missing Ones

Revoking a key stops requests that use it. Removing a team member does not revoke that member's keys. Say both in the confirmation, so an offboarding owner checks API Keys next.

### Quote Error Codes Exactly

When a request fails, show the code in monospace and the fix beside it. A sandbox key that targets mainnet returns `403 api_key_forbidden`, and the fix is `chainId=testnet` or a production key.

## Open Gaps

The library has no part for these console needs yet. Propose one as an issue in the Design System project before adding it.

* **Charts.** Usage shows request volume, sponsorship, and traffic by route and key. There is no chart for volume over time.
* **Table tools.** `Table` has no sorting, column filters, or selection. Filters are composed from `Select`.
* **Multi-line code.** `Snippet` holds one line. A test request with headers needs a multi-line code block.
* **Relative time.** Nothing formats `lastUsedAt` as 2 minutes ago. Use `Intl.RelativeTimeFormat` in your app.
* **Brand marks.** The icon set has no Google mark for the Continue with Google button.

## Related

* [Tables and Lists](/docs/patterns/tables-and-lists) for key and member tables
* [Destructive Actions](/docs/patterns/destructive-actions) for revoking keys and removing members
* [AppShell](/docs/components/app-shell) for the console frame
* [SecretValue](/docs/components/secret-value) for one-time key reveal
* [Table](/docs/components/table) for key lists
* [Banner](/docs/components/banner) for the sandbox notice
