---
title: "Mint a Handoff Key"
method: POST
path: "/vaults/v1/{vault_id}/handoffs"
tags: ["vault"]
---

# Mint a Handoff Key

`POST /vaults/v1/{vault_id}/handoffs`

## Request

- Base URL: `https://api.run402.com`
- URL: `https://api.run402.com/vaults/v1/{vault_id}/handoffs`
- Auth: one of:
  - HTTP bearer
  - API key in header `SIGN-IN-WITH-X`
  - API key in header `x-admin-key`

kygit-handoff (design D2/D3/D10). Mints a single-use bearer Handoff Key that bridges to a principal envelope: the recipient becomes an org member at the minter's role (or a narrower requested role) the moment the key is claimed. The `kgh1_<handoff_id||master_secret>` key is assembled entirely CLIENT-SIDE — `handoff_id` is client-generated (it salts the HKDF derivation of `auth_secret`/`wrap_key`) and REQUIRED in the body; the gateway never sees the assembled key, only the resulting `auth_hash` and `sealed_envelope`. Auth: `principalAuth` (control-plane session, SIWX wallet, or admin key) at `handoff.mint` (developer+, no grant key — a grant key is never a member, and a handoff confers membership). Never lifecycle-gated. Bounds (hard-coded): 20 open handoffs per vault, 100 mints per org per UTC day, TTL 60-86400s (default 3600s). vault-multi-writer rev 47: a claimed handoff now ALSO confers write authority on the vault (the blast-radius warning names it: "...becomes an owner of this org and a writer of this vault, permanently..."), so minting requires `writer_admission_grant` and the minter must currently be an active, non-gateway-blocked writer — refused 403 HANDOFF_MINT_REQUIRES_WRITER otherwise.

## Path parameters

- `vault_id` string, required

## Headers

- `SIGN-IN-WITH-X` string, required

## Request body

