Docs
Docs
Product

Webhooks

Every state change fires one signed POST to your endpoint. Verify the signature, reply 2xx, and you never have to poll.

Set your endpoint

Set webhook_url once per app, in Settings → Webhooks or with PATCH /v1/apps/current. It must be a public HTTPS URL. Settings has "Send test event" to check the endpoint answers before a real domain changes state.

curl -X PATCH https://api.apexly.dev/v1/apps/current \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"webhook_url":"https://app.hireflow.com/webhooks/apexly"}'

Events

Exactly one event per state change. Events for one domain arrive in order.

EventFires when
domain.createdA domain is created (pending_dns).
domain.verifiedThe record is confirmed by all three resolvers; the certificate is requested next.
domain.activeThe certificate is stored and the domain serves HTTPS. Mark the tenant live here.
domain.dns_brokenA live domain's record was removed or points elsewhere. Cached visitors keep the certificate.
domain.cert_failedIssuance or renewal failed after retries. status_reason says why.
domain.certificate_renewedThe certificate was renewed (from day 60 of 90).
domain.deletedThe domain was deleted; routing stops within 5 seconds.
domain.heldThe hostname was held for review; no certificate is requested.
domain.releasedA hold was released; the domain returns to its previous state.

The payload

The body is JSON. data.domain is the full domain object, the same as GET /v1/domains/{id} returns, including your metadata.

POST /webhooks/apexly HTTP/1.1
Content-Type: application/json
User-Agent: Apexly-Webhooks/1
Apexly-Signature: t=1760587971,v1=5f1c…
Apexly-Event-Id: evt_01J9X8Q2Z5…
Apexly-Delivery-Id: dlv_01J9X8Q3A1…

{
  "id": "evt_01J9X8Q2Z5…",
  "type": "domain.active",
  "created_at": "2026-10-16T04:13:11Z",
  "data": {
    "domain": {
      "id": "dom_8f3a",
      "hostname": "careers.acme.com",
      "status": "active",
      "metadata": { "customer": "acme" }
    },
    "previous_status": "issuing"
  }
}

Verify the signature

Apexly-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256(signing secret, t + "." + raw body)>. Your signing secret (whsec_…) is in Settings → Signing secret.

  1. Read the raw request body, before any JSON parsing.
  2. Compute the HMAC over the timestamp, a dot, and the raw body.
  3. Compare it in constant time with every v1 value. While a secret is being rotated we sign with both the new and the previous secret for 24 hours, so the header can carry two v1 values.
  4. Reject timestamps more than five minutes from your clock; that stops a captured request being replayed.

Each function below is tested against the shared signature vectors on every build.

import { createHmac, timingSafeEqual } from "node:crypto";

/** Verifies an Apexly-Signature header against the raw request body. */
export function verifyApexlySignature(
  header: string | undefined,
  rawBody: string | Buffer,
  secret: string,
  now = Math.floor(Date.now() / 1000),
): boolean {
  if (!header) return false;
  let t = -1;
  const sigs: string[] = [];
  for (const part of header.split(",")) {
    const [key, value = ""] = part.split("=", 2);
    if (key === "t" && /^\d{1,12}$/.test(value)) t = Number(value);
    else if (key === "v1") sigs.push(value);
  }
  if (t < 0 || Math.abs(now - t) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
  return sigs.some((sig) => {
    const got = Buffer.from(sig, "hex");
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}
Verify before you parse. Most frameworks re-serialise parsed JSON, and a re-serialised body no longer matches the signature. In Express use express.raw({ type: "application/json" }) on this route; in Django and Flask read request.body / request.get_data().

Reply and retries

  • Reply with any 2xx within 10 seconds. Do the work after replying, or on a queue.
  • Anything else is retried at 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours: seven attempts.
  • Every attempt is listed in the dashboard under the domain and in GET /v1/events, with the status code we got back. POST /v1/events/{id}/redeliver sends one again.
  • A redelivery or retry carries the same Apexly-Event-Id. Store the event ids you have handled and skip repeats.

Rotate the signing secret

POST /v1/apps/current/signing-secret/rotate (or Settings → Signing secret → Rotate) returns the new secret once. The previous secret keeps verifying for 24 hours (previous_valid_until): during that window webhooks carry a v1 for each secret. Requests to your origin keep being signed with the previous secret until previous_valid_until, then with the new one, so deploy the new secret within those 24 hours and accept a signature that matches either secret while the change rolls out. Rotating again inside those 24 hours replaces the new secret, not the previous one: requests stay signed with the secret your origin already accepts, and its 24 hours start again.

What to read next

Was this helpful?
Edit this page