---
title: "Reveal Key Endpoint"
method: POST
path: "/api/v1/account/keys/{key_id}/reveal"
tags: ["account"]
---

# Reveal Key Endpoint

`POST /api/v1/account/keys/{key_id}/reveal`

## Request

- Base URL: `https://api.billcommons.org`
- URL: `https://api.billcommons.org/api/v1/account/keys/{key_id}/reveal`
- Auth: none declared

B1: return the plaintext exactly once, then null the reveal columns.
Cookie-authed + Origin-checked (B7).

Item 5 fix: the expired-window branch now calls `expire_stale_reveal`
itself (status='revoked' + revoked_at + cache invalidation) instead of
only nulling the reveal columns and leaving `status='active'` --
otherwise `expire_stale_reveal`'s own lazy auto-revoke (C7) can never
fire again once `reveal_ciphertext` is already None, and the key stays
`active` (occupying one of the 2 active slots) forever with no one who
can ever read it.

Item 4 fix: decrypt BEFORE nulling anything. Pre-fix, the reveal
columns were nulled and committed FIRST, and `plaintext is None`
(decrypt failure) was only checked after -- so a rotated or
misconfigured `BILLCOMMONS_REVEAL_KEY` destroyed the only copy of a
live credential and then 404'd, leaving the key `active` and unreadable
forever. Now a decrypt failure leaves the ciphertext INTACT and returns
503 (retryable once the reveal key is fixed) instead of destroying
it.

Fixlist item 9 fix: the initial `SELECT` now takes `FOR UPDATE` on the
row, and the eventual claim is a conditional
`UPDATE ... WHERE reveal_ciphertext IS NOT NULL RETURNING id` rather
than an unconditional assignment + commit. Pre-fix, two concurrent
authenticated reveals for the same key could both observe
`reveal_ciphertext IS NOT NULL` (no lock, no conditional consume), both
decrypt, both null, both commit, and both return 200 with the
plaintext -- violating B1's "shown to the caller exactly ONCE". `FOR
UPDATE` serializes concurrent callers on this row; the conditional
UPDATE is the second, defense-in-depth guarantee (it also protects
correctness if `FOR UPDATE` is ever a no-op, e.g. a dialect that
doesn't implement row locks) -- a second caller unblocked after the
first commits re-reads `reveal_ciphertext IS NULL` and 404s
`nothing_to_reveal` instead of double-revealing. Kept as a SEPARATE
conditional UPDATE (not folded into one `UPDATE ... RETURNING`) so the
decrypt-before-destroy property from item 4 above still holds: a
decrypt failure leaves the ciphertext intact rather than being
destroyed by an all-in-one claim.

## Path parameters

- `key_id` string, uuid, required

## Response `200`

Successful Response

- KeyRevealResponse
  - `key` string, required
  - `warning` string

## Other responses

- `422`: Validation Error

---

[API](https://skmtc.dev/billcommons/apis/bill-commons-api.md) · [All operations](https://skmtc.dev/billcommons/apis/bill-commons-api/llms.txt) · [OpenAPI document](https://skmtc.dev/billcommons/apis/bill-commons-api/revisions/bfb8cd50a317?raw)
