v2.47.0 (Route 53 with an IAM role, a quieter inventory, and the API's answers under test)
A minor release. A Route 53 account can authenticate with the identity of the machine CertMate runs on instead of stored access keys (contributed by Quentin Bertrand, #1071). The inventory can tell a certificate a renewal replaced from one that is still being served, and hides the replaced ones until asked (#1044). The deployment probe no longer loses a mail server's greeting behind an outbound proxy (#1087). And every route of the API is now called on a real instance in CI, its answer recorded and compared with the contract version, which found six defects that no comparison of declarations could see (#1088, #1106, #1107, #1108, #1109, #1111); they are fixed in this release. The API contract moves from 2.34 to 2.39.
Read this before upgrading
If you call the API
The contract number is the one X-CertMate-API-Version reports. By number: 2.35 two optional fields on a Route 53 account; 2.36 superseded on the inventory; 2.37 seven answers now say what they mean; 2.38 the inventory configuration is saved all or nothing; 2.39 the CRL's issuer. If your code reads any of the following, this is the list.
Status codes that change.
| Request | Before | Now |
|---|---|---|
DELETE /api/dns/<provider>/accounts/<id>, no such account
| 500
| 404 with code: DNS_ACCOUNT_NOT_FOUND. A 500 stays for a settings file that could not be written
|
POST /api/deploy/test/<hook_id>, a hook that exists and ran
| 404, with "success": true in the body
| 200, success true or false in the body. 404 only for a hook that is not in the configuration (reason: hook_missing_from_config)
|
POST /api/client-certs/<id>/renew and /revoke, no such certificate
| 400
| 404, as GET /api/client-certs/<id> already said
|
POST /api/inventory/config, a field of the wrong type (null where a number goes, a number or a string where a list goes)
| 500 for null or a number; a string where a list goes was read one character at a time (a 400 about a one-letter "domain")
| 400 naming the field: max_new_per_run must be a number, not null
|
A request that is now all or nothing. POST /api/inventory/config carries up to five sections (discovery, ct_monitoring, domain_registration, domain_health, dns_resolver). Each used to be saved as it was validated, so a refused section answered 400 after the ones before it had been saved. Every section is now validated before any is saved: a 400 has changed nothing.
Values that change.
GET /api/backups:sizeandcreatedon each entry were declared and alwaysnull(the two were only insidemetadata). They are the file's size in bytes and the archive's creation time.POST /api/certificates/<domain>/renew:dns_provideranddurationwere alwaysnull. They are the provider recorded for the certificate and the seconds the renewal took (also in an async job's result).GET /api/crl/download/info:issuerwas<Name(CN=CertMate CA,O=CertMate,C=CH)>, the Python representation of a name object. It isCN=CertMate CA,O=CertMate,C=CH: the same content in the form RFC 4514 gives, which docs/api.md already showed and the inventory'sissueralready used. If you matched the old string, match the name.- The OpenAPI document declares
certificate_match(deployment status) as a boolean, which is what has always been sent; it saidobject.
Added. superseded on every certificate of GET /api/inventory, a superseded count in its summary, and include_superseded=false on it and on GET /api/inventory/crypto-report to leave them out (below). auth_mode and assume_role_arn on a Route 53 account (below).
Deprecated, and removed by the next major (3.0). Six fields of a certificate have been null in every answer of GET /api/certificates and GET /api/certificates/<domain> since they were declared in 2.2, because nothing computes them: total_issued, total_active, total_expired, total_revoked, latest_issuance and oldest_active_issuance. CertMate keeps no ledger of issuances for them to summarise, it does not revoke public certificates, and the date of the latest issuance is renewed_at (or created_at). They are still sent, as null, so nothing that reads them breaks; stop reading them. The removals announced for 3.0 are listed in #1114.
Why a minor. The rule beside API_CONTRACT_VERSION calls a status code that changes for an existing condition a major. The three that changed were a 500, a 404 on a success and a 400 for a missing resource, answers no caller can have been asked to depend on, so the maintainer decided on a minor with a line in the history that says which, rather than a 3.0. If your code did branch on one of them, the table above is the list.
If you use Route 53
Nothing changes for an account that stores access keys, which stays the default, with one deliberate exception: CertMate no longer hands certbot an AWS_SESSION_TOKEN it inherited from its own environment together with the stored keys. A token that does not belong to those keys makes AWS reject the call. If you gave CertMate temporary credentials by putting AWS_SESSION_TOKEN in the container's environment next to stored keys, that stops working in this release; use iam_role mode (below) or long-lived keys.
A Route 53 account has two new optional fields, auth_mode and assume_role_arn:
auth_mode: "access_keys"(the default) is what existed before.auth_mode: "iam_role"stores no keys and uses the AWS credential chain of the host: environment variables, an instance profile, a task role, a web-identity role. Withassume_role_arnset, CertMate then assumes that role through STS and gives certbot the temporary credentials it returns.
See DNS providers.
If you scripted against the deprecated DNS account routes
PUT and DELETE on /api/dns-providers/accounts/<account_id> (and the dashboard's /api/web/settings/accounts/<account_id>) carry no provider, and did not look for one. A DELETE answered 500 and left the account. A PUT answered 200 "Account updated" without touching the account: it created a new one under a provider called null, holding whatever you sent, even for an id that does not exist.
They now find the provider when the id belongs to exactly one, answer 404 when it belongs to none, and 409 (naming the provider-qualified path) when several providers have it. Every provider has an account called default, so that one always needs the long form, /api/dns/<provider>/accounts/<account_id>. The long form, which the dashboard, the SDK and the MCP server use, did not change (apart from the 404 above for an account that does not exist).
If you used the PUT form, look for leftovers. GET /api/dns/accounts listing an account whose provider is null is one, and it holds the credentials that were sent. Remove it with DELETE /api/dns/null/accounts/<account_id>. CertMate does not delete these for you.
The inventory page hides the certificates a renewal replaced
On the dashboard's Inventory page, a certificate is superseded when, at every host and port where it was seen, a different certificate has been seen more recently. Those are hidden by default, the page says how many it hid, and Show superseded certificates brings them back (marked as superseded). Nothing is deleted, and the API's default answer still lists everything.
The inventory is a history by fingerprint on purpose, so a renewed certificate that was never deployed is still shown: its endpoint keeps serving the old one, so the old one is not superseded. A certificate with no endpoint (found in a CT log, or issued here and never probed) is never superseded, and neither is one still served anywhere. The Adopt button is still offered on a superseded certificate, and the printable crypto report page and its links are not filtered unless you pass include_superseded=false.
If you build, test or contribute
- Ten scripts in the repository root are gone:
build-docker.sh,build-multiplatform.sh,test-multiplatform.sh,run-docker.sh,debug-docker.sh,start-certmate.sh,quick_test.sh,setup.sh,start.shandrun-tests.sh. None was shipped in the image and eight had not been touched since July 2025.docker buildanddocker buildx build(docs/docker.md),docker compose,make setup,make runandmake testtake their place;quick_test.shhas no replacement (it ran a test file that does not exist and read an API token from a file that has held only a hash since the token stopped being stored in the clear). - The Makefile runs what CI runs, and a test keeps it so.
make lintnow includes the complexity and exception budgets and the ruff ratchet;make typecheckruns the mypy ratchet;make setupinstalls fromrequirements.lockwith the test requirements on top as a constraint.make format,make pre-commitandmake docker-testare removed: the first would have rewritten 641 of 642 Python files, the second had no configuration to run, the third could not install its own requirements.make test-ciis the exact pytest line of the CI test job. - Two ratchets, not a rule set switched on.
ruff(the familiesE F W B I S C4 UP RUF) andmypy(default mode) each carry a baseline that can only go down: 2,069 ruff findings in 103 (area, rule) entries, 95 mypy errors in 27 files. A new offender, a count that goes up, a count that goes down without the entry being lowered, and an entry for something clean each fail. Both are pinned exactly inrequirements-test.txt(ruff==0.16.10,mypy==2.4.0); a Dependabot bump of either fails its check until the baseline is re-measured. The mypy job runs on the locked set under Python 3.12 and reports, without gating, for now. scripts/regenerate_lockfiles.shresolves with uv, in under a second for both files and both architectures (it took minutes under QEMU in Docker), starting from the pins already in the lock, so a regeneration moves what the change needs and nothing else. The lock keeps its format and pip stays the judge of what installs: the image build runspip install -ron it for both architectures. CONTRIBUTING gains the "Changing a dependency" section it was missing. Needs uv (pip install uvorbrew install uv).- If you build or install CertMate yourself, nothing else to do:
azure-mgmt-dnsmoves to 9.0.0 in the lock, the native Azure DNS hook is the only code that calls it, and its tests pass against that version.
Added
- Route 53 with an IAM role (#1071, by Quentin Bertrand):
auth_mode: "iam_role"andassume_role_arn, above. - Superseded certificates in the inventory (#1044, #1103): the definition, the API fields and the dashboard control, above. Requested and confirmed by the reporter of #1044.
Changed
- The API contract is compared on what the routes answer, in CI. Until now one gate compared the list of routes with the contract version and nothing compared what an answer contains: a field added to Route 53's account, which the project's own rule makes a minor change, merged with the number where it was. Two snapshots now carry it, beside the route list that already existed: the OpenAPI document the app serves (45 models, 237 properties, 69 operations, compared field by field, #1085); and the answers themselves, for 108 of the 111 routes (the other three are the document itself and the two OIDC legs, which need an identity provider): each is called on a real instance, in a world with no network, no child process, and a stand-in for certbot that writes what certbot writes, and its fields, types and status codes are recorded (182 answers, never the values; #1098, #1110). Each difference is printed with the way the version has to move. The same test refuses a plan that reaches a server error or an answer whose status and body disagree, and compares the seven operations that declare a response schema with it.
- A test records when the pinned certbot renews on its own, with and without ACME Renewal Information (#1090, measurement for #393; no behaviour changed).
- Documentation: the Route 53 account's two fields, what the provider-less account routes do now,
renew(which the API reference did not describe), the inventory configuration being all or nothing, the status codes above.
Fixed
- The SMTP deployment probe lost the server's greeting behind an outbound proxy. A mail server sends its greeting the moment it is connected, a proxy can deliver it in the same read as its own
200 Connection established, and the tunnel the probe used kept it in a buffer nobody could reach: the probe waited out its read timeout for a line it had already been sent. It reproduced on Python 3.12, 3.13 and 3.14. The tunnel is now opened by CertMate and reads the proxy's answer one byte at a time, so nothing past it is consumed; both ways into a tunnel (the probe and the inventory probe) use it. The refusal messages are the ones they were (Tunnel connection failed: 407 ...). It had been visible only as a test that failed one run in a few (#1087). - The deprecated provider-less DNS account routes wrote credentials under a provider called
nulland answered500for an account that existed (above, #1088). - Seven answers that contradicted what they meant (the status codes, the backup list, the renewal answer and
certificate_matchabove; #1106, #1107, #1108, #1111). Each was found by calling the route: the declarations were unchanged and passed every comparison. - The inventory configuration half-applied a request, and a body of the wrong type was a
500(above, #1109). - The CRL's
issuer(above, #1111).
Dependencies
azure-mgmt-dns8.1.0 -> 9.0.0 (lock).- Test requirements:
ruff==0.16.10andmypy==2.4.0, pinned exactly. - CI: the step that resolves the optional requirement sets for both architectures takes seconds again, not 13 minutes (#1084).
How it was checked
- Route 53, through the real plugin. CertMate's own strategy builds the environment and
certbot-dns-route535.8.0 runs with--dry-runagainst Let's Encrypt staging, with fake credentials, one process per case: stored keys reach AWS, which rejects them (InvalidClientTokenIdonListHostedZones);iam_rolewith credentials in the process environment reaches AWS with them, and with none stops atUnable to locate credentials;iam_rolewithassume_role_arnand no identity stops before certbot withNoCredentialsError, and no credential appears in the message; a token inherited from the environment is not passed with stored keys. - The probe fix against a proxy that sends the greeting in the same
sendas its answer (before, a timeout; after, the greeting), on 3.12, 3.13 and 3.14, with 19 tests of the tunnel itself and the SMTP legs run both ways, and six deliberate breakages of it, each caught. - The account routes: 16 tests, 14 of them failing on the old code (the other two are the long form, which must not change), checking the stored settings and not only the answer.
- Superseded certificates: 36 tests (the definition case by case, the view, the API on the real SQLite inventory through the real app, and the real
inventory.jsexecuted in node against a fake DOM), nine deliberate changes to the code each caught, and the page driven in Chrome on a seeded inventory holding the reporter's case. - The answers walk, the part that found the defects: the seal was tested on its own (a connection beyond the loopback, a name, a DNS query, each way of launching a program, and that all of it is undone, also when the code under it raises), and nine deliberate breakages of the world (a name resolving, a connection allowed,
os.system,subprocess, the resolver, the seal not undone, a stand-in that writes no key, a proxy kept, a keyless certificate that keeps its key) were each caught. Seven deliberate changes to real handlers (a field removed, a status code changed, a bool turned into a string, a known defect fixed, a server error introduced, a field added, an error status with a success body) were each caught by the test whose job it is; the first "field added" was a no-op by construction (a marshalled route drops the field before the wire) and was redone on a plain route. The walk gives the same answers from a process that never ran anything before it, from one where something had, and on a second instance built from nothing; the snapshot is written on macOS and CI checks it on Linux, on Python 3.12 and 3.14. - The fixes: 19 tests for the seven answers and 53 for the inventory configuration (every ordered pair of its five sections, one valid and one refused, each leaving the configuration exactly as it was), each fix reverted by hand and the test that names it failing.
- The tooling. Each ratchet was driven with six deliberate changes to the real tree (a new violation, a rule with no entry, one violation fixed with the entry not lowered, an entry for a clean file, another tool version recorded, a file that does not parse), each caught. The uv regeneration resolves the same 123 packages for both architectures; bumping
boto3movesboto3,botocoreands3transferand nothing else, where a resolution from zero the same day would also have movedfilelock,google-auth-httplib2andsqlalchemy. - Real certificates. The six files the release gate runs (Let's Encrypt staging, Cloudflare DNS-01, ARI early renewal included) passed 24 of 24, none skipped, in 7 minutes 45 seconds, run against
mainatafad643. The only things between that commit and this release are these notes and the version bump, and the release gate runs the files again on the release commit. - The suite. In CI at
afad643, 7,874 tests passed and 75 were skipped, on Python 3.12 and on Python 3.14.
Not verified
- Route 53 against a real AWS account. Nothing here assumed a role, read an instance profile or exchanged a web-identity token: the cases above stop at AWS rejecting fake credentials or at the absence of any. Whether
iam_roleandassume_role_arnwork end to end is what the first person to use them will find out; please report what you see. - How often a real proxy delivers a server's greeting in the same read as its own answer. The defect is certain; its frequency in the field is not.
- What the walk's issuance answers stand on. Create, renew and reissue are called against a stand-in for certbot, so their success answers are what CertMate does when certbot behaves; certbot itself, and the CA, are what the real-certificate gate is for.
- The walk does not compare the request side (the fields a route accepts), or docs/api.md with what is sent. The latter is known to differ: the client certificate examples in the API reference document a
statusthe answers do not carry and leave out many fields they do, and show a shape for create and renew that is not the answer's. Not corrected in this release (#1105). Sixteen routes answered an empty list in the walk, so the elements of that list are not compared; one route (the Azure Key Vault backfill) reaches no success answer, and three are not called. - The inventory configuration still reads its booleans with
bool(): a JSON string such as"false"is read as true. A client that sends real booleans is unaffected; refusing strings would change what any client that sends them gets, so it is a separate decision (noted in #1113). - The six deprecated fields are still sent, as
null, until 3.0. certbot-plugin-edgedns0.3.0 and whether CertMate's own ARI client should defer to certbot's are open questions, not changes in this release (#1079, #393).