Webhook module
Delivers a certificate payload to any HTTP endpoint — the escape hatch for targets that don't fit one of the other modules. You configure the URL and method per target, the headers, payload template, timeout and attempt count on the module config; the receiving system owns persistence and rollback.
Overview
- Method:
POSTby default;PUTorPATCHper target. Any other method is rejected when the target is saved. - Auth: native
bearer_tokencredential (Authorization: Bearer …, replaces any plaintext Authorization header) and/or native HMAC signing (X-Signature-256), plus any custom static headers. - Body: default JSON payload, or a custom Go template.
- Private key: opt-in — the default excludes it; set
include_private_keyonly for receivers that actually need it. - Retry: a configurable attempt budget with exponential backoff, spent only on transient failures.
- Rollback: re-send. Rollback re-sends the previous retained version to the endpoint; the receiver re-processes it. It cannot un-call the request that already fired.
Prerequisites
- An HTTP endpoint reachable from the backend.
- Authentication expectation on the receiving side that you can satisfy with static HTTP headers, a bearer token, or the built-in HMAC signing: attach an
hmac_secretcredential to the target and every request carries anX-Signature-256: sha256=<hex>header (HMAC-SHA256 over the exact body). OAuth flows are not built for you.
Where each setting lives
The target (Settings → Distribution → Targets, module Webhook) carries only the endpoint:
- Base URL — the endpoint (HTTPS recommended;
http://is accepted and then sends the certificate in clear text). - HTTP method —
POST(default),PUTorPATCH. - Timeout (seconds) — used when the module config sets no timeout; default 30.
Everything about the request lives on the module config (Settings → Distribution → Modules):
- Include private key — off by default. Turn on only when the receiver genuinely needs it.
- Template — optional Go template for a custom body. When empty, the default JSON payload (below) is sent. A template with a syntax error is rejected when the module config is saved.
- Timeout (seconds) — per attempt; overrides the target's value.
- Retry count — the total number of attempts per target, not extra retries:
0(the default) and1both mean a single attempt;3means the first attempt plus two retries. UI range 0–10. - Headers (API only) — map of name → value, sent on every attempt. Fourteen hop-by-hop / framing / proxy-spoof names (
Host,Content-Length,Transfer-Encoding,X-Forwarded-For, …) are dropped with a log line if you set them. - Concurrency (API only) — how many targets are delivered in parallel (default 5, clamped 1–50).
Saving a target or a module config performs no call to the endpoint. The health check and the dry run send an unauthenticated HEAD on demand (5 s limit for the health check, 10 s for the dry run); any HTTP status counts as reachable — only a transport error marks the target unhealthy.
Default JSON payload
{
"certificate_id": "...",
"distribution_id": "...",
"cert_fingerprint": "<sha256 hex>",
"domains": ["example.com", "www.example.com"],
"cert_pem": "-----BEGIN CERTIFICATE-----...",
"chain_pem": "-----BEGIN CERTIFICATE-----...",
"fullchain_pem": "-----BEGIN CERTIFICATE-----...",
"private_key_pem": "-----BEGIN PRIVATE KEY-----..." // only if include_private_key
}
No issued_at field is sent.
Headers CertAutoPilot sets
Every request carries four headers beyond your custom ones, plus the two auth headers when configured:
| Header | Value |
|---|---|
Content-Type | application/json (also for a custom template — the body is sent as-is). |
User-Agent | CertAutoPilot/1.0 |
X-Timestamp | RFC3339 time of that attempt (changes on every retry, not signed). |
X-Idempotency-Key | <certificate_id>-<fingerprint>:<job_id> — identical for every retry inside one distribution job, different for every new job: a re-run, a renewal and a rollback each get a fresh key. Deduplicate on it to collapse retries only; it never masks a rollback. |
X-Signature-256 | sha256=<hex HMAC-SHA256 of the body> — only when an hmac_secret credential is attached. |
Authorization | Bearer <token> — only when a bearer_token credential is attached; it replaces an Authorization header from the custom map (logged). |
Custom body templates
The template is a Go text/template, but its variables are not the JSON payload keys above. Exactly these are available:
.CertificateID · .Fingerprint · .Domains · .CertPEM · .ChainPEM · .FullChainPEM · .JobID — plus .PrivateKeyPEM, only when Include private key is on.
A name outside this list — including .PrivateKeyPEM while the key is not included — renders as the literal text <no value>, not as an empty string, so a template referencing e.g. .DistributionID posts that literal. Nothing is escaped for you: a PEM contains newlines, so "{{.FullChainPEM}}" produces invalid JSON. Example — a GitLab CI variable update body:
{
"variable_type": "env_var",
"key": "TLS_FULLCHAIN",
"value": {{ .FullChainPEM | printf "%q" }},
"protected": true
}
printf "%q" ensures the PEM becomes a JSON-safe quoted string.
A template that fails at render time (a wrong field type, a nil access) is not caught by the module-config save: it fails the target with WEBHOOK_TEMPLATE — no request is sent, nothing is retried — and the dry run reports it, so run a dry run after changing the template.
Retry semantics
retry_countis the total number of attempts (see above). Between attempts the module sleeps with exponential backoff (1s, 2s, 4s, …, capped at 1024 s); cancelling the job stops the wait.- Retried while attempts remain: transport errors (DNS, refused, reset, timeout, TLS, outbound-policy refusal, redirect limit), any 5xx, any 3xx left after the redirect limit, and the three "try again later" statuses 408, 425 and 429.
- Terminal — the remaining attempts are skipped: any other 4xx (400, 401, 403, 404, 422, …), an unparseable URL, and a template that fails to render. Re-sending the identical request would only repeat the rejection.
- The reported error code describes the last attempt:
WEBHOOK_NON_2XXwhen it received a status,WEBHOOK_CONNECTwhen it got no usable response (a 503 followed by a connection failure isWEBHOOK_CONNECTwith no status code). The target'sAttemptsvalue is the number of requests actually started (1 for a first-try success, 0 for a template failure). WEBHOOK_CONNECT(network) andWEBHOOK_NON_2XX(io_transient) qualify for the fan-out per-target retry — only fan-out batch children retry individual targets; a normal run that failed completely re-arms the sweep instead.WEBHOOK_TEMPLATE(validation) is never retried.
Security notes
- HTTPS is not enforced.
base_urlis checked only for non-emptiness — anhttp://endpoint is accepted and used as-is, sending the certificate, and the private key wheninclude_private_keyis on, in cleartext. Usehttps://yourself for anything beyond a loopback endpoint. - Outbound network policy applies: cloud-metadata and link-local addresses are blocked, preventing a misconfigured URL from hitting
169.254.169.254. Private (RFC 1918) and loopback addresses are allowed; there is no allowlist to maintain. - If
include_private_keyis ON, review the receiver's handling carefully. The private key is encrypted at rest in CertAutoPilot; after delivery, confidentiality depends on the receiver. - HMAC signing is built in: attach an
hmac_secretcredential and verifyX-Signature-256on the receiver with a constant-time compare before trusting the payload. The signature covers the body only;X-Timestampis unsigned, so there is no built-in replay protection.
Unlike the Vault, Cloudflare and Azure Key Vault clients — which refuse redirects outright — the webhook client follows up to 2 redirects and refuses the third. On 307/308 the same method, body and signature go to the new location; on 301/302/303 the request becomes a body-less GET (whose 2xx still counts as delivered). The Go HTTP client drops Authorization when the redirect leaves the original host. Point the URL at the final address.
Rollback
Rollback re-sends the previous retained version to the endpoint through the module's normal Execute path — the receiver processes the re-delivered payload. It cannot un-call the request that already fired, so the receiver should treat re-delivery idempotently: compare cert_fingerprint with what it currently holds, not with everything it has ever received (a rollback deliberately sends a fingerprint seen before). The rollback runs as a new job, so its X-Idempotency-Key differs from the original delivery. RollbackAvailable is true when an eligible previous version exists.
Troubleshooting
Webhook times out
Increase the timeout or check whether the receiver is slow-processing the payload synchronously. Async receivers should return 202 Accepted immediately; a timeout is a failed attempt and the request is sent again.
"400 Bad Request" after a template change
A 400 is terminal — no further attempts are made. Template syntax errors are rejected when the module config is saved; render errors surface at dry run and as WEBHOOK_TEMPLATE on deploy. The request body is deliberately never logged — it carries the certificate, and the private key when include_private_key is on — so only the receiver's sanitised response body appears in the job log.
"network policy blocked"
The URL resolves to a metadata IP or a link-local address. Switch to a public or private DNS name/IP that is not in those ranges; there is no allowlist to add it to.
The health check is green but deploys fail with 401
The health check and dry run send an unauthenticated HEAD, and any status counts as reachable. They prove the endpoint is reachable, not that it accepts the request. Check the bearer token, the signature secret on the receiver, and the method.