Catalog PyPI
PACKAGE EXPLORERView in registry

acmeow

PyPI

Production-grade ACME protocol client library for automated SSL/TLS certificate management

acmeautomationcertificatecloudflarednsletsencryptroute53ssltls
Versions collected1
Direct dependencies13From release 1.2.0
Last observed20 Oct, 18:18 UTC

README

Version 1.2.0 documentation

ACMEOW

Tests PyPI version Python versions License codecov Code style: ruff Documentation

A production-grade Python library for automated SSL/TLS certificate management using the ACME protocol (RFC 8555).

Documentation | PyPI | GitHub

Features

  • Full ACME Protocol Support: Complete RFC 8555 implementation including:
    • Account creation, update, key rollover, and deactivation
    • Certificate ordering, issuance, and revocation
    • DNS-01, HTTP-01, and TLS-ALPN-01 challenge validation, plus draft DNS-PERSIST-01
    • External CSR support for bring-your-own key workflows
  • Multiple Challenge Types: Supports DNS-01, HTTP-01, and TLS-ALPN-01 challenges, plus the draft DNS-PERSIST-01 persistent method
  • Flexible Challenge Handlers: Built-in handlers and callback-based custom handlers
  • Automatic Retry with Backoff: Configurable retry logic for transient failures
  • DNS Propagation Verification: Optional verification that DNS records are visible before challenge completion
  • Order Recovery: Resume interrupted certificate orders automatically
  • Preferred Chain Selection: Choose alternate certificate chains (e.g., for compatibility)
  • Thread-Safe: Safe for use in multi-threaded applications
  • Type Hints: Full type annotations for better IDE support
  • Modern Python: Requires Python 3.10+

Installation

pip install acmeow

Or install from source:

git clone https://github.com/miichoow/ACMEOW.git
cd ACMEOW
pip install -e .

For development:

pip install -e ".[dev]"

Quick Start

from pathlib import Path
from acmeow import AcmeClient, Identifier, KeyType, CallbackDnsHandler

# Create client
client = AcmeClient(
    server_url="https://acme-v02.api.letsencrypt.org/directory",
    email="admin@example.com",
    storage_path=Path("./acme_data"),
)

# Create account
client.create_account()

# Create order
order = client.create_order([Identifier.dns("example.com")])

# Define DNS record handlers (implement with your DNS provider)
def create_record(domain, name, value):
    # Create TXT record using your DNS provider API
    pass

def delete_record(domain, name):
    # Delete TXT record
    pass

# Complete challenges
handler = CallbackDnsHandler(create_record, delete_record, propagation_delay=60)
client.complete_challenges(handler)

# Finalize and get certificate
client.finalize_order(KeyType.EC256)
cert_pem, key_pem = client.get_certificate()

Challenge Handlers

from acmeow import CallbackDnsHandler

def create_txt(domain: str, record_name: str, value: str) -> None:
    # record_name is "_acme-challenge.example.com"
    # value is the base64url SHA-256 hash to put in the TXT record
    your_dns_api.create_record(record_name, "TXT", value)

def delete_txt(domain: str, record_name: str) -> None:
    your_dns_api.delete_record(record_name, "TXT")

handler = CallbackDnsHandler(
    create_record=create_txt,
    delete_record=delete_txt,
    propagation_delay=120,  # Wait for DNS propagation
)

HTTP-01 Challenge (simpler setup, no wildcards)

from pathlib import Path
from acmeow import FileHttpHandler, ChallengeType

# Files written to {webroot}/.well-known/acme-challenge/
handler = FileHttpHandler(webroot=Path("/var/www/html"))

client.complete_challenges(handler, challenge_type=ChallengeType.HTTP)

Or with callbacks:

from acmeow import CallbackHttpHandler

def setup(domain: str, token: str, key_authorization: str) -> None:
    # Serve key_authorization at http://{domain}/.well-known/acme-challenge/{token}
    pass

def cleanup(domain: str, token: str) -> None:
    # Remove the challenge response
    pass

handler = CallbackHttpHandler(setup, cleanup)

TLS-ALPN-01 Challenge (TLS termination control)

TLS-ALPN-01 proves domain control by serving a validation certificate with the ACME identifier extension (RFC 8737).

from acmeow import CallbackTlsAlpnHandler, ChallengeType

def deploy_cert(domain: str, cert_pem: bytes, key_pem: bytes) -> None:
    # Configure your TLS server with the validation certificate
    your_tls_server.set_certificate(domain, cert_pem, key_pem)

