# 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.

```bash tab="curl"
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"}'
```

```js tab="Node"
await fetch("https://api.apexly.dev/v1/apps/current", {
  method: "PATCH",
  headers: {
    Authorization: "Bearer sk_live_…",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ webhook_url: "https://app.hireflow.com/webhooks/apexly" }),
});
```

```python tab="Python"
import requests

requests.patch(
    "https://api.apexly.dev/v1/apps/current",
    headers={"Authorization": "Bearer sk_live_…"},
    json={"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`.

```http
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](https://github.com/vijaytambe12/apexly-app/blob/main/packages/shared/test-vectors/webhook-signature.json) on every build.

```ts tab="TypeScript" check="webhook"
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);
  });
}
```

```python tab="Python" check="webhook"
import hashlib
import hmac
import time


def verify_apexly_signature(header, raw_body: bytes, secret: str, now=None) -> bool:
    """Verifies an Apexly-Signature header against the raw request body."""
    if not header:
        return False
    t, sigs = None, []
    for part in header.split(","):
        key, _, value = part.partition("=")
        if key == "t" and value.isascii() and value.isdigit():
            t = int(value)
        elif key == "v1":
            sigs.append(value)
    now = int(time.time()) if now is None else now
    if t is None or abs(now - t) > 300:
        return False
    mac = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256)
    expected = mac.hexdigest().encode()
    return any(hmac.compare_digest(expected, sig.encode()) for sig in sigs)
```

```go tab="Go" check="webhook"
package apexly

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strconv"
	"strings"
	"time"
)

// VerifySignature checks an Apexly-Signature header against the raw request body.
func VerifySignature(header string, body []byte, secret string, now time.Time) bool {
	ts := int64(-1)
	var sigs []string
	for _, part := range strings.Split(header, ",") {
		key, value, _ := strings.Cut(part, "=")
		switch key {
		case "t":
			if n, err := strconv.ParseUint(value, 10, 40); err == nil {
				ts = int64(n)
			}
		case "v1":
			sigs = append(sigs, value)
		}
	}
	if ts < 0 || now.Unix()-ts > 300 || ts-now.Unix() > 300 {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(strconv.FormatInt(ts, 10) + "."))
	mac.Write(body)
	expected := mac.Sum(nil)
	for _, sig := range sigs {
		if got, err := hex.DecodeString(sig); err == nil && hmac.Equal(got, expected) {
			return true
		}
	}
	return false
}
```

```ruby tab="Ruby" check="webhook"
require "openssl"

# Verifies an Apexly-Signature header against the raw request body.
def verify_apexly_signature(header, raw_body, secret, now: Time.now.to_i)
  return false if header.nil? || header.empty?

  t = nil
  sigs = []
  header.split(",").each do |part|
    key, value = part.split("=", 2)
    if key == "t" && value&.match?(/\A\d{1,12}\z/)
      t = value.to_i
    elsif key == "v1" && value
      sigs << value
    end
  end
  return false if t.nil? || (now - t).abs > 300

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}." + raw_body.b)
  sigs.any? { |sig| OpenSSL.secure_compare(expected, sig) }
end
```

```php tab="PHP" check="webhook"
<?php

/** Verifies an Apexly-Signature header against the raw request body. */
function verify_apexly_signature(?string $header, string $rawBody, string $secret, ?int $now = null): bool
{
    if (!$header) {
        return false;
    }
    $t = null;
    $sigs = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', $part, 2), 2, '');
        if ($key === 't' && ctype_digit($value) && strlen($value) <= 12) {
            $t = (int) $value;
        } elseif ($key === 'v1') {
            $sigs[] = $value;
        }
    }
    if ($t === null || abs(($now ?? time()) - $t) > 300) {
        return false;
    }
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    foreach ($sigs as $sig) {
        if (hash_equals($expected, $sig)) {
            return true;
        }
    }
    return false;
}
```

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.

- [Quickstart](/docs/quickstart)
- [How it works](/docs/concepts/domain-states)
- [Verify the signature at your origin](/docs/guides/nextjs#verify-the-signature)
