Skip to content
Documentation

Guides

Webhooks

Stop polling. Register an endpoint, verify the signature, and react to certificate lifecycle events the moment they happen.

Why webhooks

You could poll GET /v1/certificates/:certificateId until a certificate goes active — but webhooks are the better path. Register an endpoint once and AutoCertify pushes a signed event the instant something changes: a domain verifies, a certificate issues, goes active, renews, or fails a renewal. Your systems stay in sync without a polling loop.

Register an endpoint

Create a webhook with the URL to call and the events you care about. The response includes the signing secret (whsec_…) — store it now; it’s used to verify every delivery and isn’t shown again.

POST /v1/webhooks
curl -X POST https://api.autocertify.net/v1/webhooks \
  -H "Authorization: Bearer $AUTOCERTIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/autocertify",
    "events": ["certificate.active", "certificate.renewal_failed"]
  }'
201 Created
{
  "id": "wh_3Jd8s2Rf",
  "url": "https://hooks.example.com/autocertify",
  "events": ["certificate.active", "certificate.renewal_failed"],
  "secret": "whsec_9f2Ka7Qm4Zt1Rb6Vn0Xy",
  "createdAt": "2026-02-04T18:10:00Z"
}

Event types

Subscribe to any of these. Each delivery names its type:

  • domain.verified — delegation was confirmed; issuance has been triggered.
  • certificate.issued — a certificate was requested from the CA and is being provisioned.
  • certificate.active — the certificate is live and serving HTTPS.
  • certificate.renewed — an automatic renewal completed successfully.
  • certificate.renewal_failed — a renewal needs attention; act before expiry.
  • domain.verification_failed — the delegation record could not be validated.

Payload format

Every delivery is a JSON body with a top-level id, type, createdAt, and a data object carrying the relevant resources. Here’s a certificate.active event:

certificate.active
{
  "id": "evt_7Yr4mQ2p",
  "type": "certificate.active",
  "createdAt": "2026-02-04T18:43:09Z",
  "data": {
    "certificate": {
      "id": "crt_5Nb2Kq9w",
      "domainId": "dom_8Qp3v1c7",
      "status": "active",
      "issuer": "Cloudflare for SaaS Managed CA",
      "notBefore": "2026-02-04T18:43:07Z",
      "notAfter": "2026-05-05T18:43:07Z",
      "nextRenewalAt": "2026-04-21T00:00:00Z"
    },
    "domain": {
      "id": "dom_8Qp3v1c7",
      "apex": "shop.example.com"
    }
  }
}

Each request carries the event type, a timestamp, and a signature over the timestamp and the raw body:

Delivery headers
X-AutoCertify-Event:     certificate.active
X-AutoCertify-Timestamp: 1770230589
X-AutoCertify-Signature: sha256=6f3b0c9a1d2e4f5a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f7a8b9c0d1e2a

The signature lives in X-AutoCertify-Signature: sha256=<hex>, paired with X-AutoCertify-Timestamp: <unix>. The hex is hmac_sha256(secret, "<timestamp>.<rawbody>") — the timestamp, a literal dot, then the exact request body, signed with your webhook secret. (X-AutoCertify-Event carries the event type for quick routing.)

Verifying the signature

Strip the sha256= prefix, recompute the HMAC over <timestamp>.<rawbody>, and compare it in constant time. Verify against the raw bytes of the request — parse the JSON only after the signature checks out, or a re-serialized body won’t match.

verify-webhook.mjs (Express + node:crypto)
import crypto from "node:crypto";
import express from "express";

const app = express();
const SIGNING_SECRET = process.env.AUTOCERTIFY_WEBHOOK_SECRET; // whsec_...

// Verify the exact bytes AutoCertify signed — mount the raw body, not JSON.
app.post(
  "/autocertify",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const timestamp = req.get("X-AutoCertify-Timestamp") || "";
    const header = req.get("X-AutoCertify-Signature") || ""; // "sha256=<hex>"
    const signature = header.startsWith("sha256=") ? header.slice(7) : header;

    const raw = req.body.toString("utf8"); // req.body is a Buffer here
    const signed = timestamp + "." + raw;
    const expected = crypto
      .createHmac("sha256", SIGNING_SECRET)
      .update(signed)
      .digest("hex");

    const valid =
      !!signature &&
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
    if (!valid) return res.status(400).send("invalid signature");

    // Reject stale deliveries to blunt replay attacks (5-minute window).
    const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
    if (ageSeconds > 300) return res.status(400).send("stale timestamp");

    const event = JSON.parse(raw);
    switch (event.type) {
      case "certificate.active":
        // Mark the domain as secured in your system.
        break;
      case "certificate.renewal_failed":
        // Alert a human — a certificate needs attention.
        break;
    }

    // Acknowledge fast (2xx). Do slow work off the request path.
    res.status(200).send("ok");
  }
);

Delivery & retries

AutoCertify treats any 2xx response as success. Anything else — a non-2xx status, a timeout, or a connection error — is retried with exponential backoff, up to 6 attempts. Design your handler to be idempotent (the same event may arrive more than once) and to acknowledge quickly, moving any slow work off the request path so you don’t time out and trigger a needless retry.

Rotating the secret

If a signing secret is ever exposed, rotate it with POST /v1/webhooks/:id/rotate-secret. That returns a new whsec_… value; update your endpoint’s configuration to verify against the new secret. You can also update the URL or subscribed events with PATCH /v1/webhooks/:id, or remove the endpoint entirely with DELETE /v1/webhooks/:id. See the API reference for the full webhook surface.