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 numberidentifiers(List of Strings) - The SANs/Domains requestedclient_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 failederror(String) - The detailed error from the validation attemptclient_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 codeclient_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 pointdays_remainingcounts from, so a message read days later is still self-describinglead_days(Integer) - The window this digest coverstotal(Integer) - How many certificates matched, which may be more thancertificatesholds:notify.expiry.max_entriesbounds the list, and the count is what lets a truncated message say how many it did not namecertificates(List) - each withorder_id,account_id,cert_serial,identifiers(List of Strings),not_after(Integer, epoch seconds),days_remaining(Integer, floored) andsuperseded_bysuperseded_by(Option) - absent when nothing has replaced this certificate; otherwiseorder_id,cert_serial,not_afterandvia, whereviais"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 operatorrecipient(Option) — the operator’s own contact address; theemailbackend sends there rather than tonotify.email.tooutcome(String) —succeeded_from_new_address,second_factor_refusedorlocked_outclient_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 abovechange(String) —password,second_factor_enabled,second_factor_disabledorrecovery_codes_regeneratedby_self(Boolean) —falsewhen another administrator made the changeclient_ip(Option),user_agent(Option),at(Integer)