- object
  - `handoff_id` string, uuid, required: Client-generated (any UUID version). Salts the HKDF derivation of auth_secret/wrap_key, so the client must mint it before deriving the key. Used as the row's own primary key — a collision refuses 409 HANDOFF_ID_CONFLICT rather than ever overwriting a row.
  - `role` 'owner' | 'admin' | 'developer' | 'billing' | 'viewer': Requested role. The stored minted_role is min(requested, the minter's own fresh role) — a handoff never grants MORE than the minter's own role. Defaults to the minter's role when omitted.
  - `expires_in_seconds` integer: TTL in seconds. Default 3600 (1 hour).
  - `checkpoint` object, required: The pinned checkpoint the sealed envelope bridges to — a keyed commitment, so the gateway never sees the plaintext commit oid.
    - `generation` union, required: The pinned generation: a non-negative integer, or vault's canonical generation string — 16 lowercase hex digits, zero-padded (what every head/ref_state/status surface renders and what `run402 repos handoff` sends, e.g. "000000000000000a" for ten). Either form is accepted.
      - integer
      - string
    - `snapshot_oid_hmac` string, required: 64 lowercase hex chars (32 bytes) — the HMAC form heads already carry, never the plaintext oid.
  - `sealed_envelope` string, byte, required: Base64 ciphertext, opaque to the gateway: {v, kind:"handoff", repo_id, epoch, k_e, checkpoint, note_schema} sealed under wrap_key with the vault's frame AEAD backend. Stored verbatim; never decrypted by the gateway.
  - `envelope_kind` string, required: Client-declared envelope format tag, opaque to the gateway.
  - `auth_hash` string, required: 64 lowercase hex chars (32 bytes) — SHA-256("kygit/handoff/auth-hash/v1" || auth_secret). The gateway stores only this hash, never auth_secret itself.
  - `writer_admission_grant` object, required: vault-multi-writer rev 47, REQUIRED (no observe mode). The minter's own signed grant proving THEY are currently an active vault writer and binding this mint: {format, object_kind, suite, repo_id, handoff_id, auth_hash, checkpoint_generation, checkpoint_head_sha256, grantor_writer_key_id, handoff_admission_pubkey, minted_role, claim_not_after, created_at, signature}. Verified synchronously server-side under the minter's OWN signing_pubkey (published via POST /agent/v1/whoami/encryption-key, above) — every field cross-bound against this mint request's own resolved values. Stored verbatim + its sha256; echoed back byte-for-byte from the matching claim response.

## Response `201`

Handoff minted. The Handoff Key itself is assembled and printed exactly once, client-side (the gateway never sees it).

- object
  - `handoff_id` string, uuid, required
  - `kind` 'handoff', required
  - `role` 'owner' | 'admin' | 'developer' | 'billing' | 'viewer', required: The stored minted_role — never wider than the minter's own fresh role at mint time.
  - `expires_at` string, date-time, required
  - `repo_id` string, required
  - `org_id` string, uuid, required
  - `project_id` string, nullable, required
  - `checkpoint` object, required
    - `generation` integer: Echoed as an integer; the request may carry it as an integer or the canonical zero-padded generation string.
    - `snapshot_oid_hmac` string
  - `warning` string, required: Verbatim (design D10, amended vault-multi-writer rev 47) blast-radius sentence: "Whoever claims this key first becomes a <role> of this org and a writer of this vault, permanently. The key works once and expires at <expires_at>." ("an owner" / "an admin" — the article follows the role.) Show this to whoever runs the mint — it names the authority they are about to hand over, now including on-chain WRITE authority on this vault, not just org membership.
  - `warnings` object[]: Machine-readable twin of `warning`: exactly one entry, code HANDOFF_KEY_CONFERS_ROLE, message = the same verbatim sentence. Clients key on the code, never on the prose.
    - `code` 'HANDOFF_KEY_CONFERS_ROLE', required
    - `message` string, required
  - `next_actions` NextAction[], required: Carries a revoke_handoff entry (DELETE this same handoff). The recipient's exact resume command is composed client-side (the gateway never learns the assembled kgh1_ key), so it is not on this response.
    - `type` string, required: Advisory machine-readable action type such as retry, authenticate, submit_payment, fund_wallet, renew_tier, check_usage, resume_deploy, edit_request, edit_migration, contact_support, upgrade_client, poll (poll the project events feed — appended when the envelope carries a correlated_platform_incident), or hand_to_member (on a commit/promote response that just activated a public site: show the human urls.site and urls.console, relay Run402's offer to promote the build on @run402com, and ask yes or no — see `credited_as` below).
    - `method` string
    - `path` string
    - `auth` string
    - `why` string
    - `credited_as` string, nullable: `hand_to_member` only. The caller's own live presence name in the project's default room, else that room's most recently active live presence, else `null` — in which case `why` tells the agent that joining the room with a name (`run402 rooms join --name ...`) is how the promotion credits it.
    - `body` object: `hand_to_member` only. A ready-to-send `POST /feedback/v1` template: `{ project_id, message: "promote: yes", handle? }`.

## Other responses

- `400`: VALIDATION_FAILED — a malformed field (handoff_id not a UUID, expires_in_seconds out of 60..86400, checkpoint/auth_hash/sealed_envelope shape).
- `403`: VAULT_ACCESS_DENIED — unauthorized, nonexistent, or wrong-project vault (one opaque envelope, authorize-before-reveal). Or (vault-multi-writer rev 47) HANDOFF_MINT_REQUIRES_WRITER — the caller is not currently an active, non-gateway-blocked writer on this vault; next_actions names admitting yourself as a writer first.
- `409`: HANDOFF_ID_CONFLICT (the client-generated handoff_id already exists) or HANDOFF_OPEN_LIMIT (the vault already has 20 open handoffs). Or (vault-byo-primary-bucket D11) VAULT_BYO_SINGLE_WRITER — this vault's storage_profile is "byo": a bring-your-own-bucket vault admits exactly one writer in v1, because its key envelopes live only in the customer's bucket, so a second writer would gain writer authority without the bucket access it needs to read them. details: { storage_profile: "byo", vault_id }. Refused before any claim row, placeholder membership, or audit row is written.
- `429`: RATE_LIMITED — the organization already minted 100 handoffs today (UTC).

---

[API](https://skmtc.dev/run402/apis/run402-api.md) · [All operations](https://skmtc.dev/run402/apis/run402-api/llms.txt) · [OpenAPI document](https://skmtc.dev/run402/apis/run402-api/revisions/893607557f50?raw)
