> ## Documentation Index
> Fetch the complete documentation index at: https://docs.basaltic.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Certificates

> Issue, renew and revoke TLS certificates, and attach them to a load balancer listener.

The certificate service issues TLS certificates for your domains and keeps them
current. You give it a name and a list of domains; it proves you control them
over DNS, gets them signed, and renews them before they expire. You can also
upload a certificate you already hold.

Certificates are attached to a [load balancer](/api-reference/introduction)
listener by CRN, so the load balancer never handles your private key directly.

<CardGroup cols={2}>
  <Card title="Issue a certificate" icon="badge-check" href="#issuing-a-certificate">
    The DNS challenge, start to finish, and what changes when you host the
    domain elsewhere.
  </Card>

  <Card title="Attach it to a listener" icon="route" href="#serving-a-certificate">
    Multi-SNI, the default certificate, and the two removals a listener
    refuses.
  </Card>

  <Card title="Renewal" icon="rotate-cw" href="#renewal">
    What renews on its own, what does not, and when.
  </Card>

  <Card title="Troubleshooting" icon="life-buoy" href="#troubleshooting">
    Stuck in `pending_dns`, wildcard failures, and reading `error_message`.
  </Card>
</CardGroup>

## Issuing a certificate

Issuance is asynchronous. `POST /v1/certificates` answers **`202`** with the
certificate in `pending` and an **empty** `challenges` list — the challenges are
planned by the issuance workflow just after the row is created, not by the call
that created it.

<Steps>
  <Step title="Create the certificate">
    <Tabs>
      <Tab title="Console">
        Go to **Certificates** and choose **Create Certificate**. Give it a
        **Name**, add each **Domain**, and leave the source on **Auto-issue
        via DNS challenge**.

        The certificate lands in the list straight away, and the detail page
        shows **Configure DNS to complete certificate issuance** with the
        records to publish.
      </Tab>

      <Tab title="API">
        ```bash theme={null}
        POST https://certificate.sa-saopaulo-1.basaltic.sh/v1/certificates
        {
          "name": "prod-frontend",
          "domains": ["example.com", "www.example.com"]
        }
        ```
      </Tab>
    </Tabs>

    `name` is unique per account and lands in the CRN, so it has to be
    URL-safe — letters, digits, dot, dash, underscore.
  </Step>

  <Step title="Poll until the challenges appear">
    ```bash theme={null}
    GET /v1/certificates/{certificate_id}
    ```

    The status moves to `pending_dns` and `challenges` grows one entry per
    domain, each carrying `cname_record_name`, `expected_cname` and `our_dns`.

    <Warning>
      Do not read `challenges` from the create response — it is always empty
      there. Poll the certificate.
    </Warning>
  </Step>

  <Step title="Publish the CNAME">
    Each challenge needs `_acme-challenge.<domain>` pointing at that
    challenge's `expected_cname`. Whether you do anything here depends on who
    hosts the zone — see below.
  </Step>

  <Step title="Wait for active">
    Each challenge flips `verified: true` as its CNAME resolves. Once every
    challenge is verified and signing completes, the certificate becomes
    `active` and `certificate_pem`, `fingerprint`, `issued_at` and
    `expires_at` are populated.
  </Step>
</Steps>

### Who publishes the CNAME

<Tabs>
  <Tab title="Domain on Basaltic DNS">
    The challenge comes back with `our_dns: true` and **the CNAME has already
    been created for you**. There is nothing to do but poll until `verified`
    turns true.
  </Tab>

  <Tab title="Domain hosted elsewhere">
    The challenge comes back with `our_dns: false`. Add the record at your
    registrar, exactly as the challenge names it:

    ```dns theme={null}
    _acme-challenge.example.com.  CNAME  <expected_cname>
    ```

    Take `expected_cname` from the challenge rather than copying it from
    anywhere else — it is per-order, and the target is where the validation
    TXT gets published during issuance.

    <Note>
      The delegation is one-time work per domain, not per issuance. Leave the
      CNAME in place and renewals validate without you touching DNS again.
    </Note>
  </Tab>
</Tabs>

### Wildcards