def cleanup_cert(domain: str) -> None:
    your_tls_server.remove_certificate(domain)

handler = CallbackTlsAlpnHandler(deploy_cert, cleanup_cert)

client.complete_challenges(handler, challenge_type=ChallengeType.TLS_ALPN)

Or write certificates to files:

from pathlib import Path
from acmeow import FileTlsAlpnHandler, ChallengeType

handler = FileTlsAlpnHandler(
    cert_dir=Path("/etc/tls/acme"),
    cert_pattern="{domain}.alpn.crt",
    key_pattern="{domain}.alpn.key",
    reload_callback=lambda: subprocess.run(["nginx", "-s", "reload"]),
)

client.complete_challenges(handler, challenge_type=ChallengeType.TLS_ALPN)

DNS-PERSIST-01 Challenge (persistent record, draft)

DNS-PERSIST-01 proves domain control with a long-lived TXT record at _validation-persist.<domain> that stays published and authorizes future issuance, so renewals need no DNS changes.

Warning: Defined by draft-ietf-acme-dns-persist, an Internet-Draft that is not yet an RFC, so the record format may still change. Let's Encrypt staging offers it (verified July 2026); production and other CAs are unverified — check with auth.get_dns_persist_challenge(), which returns None when the CA does not offer it.

Unlike DNS-01, this method uses no token and no key authorization. The record value binds an Issuer Domain Name to your account URI using RFC 8659 issue-value syntax, so it has its own handler interface and client method:

from acmeow import CallbackDnsPersistHandler

def upsert_txt(domain: str, record_name: str, value: str) -> None:
    # record_name: "_validation-persist.example.com"
    # value: "ca.example; accounturi=https://ca.example/acct/1"
    your_dns_api.upsert_record(record_name, "TXT", value)

handler = CallbackDnsPersistHandler(upsert_txt)

client.complete_dns_persist_challenges(handler)

The record is not deleted after validation — that persistence is the point. Pass persist=False to the handler to opt out.

DNS providers work with it too:

from acmeow import get_dns_provider, DnsProviderPersistHandler

provider = get_dns_provider("cloudflare", api_token="...")
client.complete_dns_persist_challenges(DnsProviderPersistHandler(provider))

Key Types

from acmeow import KeyType

# Available key types for certificate private keys:
KeyType.RSA2048  # RSA 2048-bit (minimum recommended)
KeyType.RSA3072  # RSA 3072-bit
KeyType.RSA4096  # RSA 4096-bit
KeyType.RSA8192  # RSA 8192-bit (slow)
KeyType.EC256    # ECDSA P-256 (recommended)
KeyType.EC384    # ECDSA P-384

Configuration Options

client = AcmeClient(
    server_url="https://acme-v02.api.letsencrypt.org/directory",
    email="admin@example.com",
    storage_path=Path("./acme_data"),
    verify_ssl=True,   # Verify SSL certificates (default: True)
    timeout=30,        # Request timeout in seconds (default: 30)
)

Retry Configuration

Configure automatic retry with exponential backoff for transient failures:

from acmeow import AcmeClient, RetryConfig

# Custom retry settings
retry_config = RetryConfig(
    max_retries=5,      # Maximum retry attempts (default: 5)
    initial_delay=1.0,  # Initial delay in seconds (default: 1.0)
    max_delay=60.0,     # Maximum delay between retries (default: 60.0)
    multiplier=2.0,     # Exponential backoff multiplier (default: 2.0)
    jitter=True,        # Add randomness to prevent thundering herd (default: True)
)

client = AcmeClient(
    server_url="https://acme-v02.api.letsencrypt.org/directory",
    email="admin@example.com",
    storage_path=Path("./acme_data"),
    retry_config=retry_config,
)

The client automatically retries on:

  • Rate limits (HTTP 429)
  • Server errors (HTTP 500, 502, 503, 504)
  • Connection errors and timeouts

Proxy Configuration

Use proxy for ACME HTTP client:

from acmeow import AcmeClient

# Custom retry settings
proxy_url = "socks5://proxy.example.com:1080"

client = AcmeClient(
    server_url="https://acme-v02.api.letsencrypt.org/directory",
    email="admin@example.com",
    storage_path=Path("./acme_data"),
    proxy_url=proxy_url,
)

DNS Propagation Verification

Verify DNS records are visible before completing DNS-01 challenges:

