Custom Script Notifications
The custom notification backend runs a local script (Bash, Python, Go, …) when
an ACME event occurs. Use it to integrate with internal ticketing systems,
custom logging infrastructure, or alerting pipelines that a plain HTTP webhook
cannot satisfy.
Configuration
custom is a named map, like [filter.check]. Two keys switch it
on: notify.enabled activates the backend, and notify.custom_enabled selects
which scripts run, and in what order.
[notify]
enabled = ["custom"]
custom_enabled = ["ticket-creator"]
[notify.custom.ticket-creator]
script_path = "/etc/acme-proxy/scripts/ticket-creator.sh"
timeout_ms = 10000
args = []
events = ["certificate_issued", "certificate_revoked"]
Three ways to get this wrong, all of which fail at startup rather than at delivery time:
- Listing
"custom"innotify.enabledwhilecustom_enabledis empty. - Naming an entry in
custom_enabledthat has no[notify.custom.<name>]table. - Using an entry name outside
^[a-z0-9-]+$—ticket_creator(underscore) is rejected;ticket-creatoris fine.
One process is spawned per enabled script per event.
Reference
script_path (String) — Default: "" | Env: ACME_PROXY_NOTIFY__CUSTOM__<NAME>__SCRIPT_PATH
Path to the executable. Required.
timeout_ms (Integer) — Default: 5000 | Env: ACME_PROXY_NOTIFY__CUSTOM__<NAME>__TIMEOUT_MS
Maximum execution time. A script still running when this expires is killed.
args (Array) — Default: [] | Env: ACME_PROXY_NOTIFY__CUSTOM__<NAME>__ARGS
Static arguments passed to the script on every invocation.
events (Array) — Default: every event | Env: ACME_PROXY_NOTIFY__CUSTOM__<NAME>__EVENTS
Which events this script reacts to. Valid names are profile_mounted,
account_created, account_deactivated, certificate_issued,
certificate_revoked, challenge_failed, certificates_expiring. An
unrecognised name is a startup error.
Execution model and security
- Environment clearing (
env_clear): the child runs with a scrubbed environment, inheriting only a minimalPATHand the injectedACME_NOTIFY_*variables. The server’s own environment may hold secrets —notify.email.smtp_password, the RFC 2136 TSIG key, the NetBox token — and a notification script has no business reading them. - Zombie prevention (
kill_on_drop): the script runs under a Tokio timeout withkill_on_drop(true). Atokio::time::timeoutonly drops the future, so without this a timed-out script would outlive its deadline and leak a process per event. - Fire and forget: delivery runs in a background task. A failing or hanging
script can never delay or fail the ACME response that triggered it; failures
are logged (
event = "notify_delivery_failed") and dropped, with no retry.
Data passing
The script receives context both ways.
Environment variables
All seven are always set. Ones that do not apply to the event are set to the empty string rather than omitted, so a script can read them unconditionally.
| Variable | Value |
|---|---|
ACME_NOTIFY_HOOK | The event name, e.g. certificate_issued. |
ACME_NOTIFY_PROFILE | The profile the event occurred in. |
ACME_NOTIFY_CLIENT_IP | The ACME client’s address; empty when no request was in scope (e.g. profile_mounted, or an asynchronous relay completion). |
ACME_NOTIFY_ACCOUNT_ID | The account, when the event has one. |
ACME_NOTIFY_ORDER_ID | The order, when the event has one. |
ACME_NOTIFY_CERT_SERIAL | The certificate serial, on certificate_issued / certificate_revoked. |
ACME_NOTIFY_IDENTIFIERS | Comma-joined identifier values. Only populated for certificate_issued. |
On certificates_expiring every variable but ACME_NOTIFY_HOOK and
ACME_NOTIFY_PROFILE is empty, and that is not an omission: a digest is
about a list of certificates spanning however many accounts, so there is no one
account, order or serial for a variable to hold. Read the list from the JSON on
stdin, which is the channel that carries structure.
On admin_sign_in / admin_credential_changed the only populated variables
are ACME_NOTIFY_HOOK, ACME_NOTIFY_PROFILE (__admin__) and
ACME_NOTIFY_CLIENT_IP (the web-admin client’s address). The username,
recipient, outcome / change, by_self and user_agent are on the stdin
JSON — an operator is not an ACME subject, so it has no account or order id.
There is no
ACME_NOTIFY_EVENT; the event name isACME_NOTIFY_HOOK.
JSON on stdin
A JSON object is always written to the script’s standard input. It is the
event’s own fields plus a "hook" key naming the event — the same data the
templating backends render from. For example, on
certificate_issued:
{
"hook": "certificate_issued",
"profile": "default",
"order_id": "…",
"account_id": "…",
"cert_serial": "…",
"identifiers": ["a.example.com", "b.example.com"],
"client_ip": "203.0.113.5"
}
The exact fields per event are listed in Customizing Templates — the template context and the stdin payload carry the same values.
The issued certificate itself is never passed to a notification script, on stdin or otherwise. Only its serial and the identifiers it covers are available. A script that needs the PEM must fetch it out of band.
Example
#!/bin/bash
# /etc/acme-proxy/scripts/ticket-creator.sh
set -euo pipefail
payload=$(cat) # the JSON described above
case "$ACME_NOTIFY_HOOK" in
certificate_issued)
echo "issued ${ACME_NOTIFY_CERT_SERIAL} for ${ACME_NOTIFY_IDENTIFIERS}" \
>> /var/log/acme-issuance.log
;;
certificate_revoked)
# ACME_NOTIFY_IDENTIFIERS is empty for this event — read the payload
# if you need more than the serial.
reason=$(echo "$payload" | jq -r '.reason // "unspecified"')
curl -sS -X POST https://tickets.internal/api/incidents \
-H 'Content-Type: application/json' \
-d "{\"serial\":\"$ACME_NOTIFY_CERT_SERIAL\",\"reason\":\"$reason\"}"
;;
esac
See Custom Plugins Examples for more.