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.
| Event | Fires when |
|---|---|
domain.created | A domain is created (pending_dns). |
domain.verified | The record is confirmed by all three resolvers; the certificate is requested next. |
domain.active | The certificate is stored and the domain serves HTTPS. Mark the tenant live here. |
domain.dns_broken | A live domain's record was removed or points elsewhere. Cached visitors keep the certificate. |
domain.cert_failed | Issuance or renewal failed after retries. status_reason says why. |
domain.certificate_renewed | The certificate was renewed (from day 60 of 90). |
domain.deleted | The domain was deleted; routing stops within 5 seconds. |
domain.held | The hostname was held for review; no certificate is requested. |
domain.released | A 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.
- Read the raw request body, before any JSON parsing.
- Compute the HMAC over the timestamp, a dot, and the raw body.
- Compare it in constant time with every
v1value. While a secret is being rotated we sign with both the new and the previous secret for 24 hours, so the header can carry twov1values. - 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);
});
}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}/redeliversends 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.