---
title: "Create Ssh Key"
method: POST
path: "/ssh-keys"
---

# Create Ssh Key

`POST /ssh-keys`

## Request

- Base URL: `https://api.xhostd.com` (the document declares no server; this is the origin it was published from)
- URL: `https://api.xhostd.com/ssh-keys`
- Auth: none declared

Register one OpenSSH public key for the caller.

The route answers 400 for a line the parser refuses, and 409 for a
fingerprint the table already holds. The unique constraint is
global, so the same key cannot name two accounts.

The route writes one ``ssh_key.added`` notification and queues one
``security.ssh_key_added`` mail in the same transaction as the row.
A coding agent can register a key for the user, so the user must
learn about the new key. Both carry the same text: a bell row alone
reaches nobody on an account no person watches, and the mail leaves
the console (design/EMAIL.md). An account that holds no verified
address gets the notification alone, because the sink writes no row
for an empty recipient.

``api_login`` stores the caller's value. True means the key can also
mint a bearer token through ``POST /auth/ssh-key``, and the shared
text says so. The console sends a checkbox that starts cleared, so a
pasted key stays a git identity unless the person asks for more.

Registering a key is a mint, so the mint rule gates it. A key
satisfies ``repo:*`` by construction (design/AUTH.md), so the route
requires ``repo:*``; ``api_login`` additionally requires the whole
default set, because the key goes on to mint that set plus
``email:bind`` through ``POST /auth/ssh-key``. Either refusal answers
403 ``scope_denied``.

``api_login`` also needs an ``ssh-ed25519`` key, the one type
``POST /auth/ssh-key`` accepts. The route answers 400 for any other
type, so the stored flag never promises a sign-in the login route
refuses. Every other accepted type still registers as a git identity.

``api_login`` then needs one more thing, because that mint carries
``email:bind`` and a bound address takes the account's console: the
account holds a verified address, or the caller's own token carries
``email:bind``. The second arm is the rule that a key-proved
credential can register another key — only the two proof-based mints
in :mod:`xhostd.routes.registration` grant that scope, so a token
from ``POST /credentials`` or ``POST /tokens`` never holds it, and an
agent with no address still rotates its own keys. A refusal answers
409 ``conflict``, the ``POST /me/email-verifications`` shape: nothing
about the caller's authorization is wrong, and the account is not
ready.

The two scope checks run first, before any read of the account, so a
caller that fails authorization learns nothing about account state.

This route carries NO protected-action gate. A gate would close the
one path that lets an agent work without a secret in its context. The
scope gate is not that gate: a general credential passes it, and only
a narrowed one, which could not reach the repositories itself, is
refused.

## Headers

- `authorization` string, nullable

## Request body

- CreateSshKeyRequest
  - `public_key` string, required
  - `label` string, nullable
  - `api_login` boolean

## Response `200`

Successful Response

- SshKeyInfo
  - `id` string, uuid, required
  - `label` string, nullable, required
  - `algo` string, required
  - `fingerprint` string, required
  - `created_at` string, date-time, required
  - `last_used_at` string, date-time, nullable, required
  - `api_login` boolean, required

## Other responses

- `422`: Validation Error

---

[API](https://skmtc.dev/xhostd/apis/xhostd-public.md) · [All operations](https://skmtc.dev/xhostd/apis/xhostd-public/llms.txt) · [OpenAPI document](https://skmtc.dev/xhostd/apis/xhostd-public/revisions/bf439c4d1789?raw)
