markdown · 4656 bytes Raw Blame History

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_key and raw_key — both carry the same armored block. gh historically distinguishes them; shithub follows suit for client compatibility.
  • can_encrypt_comms and can_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[].verifiedtrue when the UID email matches a verified email on the authenticating user's account.
  • expires_at — null for keys that don't expire.
  • revokedtrue after a DELETE (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.