A wildcard SAN validates at its **parent** name, not at the literal SAN — this
is [RFC 8555 §8.4](https://datatracker.ietf.org/doc/html/rfc8555#section-8.4).
So a certificate for `*.example.com` needs:

```dns theme={null}
_acme-challenge.example.com.  CNAME  <expected_cname>
```

The challenge's `cname_record_name` already accounts for this; publish what it
says and the wildcard case takes care of itself.

<Warning>
  A wildcard order can only be signed by a wildcard-capable authority. If none
  is available the certificate fails rather than falling back to one that
  cannot issue it.
</Warning>

## Bringing your own certificate

Store material you already hold. There is no challenge and no wait — the
certificate is `active` immediately.

<Tabs>
  <Tab title="Console">
    On **Create Certificate**, switch the source to **Provide your own
    certificate**, then paste the **Certificate** (PEM-encoded), the
    **Private Key**, and optionally the **Certificate Chain**.
  </Tab>

  <Tab title="API">
    Set `source: "uploaded"`. The response is **`201`** with the certificate
    already `active`.
  </Tab>
</Tabs>

<CodeGroup>
  ```json Issued by us (default) theme={null}
  {
    "name": "prod-frontend",
    "domains": ["example.com", "www.example.com"],
    "key_algorithm": "ecdsa-p256"
  }
  ```

  ```json Uploaded theme={null}
  {
    "name": "prod-frontend",
    "domains": ["example.com"],
    "source": "uploaded",
    "certificate_pem": "-----BEGIN CERTIFICATE-----\n...",
    "chain_pem": "-----BEGIN CERTIFICATE-----\n...",
    "private_key_pem": "-----BEGIN PRIVATE KEY-----\n..."
  }
  ```
</CodeGroup>

`certificate_pem` and `private_key_pem` are both required when uploading;
`chain_pem` is optional.

<Warning>
  **Uploaded certificates are never renewed.** Renewal only covers what the
  platform issued, because only then does it hold the authority relationship
  needed to reissue. Track the expiry of an uploaded certificate yourself and
  upload a replacement before `expires_at`.
</Warning>

## Serving a certificate

Attach the certificate to an HTTPS listener by CRN:

```bash theme={null}
POST /v1/load-balancers/{id}/listeners/{listener_id}/certificates
{ "certificate_crn": "crn:certificate::my-account:certificate/prod-frontend",
  "is_default": true }
```

A listener can hold several certificates and picks one per connection by
matching the client's SNI against each certificate's SANs. The one marked
`is_default` is the fallback for a client whose SNI matches nothing, or that
sends no SNI at all. Setting a new default demotes the previous one in the same
transaction.

<Warning>
  Detaching a certificate is refused in two cases: removing the **last**
  certificate from an HTTPS listener, and removing the **current default**
  while other certificates are still attached. Promote a replacement first.
</Warning>

<Note>
  A certificate CRN ends in `certificate/<name>`, so the slash must be
  percent-encoded as `%2F` when it appears in a path segment. Sent raw, it
  addresses a different route that does not exist.
</Note>

## Renewal

Certificates the platform issued are renewed automatically, starting **30 days
before `expires_at`**. The window is deliberately wide: it leaves room for many
attempts before anything actually expires, so a transient DNS or authority
problem is not an outage.

Renewal reuses the existing challenge delegation, so a CNAME you published once
keeps working. When a certificate is reissued its `fingerprint` changes — that
is the signal to anything holding the material that there is a new generation to
pick up.

A renewal that fails is retried, but not immediately: a certificate whose last
attempt was under six hours ago is skipped, so a stuck CNAME or an authority
having a bad day is retried on a sensible cadence rather than on every pass.

<Info>
  Uploaded certificates are excluded from renewal. See the warning above.
</Info>

## Revoking

```bash theme={null}
POST /v1/certificates/{certificate_id}/revoke
```

<Note>
  Revocation is **API only**. The console's action on a certificate is
  **Delete certificate**, which is a different thing: deleting removes the
  certificate from your account, while revoking asks the issuing authority to
  mark it invalid so clients that check revocation stop trusting it. If the
  private key may have leaked, revoke — deleting alone leaves a certificate
  that still validates.
</Note>

Revocation is asynchronous: the call returns **`202`** with the certificate
still in its previous state, and you poll until `status` is `revoked`. An
uploaded certificate is revoked locally, without contacting an authority.
Revoking something already revoked is a no-op, not an error.

## Certificate material

`GET /v1/certificates/{certificate_id}/material` is the only endpoint that
returns the **decrypted private key**. Every other read omits it.

<Warning>
  This endpoint accepts machine identity only — it needs a signed request, and
  there is no browser-session path to it. It also requires
  `certificate:GetCertificateMaterial` on the certificate's CRN, which is a
  separate action from reading the certificate.
</Warning>

It exists for in-VM data-plane agents — a load balancer's instance role, signed
through IMDS and STS — that learn about a certificate by CRN and fetch the
material under their own identity. The response carries a `fingerprint` so the
caller can confirm it got the generation it was told to install before
replacing what it is serving.

The private key is stored encrypted under the regional KMS key, so only a
binary in the same region can decrypt it.

## Statuses

```mermaid theme={null}
stateDiagram-v2
    [*] --> pending: create (acme)
    [*] --> active: create (uploaded)
    pending --> pending_dns: challenges planned
    pending_dns --> active: every challenge verified
    pending_dns --> error: validation deadline passed
    pending --> error: issuance failed
    active --> revoked: revoke
    active --> expired: expires_at reached
```

| Status        | Meaning                                                                                       |
| ------------- | --------------------------------------------------------------------------------------------- |
| `pending`     | Created; the issuance workflow has not planned challenges yet. `challenges` is empty.         |
| `pending_dns` | Waiting on DNS. One `challenges` entry per domain; issuance proceeds when all are `verified`. |
| `active`      | Signed and usable. `certificate_pem`, `fingerprint` and `expires_at` are set.                 |
| `error`       | Issuance failed. Read `error_message`.                                                        |
| `expired`     | Passed `expires_at` without a successful renewal.                                             |
| `revoked`     | Revocation completed.                                                                         |

## Key algorithms

`key_algorithm` defaults to **`ecdsa-p256`** and cannot be changed after
creation — issue a new certificate to move to a different algorithm.

<Columns cols={2}>
  <Card title="ECDSA" icon="feather">
    `ecdsa-p256` (default), `ecdsa-p384`. Smaller keys, faster handshakes.
    Prefer these unless a client requires RSA.
  </Card>

  <Card title="RSA" icon="building">
    `rsa-2048`, `rsa-4096`. For older clients and appliances that will not
    negotiate an ECDSA chain.
  </Card>
</Columns>

## Limits and naming

<ResponseField name="domains" type="1–100 entries">
  Capped at 100 to stay inside the signing authority's per-order limits.
</ResponseField>

<ResponseField name="name" type="unique per account">
  Matches `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,253}$`. It appears in the CRN, so it
  has to be URL-safe.
</ResponseField>

<ResponseField name="crn" type="name-based, no region">
  `crn:certificate::<account>:certificate/<name>`. The region slot is empty —
  certificates are not region-bound — and because the CRN is built from the
  name, an IAM policy can wildcard a naming convention:
  `crn:certificate::my-account:certificate/prod-*`.
</ResponseField>

## Troubleshooting

<AccordionGroup>
  <Accordion title="Stuck in pending_dns" icon="clock">
    Read the `challenges` array. Each entry has its own `verified` flag and,
    when a check has failed, an `error_message` naming what was found against
    what was expected — for example `CNAME = foo, want bar`.

    The usual causes are a CNAME published at the wrong name (check
    `cname_record_name` rather than assuming), a trailing-dot mistake at the
    registrar, or simply DNS TTL — a record that replaced an older one is not
    visible until the previous value ages out.
  </Accordion>

  <Accordion title="challenges is empty right after creating" icon="circle-dashed">
    That is the documented behaviour of `POST /v1/certificates`, not a
    failure. Challenges are planned by the issuance workflow moments later.
    Poll `GET /v1/certificates/{certificate_id}` until the status is
    `pending_dns`.
  </Accordion>

  <Accordion title="A wildcard certificate will not validate" icon="asterisk">
    Wildcards validate at the parent name: `*.example.com` is proven by
    `_acme-challenge.example.com`, not by anything containing a `*`. Publish
    exactly the `cname_record_name` the challenge gives you.

    A wildcard also needs a wildcard-capable authority, so a wildcard order
    fails rather than falling back to one that cannot sign it.
  </Accordion>

  <Accordion title="Status is error" icon="triangle-alert">
    `error_message` carries the last failure — commonly that the DNS challenge
    did not propagate before the deadline. Fix the record and create a new
    certificate; a certificate in `error` is not retried indefinitely.
  </Accordion>

  <Accordion title="The load balancer is serving the wrong certificate" icon="route">
    A listener matches SNI against each attached certificate's SANs and falls
    back to the one flagged `is_default`. A client seeing the default when it
    expected something else usually means the SAN it asked for is not on any
    attached certificate — check `domains`.
  </Accordion>
</AccordionGroup>

## Next

<CardGroup cols={2}>
  <Card title="API reference" icon="code" href="/api-reference/introduction">
    Every certificate operation, with request and response schemas.
  </Card>

  <Card title="Authentication" icon="key" href="/authentication">
    Signing requests, and the machine identity the material endpoint needs.
  </Card>
</CardGroup>