from acmeow import AcmeClient, DnsConfig

# Configure DNS verification
dns_config = DnsConfig(
    nameservers=["8.8.8.8", "1.1.1.1"],  # DNS servers to query
    timeout=5.0,                          # Query timeout in seconds
    retries=3,                            # Retries per server
    min_servers=1,                        # Minimum servers that must see the record
    require_all=False,                    # Require all servers to see the record
)

client = AcmeClient(...)
client.set_dns_config(dns_config)

# DNS propagation will be verified before notifying the ACME server
client.complete_challenges(handler, verify_dns=True, dns_timeout=300)

Order Recovery

Orders are automatically saved and can be resumed after interruption:

# Orders are automatically saved during creation
order = client.create_order([Identifier.dns("example.com")])

# If the process is interrupted, the order can be loaded later
client = AcmeClient(...)
client.create_account()  # Must create/load account first

# Load the saved order (returns None if no saved order exists)
order = client.load_order()
if order:
    print(f"Resumed order: {order.url} (status: {order.status})")

Preferred Certificate Chain

Select an alternate certificate chain when downloading the certificate:

# Get certificate with preferred chain (e.g., for older client compatibility)
cert_pem, key_pem = client.get_certificate(preferred_chain="ISRG Root X1")

# The preferred_chain parameter matches against the issuer CN in alternate chains
# If not found, the default chain is returned

External CSR

If you need to manage your own private key or use a CSR generated by an external tool (e.g., a hardware security module), pass the CSR directly to finalize_order:

from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID

# Generate key and CSR externally
my_key = ec.generate_private_key(ec.SECP256R1())
csr = (
    x509.CertificateSigningRequestBuilder()
    .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "example.com")]))
    .add_extension(
        x509.SubjectAlternativeName([x509.DNSName("example.com")]),
        critical=False,
    )
    .sign(my_key, hashes.SHA256())
)
csr_pem = csr.public_bytes(serialization.Encoding.PEM)

# Finalize order with external CSR (no key is generated or stored by ACMEOW)
client.finalize_order(csr=csr_pem)

# key_pem is None because the key was not managed by ACMEOW
cert_pem, key_pem = client.get_certificate()
assert key_pem is None

# Save the certificate alongside your own key
Path("certificate.pem").write_text(cert_pem)
Path("private_key.pem").write_bytes(
    my_key.private_bytes(
        serialization.Encoding.PEM,
        serialization.PrivateFormat.PKCS8,
        serialization.NoEncryption(),
    )
)

Both PEM (str or bytes) and DER (bytes) encoded CSRs are accepted. When a CSR is provided, the key_type and common_name parameters are ignored.

External Account Binding (EAB)

Some CAs require EAB to link ACME accounts to existing accounts:

client.set_external_account_binding(
    kid="your-key-id",
    hmac_key="your-base64url-hmac-key",
)
client.create_account()

ACME Servers

CAProduction URLStaging URL
Let's Encrypthttps://acme-v02.api.letsencrypt.org/directoryhttps://acme-staging-v02.api.letsencrypt.org/directory
ZeroSSLhttps://acme.zerossl.com/v2/DV90-
Buypasshttps://api.buypass.com/acme/directoryhttps://api.test4.buypass.no/acme/directory

Storage Structure

acme_data/
├── accounts/
│   └── acme-v02.api.letsencrypt.org/
│       └── admin@example.com/
│           ├── account.json
│           └── keys/
│               └── admin@example.com.key
├── orders/
│   └── current_order.json    # Saved order for recovery
└── certificates/
    ├── example.com.crt
    └── example.com.key

Exception Handling

from acmeow import (
    AcmeError,              # Base exception
    AcmeServerError,        # Server returned an error
    AcmeAuthenticationError,# Account authentication failed
    AcmeAuthorizationError, # Challenge validation failed
    AcmeOrderError,         # Order creation/finalization failed
    AcmeCertificateError,   # Certificate download failed
    AcmeConfigurationError, # Invalid configuration
    AcmeNetworkError,       # Network communication failed
    AcmeTimeoutError,       # Operation timed out
    AcmeRateLimitError,     # Rate limit exceeded
    AcmeDnsError,           # DNS verification failed
)

try:
    client.complete_challenges(handler)
except AcmeRateLimitError as e:
    print(f"Rate limited, retry after {e.retry_after}s")
except AcmeDnsError as e:
    print(f"DNS verification failed for {e.domain}")
