GPG keys
OpenPGP public-key CRUD for the authenticating user. JSON shape
is GitHub-exact — every field on
gh's /user/gpg_keys
is present with the same name and nullability so existing clients
work without per-field shims.
All endpoints require Authorization: Bearer <pat> and the
common API conventions apply.
Key shape
{
"id": 17,
"name": "laptop",
"primary_key_id": null,
"key_id": "ABCDEF0123456789",
"public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n…",
"raw_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n…",
"emails": [
{ "email": "alice@example.com", "verified": true }
],
"subkeys": [
{
"id": 0,
"primary_key_id": 17,
"key_id": "FEDC...",
"public_key": "",
"emails": [],
"subkeys": [],
"can_sign": true,
"can_encrypt_comms": false,
"can_encrypt_storage": false,
"can_certify": false,
"created_at": "2026-05-12T04:00:00Z",
"expires_at": null,
"raw_key": null,
"revoked": false
}
],
"can_sign": false,
"can_encrypt_comms": false,
"can_encrypt_storage": false,
"can_certify": true,
"created_at": "2026-05-12T04:00:00Z",
"expires_at": null,
"revoked": false,
"raw_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n…"
}
Field notes:
key_id/subkeys[].key_id— uppercase hex. The primary surfaces its short 16-hex key id; full 40-hex fingerprints are used internally for verification lookups but not exposed in the response (gh-parity).public_keyandraw_key— both carry the same armored block. gh historically distinguishes them; shithub follows suit for client compatibility.can_encrypt_commsandcan_encrypt_storage— RFC 4880 §5.2.3.21 splits encryption capability into two bits; both surface honestly. Encryption-only keys (no signing subkey) are accepted; they just can't verify commits.emails[].verified—truewhen the UID email matches a verified email on the authenticating user's account.expires_at— null for keys that don't expire.revoked—trueafter aDELETE(soft-delete in the DB).
List GPG keys
GET /api/v1/user/gpg_keys
Required scope: user:read. Paginated via ?page= and
?per_page= (≤ 100, default 30). Response carries the standard
Link: header.
Get a single GPG key
GET /api/v1/user/gpg_keys/{id}
Required scope: user:read. 404 when the id does not belong to
the authenticating user (existence-leak safe — the same status
is returned regardless of whether the row exists for another
user).
Add a GPG key
POST /api/v1/user/gpg_keys
Required scope: user:write.
Request body
{
"name": "laptop",
"armored_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n…"
}
| Field | Type | Notes |
|---|---|---|
name |
string | 1–80 characters; user-visible label. |
armored_public_key |
string | Full ASCII-armored block, including the BEGIN/END envelope. |
Returns 201 on success with the same shape as the list
endpoint. Successful inserts dispatch a background backfill
that retroactively stamps verification rows for any existing
commits whose signing subkey matches.
Errors
| Status | When |
|---|---|
| 401 | PAT missing/invalid. |
| 403 | PAT lacks user:write scope. |
| 422 | Block unparseable, key already registered, RSA<2048, no UID, expired primary, etc. |
Rejection classes the parser raises explicitly: private-key
blocks, signature blocks, RSA<2048, DSA-only, expired primary,
no-UID entities. Encryption-only keys with valid encryption
subkeys are accepted with can_sign: false.
Delete a GPG key
DELETE /api/v1/user/gpg_keys/{id}
Required scope: user:write. Returns 204 No Content on
success. Soft-delete: the row stays in the DB with
revoked_at = now(). Verification cache rows that resolved
against the deleted key are invalidated; affected commits
revert to no badge until another matching key is uploaded.
404 when the id does not belong to the authenticating user.
View source
| 1 | # GPG keys |
| 2 | |
| 3 | OpenPGP public-key CRUD for the authenticating user. JSON shape |
| 4 | is GitHub-exact — every field on |
| 5 | [gh's `/user/gpg_keys`](https://docs.github.com/en/rest/users/gpg-keys) |
| 6 | is present with the same name and nullability so existing clients |
| 7 | work without per-field shims. |
| 8 | |
| 9 | All endpoints require `Authorization: Bearer <pat>` and the |
| 10 | [common API conventions](overview.md) apply. |
| 11 | |
| 12 | ## Key shape |
| 13 | |
| 14 | ```json |
| 15 | { |
| 16 | "id": 17, |
| 17 | "name": "laptop", |
| 18 | "primary_key_id": null, |
| 19 | "key_id": "ABCDEF0123456789", |
| 20 | "public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n…", |
| 21 | "raw_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n…", |
| 22 | "emails": [ |
| 23 | { "email": "alice@example.com", "verified": true } |
| 24 | ], |
| 25 | "subkeys": [ |
| 26 | { |
| 27 | "id": 0, |
| 28 | "primary_key_id": 17, |
| 29 | "key_id": "FEDC...", |
| 30 | "public_key": "", |
| 31 | "emails": [], |
| 32 | "subkeys": [], |
| 33 | "can_sign": true, |
| 34 | "can_encrypt_comms": false, |
| 35 | "can_encrypt_storage": false, |
| 36 | "can_certify": false, |
| 37 | "created_at": "2026-05-12T04:00:00Z", |
| 38 | "expires_at": null, |
| 39 | "raw_key": null, |
| 40 | "revoked": false |
| 41 | } |
| 42 | ], |
| 43 | "can_sign": false, |
| 44 | "can_encrypt_comms": false, |
| 45 | "can_encrypt_storage": false, |
| 46 | "can_certify": true, |
| 47 | "created_at": "2026-05-12T04:00:00Z", |
| 48 | "expires_at": null, |
| 49 | "revoked": false, |
| 50 | "raw_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n…" |
| 51 | } |
| 52 | ``` |
| 53 | |
| 54 | Field notes: |
| 55 | |
| 56 | - `key_id` / `subkeys[].key_id` — uppercase hex. The primary |
| 57 | surfaces its short 16-hex key id; full 40-hex fingerprints are |
| 58 | used internally for verification lookups but not exposed in |
| 59 | the response (gh-parity). |
| 60 | - `public_key` and `raw_key` — both carry the same armored block. |
| 61 | gh historically distinguishes them; shithub follows suit for |
| 62 | client compatibility. |
| 63 | - `can_encrypt_comms` and `can_encrypt_storage` — RFC 4880 §5.2.3.21 |
| 64 | splits encryption capability into two bits; both surface |
| 65 | honestly. Encryption-only keys (no signing subkey) **are |
| 66 | accepted**; they just can't verify commits. |
| 67 | - `emails[].verified` — `true` when the UID email matches a |
| 68 | verified email on the authenticating user's account. |
| 69 | - `expires_at` — null for keys that don't expire. |
| 70 | - `revoked` — `true` after a `DELETE` (soft-delete in the DB). |
| 71 | |
| 72 | ## List GPG keys |
| 73 | |
| 74 | ``` |
| 75 | GET /api/v1/user/gpg_keys |
| 76 | ``` |
| 77 | |
| 78 | Required scope: `user:read`. Paginated via `?page=` and |
| 79 | `?per_page=` (≤ 100, default 30). Response carries the standard |
| 80 | `Link:` header. |
| 81 | |
| 82 | ## Get a single GPG key |
| 83 | |
| 84 | ``` |
| 85 | GET /api/v1/user/gpg_keys/{id} |
| 86 | ``` |
| 87 | |
| 88 | Required scope: `user:read`. 404 when the id does not belong to |
| 89 | the authenticating user (existence-leak safe — the same status |
| 90 | is returned regardless of whether the row exists for another |
| 91 | user). |
| 92 | |
| 93 | ## Add a GPG key |
| 94 | |
| 95 | ``` |
| 96 | POST /api/v1/user/gpg_keys |
| 97 | ``` |
| 98 | |
| 99 | Required scope: `user:write`. |
| 100 | |
| 101 | ### Request body |
| 102 | |
| 103 | ```json |
| 104 | { |
| 105 | "name": "laptop", |
| 106 | "armored_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n…" |
| 107 | } |
| 108 | ``` |
| 109 | |
| 110 | | Field | Type | Notes | |
| 111 | |----------------------|--------|------------------------------------------------------------------------| |
| 112 | | `name` | string | 1–80 characters; user-visible label. | |
| 113 | | `armored_public_key` | string | Full ASCII-armored block, including the BEGIN/END envelope. | |
| 114 | |
| 115 | Returns `201` on success with the same shape as the list |
| 116 | endpoint. Successful inserts dispatch a background backfill |
| 117 | that retroactively stamps verification rows for any existing |
| 118 | commits whose signing subkey matches. |
| 119 | |
| 120 | ### Errors |
| 121 | |
| 122 | | Status | When | |
| 123 | |-------:|-------------------------------------------------------------------------------------| |
| 124 | | 401 | PAT missing/invalid. | |
| 125 | | 403 | PAT lacks `user:write` scope. | |
| 126 | | 422 | Block unparseable, key already registered, RSA<2048, no UID, expired primary, etc. | |
| 127 | |
| 128 | Rejection classes the parser raises explicitly: private-key |
| 129 | blocks, signature blocks, RSA<2048, DSA-only, expired primary, |
| 130 | no-UID entities. Encryption-only keys with valid encryption |
| 131 | subkeys are accepted with `can_sign: false`. |
| 132 | |
| 133 | ## Delete a GPG key |
| 134 | |
| 135 | ``` |
| 136 | DELETE /api/v1/user/gpg_keys/{id} |
| 137 | ``` |
| 138 | |
| 139 | Required scope: `user:write`. Returns `204 No Content` on |
| 140 | success. Soft-delete: the row stays in the DB with |
| 141 | `revoked_at = now()`. Verification cache rows that resolved |
| 142 | against the deleted key are invalidated; affected commits |
| 143 | revert to no badge until another matching key is uploaded. |
| 144 | |
| 145 | 404 when the id does not belong to the authenticating user. |