Certificates
Generated from core/api/router.go. Edit the router, not this file.
| Method | Path | Who can call it |
|---|---|---|
GET | /api/v1/certificates | Any authenticated user |
POST | /api/v1/certificates | Operator, admin |
GET | /api/v1/certificates/:id | Any authenticated user |
PATCH | /api/v1/certificates/:id | Operator, admin |
DELETE | /api/v1/certificates/:id | Admin |
GET | /api/v1/certificates/:id/private-key | Admin |
POST | /api/v1/certificates/:id/renew | Operator, admin |
POST | /api/v1/certificates/:id/revoke | Admin |
GET /certificates filters on status, environment, common_name, ca_account_id.
Despite its name, common_name matches any of a certificate's names, the subject common name or any SAN, by substring and ignoring case. Conforming CAs are dropping the common name (Pebble already issues without one), and a certificate whose only name is in its SANs must still be findable by it. A certificate matches once however many of its names do.
Issuing
POST /api/v1/certificates{
"common_name": "app.example.com",
"sans": ["www.app.example.com"],
"ca_account_id": "uuid",
"key_type": "ECDSA",
"key_size": 256,
"validity_days": 90,
"environment": "production",
"team": "platform",
"auto_renew": true,
"renewal_lead_days": 30
}What happens, in order: the CA account is loaded, policy is evaluated, the gateway is located, its stored configuration is decrypted for exactly this one call, issuance is requested, and the returned certificate is parsed before it is stored. A gateway that returns anything other than a valid X.509 certificate produces a 502 rather than a record marked ISSUED.
Metadata on the stored record — serial, issuer, validity dates, fingerprint, key type and size — is read from the certificate itself, not from what the gateway claimed.
If the gateway returns a private key, it is sealed before it touches the database. Supplying your own CSR avoids this entirely and is the better pattern: the key then stays wherever it was generated.
Returns 201. When non-blocking policy violations were recorded, the body is {"certificate": {...}, "policy_violations": [...]} instead of the bare record — a policy that finds something must say so even when it does not block.
Renewing
POST /certificates/:id/renew runs the same path, and asks the CA for the same lifetime: the template's validity_days, or the certificate's own when the template names none. Before #102 a renewal asked for nothing, and a 90-day certificate could come back lasting a year. The key is rotated and the new one persisted; a renewal that produced a certificate without storing its matching key would leave a record that looks healthy and cannot terminate TLS. That is only possible for a key CertPilot already holds: a certificate whose key_custody is AGENT or EXTERNAL is refused with 400, and renewed by whoever holds its key (see Asking for one).
On failure the record is marked RENEWAL_FAILED with renewal_error set, and the previous certificate is left intact.
Exporting a private key
GET /api/v1/certificates/:id/private-key{ "common_name": "app.example.com", "private_key_pem": "-----BEGIN..." }Admin only. Writes cert.private_key_exported to the audit log with the actor and client IP. Returns 404 when no key is stored — which is the normal case for imported, discovered, or CSR-based certificates.
Revoking
POST /api/v1/certificates/:id/revoke{ "reason": 1 }Admin only. The CA is told first, and the record is updated only if the CA agreed. The ordering is the guarantee: a record reading REVOKED beside a certificate that still answers handshakes is worse than either alternative, so every failure on this path leaves the record untouched and says so.
reason is required. Accepted codes are 0 unspecified, 1 keyCompromise, 3 affiliationChanged, 4 superseded, 5 cessationOfOperation, 9 privilegeWithdrawn — the subset of RFC 5280 a CA will act on. Anything else is a 400 listing them, so a caller does not have to go and read the RFC.
| Status | When |
|---|---|
200 | Revoked at the CA and recorded |
400 | Missing or unaccepted reason; no stored copy of the certificate; or no CA account to send it to |
404 | No such certificate |
409 | Already revoked — the CA is not asked again |
502 | Gateway not connected, or the CA declined. Nothing has been changed |
500 | The CA revoked it and the record could not be written. The certificate is dead and the record is wrong until the write succeeds |
Writes certificate.revoked to the audit log with the actor, reason code and fingerprint, and publishes cert.revoked on the event stream so a wall display reflects it without waiting for a sweep.
DELETE /certificates/:id is not a substitute: it removes the record and the certificate stays valid at the CA. It refuses a live certificate with a 409 that names this endpoint; ?forget=true overrides that deliberately.
Endpoint detail
GET /api/v1/certificates
| Who can call it | Any authenticated user |
| Handler | certHandler.List |
| Display token | Readable by an unattended screen |
Parameters
| Name | In | Default | |
|---|---|---|---|
ca_account_id | query | ||
common_name | query | ||
environment | query | ||
limit | query | ||
offset | query | ||
status | query |
Responses
| Status | Body |
|---|---|
200 | An object with data (Certificate[]), total (integer) |
400 | { "error": … } |
500 | { "error": … } |
Example request
curl -X GET 'https://certpilot.example.com/api/v1/certificates' \
-H 'Authorization: Bearer <token>'POST /api/v1/certificates
| Who can call it | Operator, admin |
| Handler | certHandler.Create |
| Display token | Refused — not a viewer-safe GET |
Request body
RequestCertificateInput defines the payload to request a new certificate.
| Field | Type | Description |
|---|---|---|
common_name | string | CommonName is required unless CSRPEM is supplied, in which case the names come from the request itself. Checked by the resolver rather than by a binding tag, which cannot express "one of these two". |
sans | string[] | |
template_id | string | TemplateID names the certificate template to issue under, by slug or by uuid. A slug is what a pipeline should carry: it survives a rename. Optional, for now. A request without one resolves to the default template for CAAccountID, which constrains nothing — so every caller written before templates existed behaves exactly as it did. Requiring it is the third stage of #25 and is a decision for an operator, not a migration. |
ca_account_id | string | CAAccountID is only consulted when TemplateID is absent. A template pins its own issuer, so naming both is naming the same thing twice — and when they disagree the template wins, because a requester who could choose an issuer could choose the cheapest, the least logged, or the one with the widest trust. No longer binding:"required": one of the two has to be present, which a binding tag cannot express. The resolver says which is missing. |
csr_pem | string | CSRPEM is a certificate signing request whose private key was generated somewhere else and never sent here. This is the path for the keys CertPilot must not hold: an HSM, a load balancer that generates its own, a team whose policy forbids a key leaving their host. The names, key type and key size are taken from the request and the corresponding fields above are ignored, because the only key that can serve the certificate is the one the requester already has — honouring a conflicting key_type here would issue a certificate nobody can use. |
key_type | string | |
key_size | integer | |
validity_days | integer | |
environment | string | |
team | string | |
auto_renew | boolean | |
renewal_lead_days | integer | |
metadata | object | Metadata holds values for the admin-defined fields. Required ones are enforced at request time. |
Responses
| Status | Body |
|---|---|
201 | An object with certificate (Certificate), policy_violations, warnings (string[]) |
400 | { "error": … } |
500 | { "error": … } |
502 | An object with conformance_findings · gateway reported success but returned no certificate |
Example request
curl -X POST 'https://certpilot.example.com/api/v1/certificates' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"common_name": "<common_name>",
"sans": [
"<sans>"
],
"template_id": "<template_id>",
"ca_account_id": "<ca_account_id>",
"csr_pem": "<csr_pem>"
}'GET /api/v1/certificates/:id
| Who can call it | Any authenticated user |
| Handler | certHandler.Get |
| Display token | Readable by an unattended screen |
Parameters
| Name | In | Default | |
|---|---|---|---|
id | path | required |
Responses
| Status | Body |
|---|---|
200 | A Certificate object |
404 | { "error": … } |
500 | { "error": … } · could not read the certificate |
Example request
curl -X GET 'https://certpilot.example.com/api/v1/certificates/<id>' \
-H 'Authorization: Bearer <token>'PATCH /api/v1/certificates/:id
| Who can call it | Operator, admin |
| Handler | certHandler.UpdateMetadata |
| Display token | Refused — not a viewer-safe GET |
The only mutable part of a certificate: the decisions people record about it, not the facts the CA established.
Parameters
| Name | In | Default | |
|---|---|---|---|
id | path | required |
Request body
UpdateMetadataInput is what an operator may change on a certificate. Pointers, so an omitted field is left alone rather than cleared. A form that edits only the team must not blank the environment, and a client that knows about three of these fields must not erase the fourth.
| Field | Type | Description |
|---|---|---|
environment | string | |
team | string | |
tags | string[] | |
metadata | object |
Responses
| Status | Body |
|---|---|
200 | A Certificate object |
400 | { "error": … } |
404 | { "error": … } |
500 | { "error": … } · could not read the certificate |
Example request
curl -X PATCH 'https://certpilot.example.com/api/v1/certificates/<id>' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"environment": "<environment>",
"team": "<team>",
"tags": [
"<tags>"
],
"metadata": {}
}'DELETE /api/v1/certificates/:id
| Who can call it | Admin |
| Handler | certHandler.Delete |
| Display token | Refused — not a viewer-safe GET |
Parameters
| Name | In | Default | |
|---|---|---|---|
id | path | required |
Responses
| Status | Body |
|---|---|
200 | An object with message (string) |
404 | { "error": … } · no such certificate |
409 | An object with not_after, status |
500 | { "error": … } · could not read the certificate |
Example request
curl -X DELETE 'https://certpilot.example.com/api/v1/certificates/<id>' \
-H 'Authorization: Bearer <token>'GET /api/v1/certificates/:id/private-key
| Who can call it | Admin |
| Handler | certHandler.PrivateKey |
| Display token | Refused — not a viewer-safe GET |
Parameters
| Name | In | Default | |
|---|---|---|---|
id | path | required |
Responses
| Status | Body |
|---|---|
200 | An object with common_name, private_key_pem |
404 | { "error": … } · no private key is stored for this certificate — it was either imported, discovered, or issued from a CSR whose key never left its host |
500 | { "error": … } · could not read the certificate |
Example request
curl -X GET 'https://certpilot.example.com/api/v1/certificates/<id>/private-key' \
-H 'Authorization: Bearer <token>'POST /api/v1/certificates/:id/renew
| Who can call it | Operator, admin |
| Handler | certHandler.Renew |
| Display token | Refused — not a viewer-safe GET |
Parameters
| Name | In | Default | |
|---|---|---|---|
id | path | required |
Responses
| Status | Body |
|---|---|
202 | An object with data (RenewalJob), message (string) |
400 | { "error": … } |
404 | { "error": … } |
500 | { "error": … } · could not read the certificate |
Example request
curl -X POST 'https://certpilot.example.com/api/v1/certificates/<id>/renew' \
-H 'Authorization: Bearer <token>'POST /api/v1/certificates/:id/revoke
| Who can call it | Admin |
| Handler | certHandler.Revoke |
| Display token | Refused — not a viewer-safe GET |
Exporting a private key is admin-only and audited: it is the one operation that removes a secret from the system's custody. Revocation is admin, and it is the operation DELETE was being used for. It tells the CA first and records only what the CA accepted, so a certificate can never read REVOKED here while still answering handshakes in production.
Parameters
| Name | In | Default | |
|---|---|---|---|
id | path | required |
Request body
RevokeCertificateInput names the reason. It is required rather than defaulted: "unspecified" is a legitimate answer, but it should be one somebody chose, because the reason is what tells the next reader whether a key was compromised or a service was simply retired.
| Field | Type | Description | |
|---|---|---|---|
reason | integer | required |
Responses
| Status | Body |
|---|---|
200 | An object with certificate (Certificate), message |
400 | { "error": … } |
404 | { "error": … } · no such certificate |
409 | An object with revoked_at · this certificate is already revoked |
500 | { "error": … } · could not read the certificate |
502 | { "error": … } |
Example request
curl -X POST 'https://certpilot.example.com/api/v1/certificates/<id>/revoke' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"reason": 0
}'