except AcmeAuthorizationError as e:
    print(f"Challenge failed for {e.domain}: {e.message}")
except AcmeError as e:
    print(f"ACME error: {e.message}")

Context Manager

with AcmeClient(...) as client:
    client.create_account()
    # ... operations
# Client is automatically closed

Account Management

Update Contact Email

# Update account contact information
client.update_account(email="new-email@example.com")

Account Key Rollover

# Roll over to a new account key (RFC 8555 Section 7.3.5)
client.key_rollover()

Deactivate Account

# Permanently deactivate the account (RFC 8555 Section 7.3.7)
# Warning: This cannot be undone!
client.deactivate_account()

Certificate Revocation

from acmeow import RevocationReason

# Revoke a certificate (RFC 8555 Section 7.6)
with open("certificate.pem") as f:
    cert_pem = f.read()

# Revoke without reason
client.revoke_certificate(cert_pem)

# Or specify a revocation reason
client.revoke_certificate(cert_pem, reason=RevocationReason.KEY_COMPROMISE)

Available revocation reasons:

  • RevocationReason.UNSPECIFIED - No specific reason
  • RevocationReason.KEY_COMPROMISE - Private key compromised
  • RevocationReason.CA_COMPROMISE - CA compromised
  • RevocationReason.AFFILIATION_CHANGED - Affiliation changed
  • RevocationReason.SUPERSEDED - Certificate superseded
  • RevocationReason.CESSATION_OF_OPERATION - Operations ceased
  • RevocationReason.CERTIFICATE_HOLD - Temporarily on hold
  • RevocationReason.REMOVE_FROM_CRL - Remove from CRL
  • RevocationReason.PRIVILEGE_WITHDRAWN - Privileges withdrawn
  • RevocationReason.AA_COMPROMISE - Attribute authority compromised

Development

Installation

# Clone the repository
git clone https://github.com/miichoow/ACMEOW.git
cd ACMEOW

# Create virtual environment
python -m venv .venv
source .venv/bin/activate  # Linux/macOS
# or
.venv\Scripts\activate  # Windows

# Install in development mode with all dependencies
pip install -e ".[dev,docs]"

Running Tests

# Run all tests
pytest

# Run with verbose output
pytest -v

# Run with coverage report
pytest --cov=acmeow --cov-report=html

# Run specific test file
pytest tests/test_client.py

# Run specific test class
pytest tests/test_client.py::TestAcmeClientInit

# Run specific test
pytest tests/test_client.py::TestAcmeClientInit::test_init_fetches_directory

Code Quality

# Run linter
ruff check src/acmeow/

# Run type checker
mypy src/acmeow/

# Format code (check only)
ruff format --check src/acmeow/

# Format code (apply changes)
ruff format src/acmeow/

Building Documentation

# Install docs dependencies
pip install -e ".[docs]"

# Build HTML documentation
cd docs
make html

# View documentation
open _build/html/index.html  # macOS
xdg-open _build/html/index.html  # Linux
start _build/html/index.html  # Windows

Examples

The examples/ directory contains complete working examples:

ExampleDescription
dns_challenge.pyDNS-01 challenge workflow for certificate issuance
http_challenge.pyHTTP-01 challenge workflow for certificate issuance
dns_persist_challenge.pyDNS-PERSIST-01 persistent record workflow (draft)
account_management.pyAccount creation, updates, and key rollover
revoke_certificate.pyCertificate revocation
deactivate_account.pyPermanent account deactivation
eab_account.pyExternal Account Binding for CAs like ZeroSSL

Run examples:

python examples/dns_challenge.py

License

Apache License 2.0

Contributing

Contributions are welcome! Please see CONTRIBUTING.md for guidelines.

Downloads & popularity

Awaiting observations

No metric observations yet

Download counts and popularity will appear here as the collector receives them.

Versions 1

Collected releases
VersionPublishedDirect dependenciesOpen version
1.2.020 Jul 202613

Package advisories

0
No matching advisories collected

This describes our collected data; it is not a security assessment.

Repository

PENDING
miichoow/acmeow

Shared repository data · GitHub

License detected in repository
Not collected

Metadata history

Meaningful changes
Current collected state Current

Observed since 20 Oct, 18:18 UTC

No superseded metadata states collected. Unchanged observations do not create new revisions.

Record identity

package:7efe29b1a2ac78a63c0ff431a281b64162a48f633e9ef56083ed81bb59d9e6ad