Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Customizing Templates

acme-proxy uses the MiniJinja templating engine to render notification payloads. The server embeds sensible default templates inside the binary, but you can override any of them by pointing the server to a custom template directory.

Enabling custom templates

In your config.toml, define a template_dir:

[notify]
enabled = ["email", "webhook"]
template_dir = "/etc/acme-proxy/templates"

The server will look in this directory before falling back to its embedded defaults. You only need to create the files you want to override.

Template file structure

Templates are grouped by backend and event name. Email requires separate files for the subject line and body.

/etc/acme-proxy/templates/
├── email/
│   ├── account_created.subject.j2
│   ├── account_created.body.j2
│   ├── certificate_issued.subject.j2
│   └── certificate_issued.body.j2
└── webhook/
    ├── certificate_issued.j2
    └── challenge_failed.j2

(Available event names: profile_mounted, account_created, account_deactivated, certificate_issued, certificate_revoked, challenge_failed, certificates_expiring, admin_sign_in, admin_credential_changed).

A webhook/<event>.j2 renders the message, not the payload: every [notify.webhook.<name>] entry then wraps it in its own body template. So a file here restyles the text for every webhook target at once, and an entry’s body restructures one target’s request without touching the text. See Webhook.

Context variables

When rendering a template, acme-proxy passes a context object containing the event’s data. All events include profile (the name of the profile triggered) and most include client_ip (the IP address of the ACME client that initiated the request).

certificate_issued

Triggered when an order is finalized and the signer mints a certificate.

  • profile (String)
  • order_id (String)
  • account_id (String)
  • cert_serial (String) - Hex-encoded serial number
  • identifiers (List of Strings) - The SANs/Domains requested
  • client_ip (Option<String>)

Example (webhook/certificate_issued.j2):

✅ **Certificate Issued** on profile `{{ profile }}`
**Domains:** {{ identifiers | join(", ") }}
**Serial:** `{{ cert_serial }}`
**Requested By IP:** `{{ client_ip | default("system") }}`

challenge_failed

Triggered when an HTTP-01 or DNS-01 validation attempt fails.

  • profile (String)
  • order_id (String)
  • account_id (String)
  • authz_id (String)
  • challenge_id (String)
  • challenge_type (String) - e.g. “http-01”
  • identifier (String) - The domain that failed
  • error (String) - The detailed error from the validation attempt
  • client_ip (Option<String>)

account_created / account_deactivated

Triggered on account lifecycle events.

  • profile (String)
  • account_id (String)
  • contact (List of Strings) - e.g. ["mailto:admin@example.com"] (only on created)
  • client_ip (Option<String>)

certificate_revoked

Triggered via the ACME API or Admin CLI.

  • profile (String)
  • order_id (String)
  • account_id (String)
  • cert_serial (String)
  • reason (Option<Integer>) - RFC 5280 revocation reason code
  • client_ip (Option<String>)

profile_mounted

Triggered during server startup when a profile is successfully initialized.

  • profile (String)

certificates_expiring

The periodic expiry digest — the one event whose subject is a list, so a template here loops where every other one interpolates.

  • profile (String)
  • generated_at (Integer) - Epoch seconds; the point days_remaining counts from, so a message read days later is still self-describing
  • lead_days (Integer) - The window this digest covers
  • total (Integer) - How many certificates matched, which may be more than certificates holds: notify.expiry.max_entries bounds the list, and the count is what lets a truncated message say how many it did not name
  • certificates (List) - each with order_id, account_id, cert_serial, identifiers (List of Strings), not_after (Integer, epoch seconds), days_remaining (Integer, floored) and superseded_by
  • superseded_by (Option) - absent when nothing has replaced this certificate; otherwise order_id, cert_serial, not_after and via, where via is "replaces" (the client said so, RFC 9773 §5) or "identifiers" (a later certificate of the same account covers the same names)

There is no client_ip: a digest is generated by a sweep, with no request anywhere in scope.

Example (webhook/certificates_expiring.j2):

⏰ **{{ total }} expiring** on `{{ profile }}`
{%- for cert in certificates %}
{{ cert.identifiers | join(", ") }} — {{ cert.days_remaining }}d
{{- " (already replaced)" if cert.superseded_by }}
{%- endfor %}

The superseded_by test is what makes a digest readable: an operator scans for the entries without it. Rendering every row identically would bury the handful that nobody has renewed among the many that are already taken care of.

admin_sign_in

A web-admin operator sign-in worth flagging.

  • profile (String) — always __admin__
  • username (String) — the operator
  • recipient (Option) — the operator’s own contact address; the email backend sends there rather than to notify.email.to
  • outcome (String) — succeeded_from_new_address, second_factor_refused or locked_out
  • client_ip (Option), user_agent (Option)
  • at (Integer) — epoch seconds

admin_credential_changed

A web-admin operator’s password or second factor changed.

  • profile (String) — always __admin__
  • username, recipient — as above
  • change (String) — password, second_factor_enabled, second_factor_disabled or recovery_codes_regenerated
  • by_self (Boolean) — false when another administrator made the change
  • client_ip (Option), user_agent (Option), at (Integer)