Webhooks
InfraNest can tell your own system when something happens — a domain about to expire, a monitor going down, DNS records changing — by posting JSON to a URL you own. This page is for building the receiver: the events, what they carry, and how to check that a request really came from InfraNest.
Endpoints are set up in the app, under Integrations → Custom webhook, not through the API: a URL and its signing secret are settings a token may never change. The help center's Connect a custom webhook walks through it. Copy the signing secret (whsec_…) when you save the endpoint; it is shown once.
What arrives
A POST with a JSON body in one envelope, whatever the event:
{
"event": "monitor.alert",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": { "check_name": "Website", "status": "down", "monitor_type": "http" }
}event— which event it is. Branch on this.created_at— when InfraNest sent it, ISO 8601 in UTC.data— the event's own fields, listed below.
Headers:
| Header | What it carries |
|---|---|
X-InfraNest-Signature-V2 | sha256= and the HMAC-SHA256 of "<timestamp>.<body>", in hex. See Verify the signature. |
X-InfraNest-Timestamp | Unix time, in seconds, that the signature covers. |
X-InfraNest-Event | The event name, the same as event in the body. |
X-InfraNest-Delivery | The delivery's ID, unique per delivery. Use it to ignore a delivery you already handled. |
X-InfraNest-Signature | Deprecated, sent until 2027-04-30. See The old signature header. |
Content-Type | application/json |
Every request carries these headers, whether it is a test delivery, an event your endpoint is subscribed to, or an event sent by an automation, including the Send everything to … automation an endpoint starts with. The only exception is an automation that calls a URL directly instead of a saved endpoint: there is no delivery, so there is no X-InfraNest-Delivery.
Your endpoint has 10 seconds to answer, and only a 2xx counts as received. A delivery is tried up to 6 times, waiting 30 seconds, then 5 minutes, then 30 minutes, then 2 hours, then 8 hours between tries.
An event sent by an automation is retried by the automation, and each try is in its run history.
Events
Each sample is what Send test event delivers for that event, generated from the same list of fields the automation builder filters on. Values are examples; "sample": true is only ever in a test delivery. A real delivery may carry more fields than the sample: read the ones you need and ignore the rest.
domain.expiring
{
"event": "domain.expiring",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"domain": "example.com",
"days_left": 14,
"expiry_may_be_stale": true,
"unused": true,
"sample": true
}
}domain.status_changed
{
"event": "domain.status_changed",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"domain": "example.com",
"change": "nameservers_changed",
"old_nameservers": [
"ns1.old.example",
"ns2.old.example"
],
"new_nameservers": [
"ns1.new.example",
"ns2.new.example"
],
"sample": true
}
}domain.dnssec_broken
{
"event": "domain.dnssec_broken",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"domain": "example.com",
"sample": true
}
}server.status_changed
{
"event": "server.status_changed",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"server_name": "web-01",
"action": "cloud_server.powered_on",
"sample": true
}
}monitor.alert
{
"event": "monitor.alert",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"check_name": "example",
"status": "up",
"monitor_type": "http",
"sample": true
}
}dns.records_changed
{
"event": "dns.records_changed",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"zone": "example",
"provider": "aws",
"sample": true
}
}dns.live_mismatch
{
"event": "dns.live_mismatch",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"zone": "example",
"records": 1,
"record_name": "example",
"record_type": "A",
"sample": true
}
}watched_domain.dropping_soon
{
"event": "watched_domain.dropping_soon",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"domain": "example.com",
"tld": "com",
"sample": true
}
}watched_domain.available
{
"event": "watched_domain.available",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"domain": "example.com",
"tld": "com",
"sample": true
}
}watched_domain.registered
{
"event": "watched_domain.registered",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"domain": "example.com",
"tld": "com",
"sample": true
}
}watched_domain.failed
{
"event": "watched_domain.failed",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"domain": "example.com",
"tld": "com",
"sample": true
}
}watched_domain.in_auction
{
"event": "watched_domain.in_auction",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"domain": "example.com",
"tld": "com",
"auction": {
"provider": "Example Auctions",
"url": "https://auctions.example/example.com",
"source": "http"
},
"sample": true
}
}watched_domain.released
{
"event": "watched_domain.released",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"domain": "example.com",
"tld": "com",
"drop_eta": "2026-01-01T12:00:00+00:00",
"sample": true
}
}watched_domain.renewed
{
"event": "watched_domain.renewed",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"domain": "example.com",
"tld": "com",
"expires_at": "2026-01-01T12:00:00+00:00",
"sample": true
}
}watched_domain.funding_short
{
"event": "watched_domain.funding_short",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"domain": "example.com",
"tld": "com",
"drop_eta": "2026-01-01T12:00:00+00:00",
"sample": true
}
}certificate.expiring
{
"event": "certificate.expiring",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"subject": "example",
"days_left": 14,
"owner_name": "example",
"owner_email": "example",
"sample": true
}
}certificate.expired
{
"event": "certificate.expired",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"subject": "example",
"sample": true
}
}certificate.changed
{
"event": "certificate.changed",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"subject": "example",
"sample": true
}
}certificate.weak
{
"event": "certificate.weak",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"subject": "example",
"issues": [
"example"
],
"sample": true
}
}certificate.unexpected
{
"event": "certificate.unexpected",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"subject": "example",
"sample": true
}
}certificate.revoked
{
"event": "certificate.revoked",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"subject": "example",
"issuer": "example",
"host": "example",
"sample": true
}
}dynamic_ip.changed
{
"event": "dynamic_ip.changed",
"created_at": "2026-01-15T09:30:00.000000Z",
"data": {
"label": "example",
"ip": "example",
"ip_version": "v4",
"current_ipv4": "example",
"current_ipv6": "example",
"country_code": "example",
"asn_org": "example",
"is_hosting": true,
"sample": true
}
}Verify the signature
Check every request before acting on it. Compute the HMAC-SHA256 of the timestamp, a full stop and the raw request body, exactly as received, "<timestamp>.<body>", with your endpoint's signing secret. Do it before any JSON parsing, which changes spacing. Compare the result with X-InfraNest-Signature-V2 in constant time, and refuse a timestamp more than 300 seconds from your clock, so a captured request cannot be replayed later.
import crypto from 'node:crypto'
// rawBody: the request body as a string or Buffer, before JSON.parse.
export function verifyInfraNest(rawBody, headers, secret, toleranceSeconds = 300) {
const signature = headers['x-infranest-signature-v2'] ?? ''
const timestamp = headers['x-infranest-timestamp'] ?? ''
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false
const signed = Buffer.concat([Buffer.from(`${timestamp}.`), Buffer.from(rawBody)])
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(signed).digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(signature)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}import hashlib
import hmac
import time
def verify_infranest(raw_body: bytes, headers, secret: str, tolerance: int = 300) -> bool:
"""raw_body: the request body as bytes, before json.loads."""
signature = headers.get("X-InfraNest-Signature-V2", "")
timestamp = headers.get("X-InfraNest-Timestamp", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
return False
signed = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)<?php
// $rawBody = file_get_contents('php://input');
function verifyInfraNest(string $rawBody, ?string $timestamp, ?string $signature, string $secret, int $tolerance = 300): bool
{
if ($timestamp === null || ! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $tolerance) {
return false;
}
return hash_equals('sha256='.hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret), (string) $signature);
}
// verifyInfraNest($rawBody, $_SERVER['HTTP_X_INFRANEST_TIMESTAMP'] ?? null, $_SERVER['HTTP_X_INFRANEST_SIGNATURE_V2'] ?? null, getenv('INFRANEST_WEBHOOK_SECRET'));package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"time"
)
// VerifyInfraNest checks a delivery: timestamp and signature are the X-InfraNest-Timestamp and
// X-InfraNest-Signature-V2 headers, rawBody the body as received.
func VerifyInfraNest(rawBody []byte, timestamp, signature, secret string) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if age := time.Now().Unix() - ts; age > 300 || age < -300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}function Test-InfraNestSignature {
param([byte[]]$RawBody, [string]$Timestamp, [string]$Signature, [string]$Secret, [int]$Tolerance = 300)
if ($Timestamp -notmatch '^\d+$') { return $false }
if ([Math]::Abs([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - [long]$Timestamp) -gt $Tolerance) { return $false }
$signed = [Text.Encoding]::UTF8.GetBytes("$Timestamp.") + $RawBody
$hmac = [System.Security.Cryptography.HMACSHA256]::new([Text.Encoding]::UTF8.GetBytes($Secret))
$expected = 'sha256=' + -join ($hmac.ComputeHash($signed) | ForEach-Object { $_.ToString('x2') })
return [System.Security.Cryptography.CryptographicOperations]::FixedTimeEquals(
[Text.Encoding]::ASCII.GetBytes($expected), [Text.Encoding]::ASCII.GetBytes([string]$Signature))
}# BODY holds the raw body; TIMESTAMP and SIGNATURE the X-InfraNest-Timestamp and X-InfraNest-Signature-V2 headers.
expected="sha256=$(printf '%s' "$TIMESTAMP.$BODY" | openssl dgst -sha256 -hmac "$INFRANEST_WEBHOOK_SECRET" | awk '{print $NF}')"
[ "$expected" = "$SIGNATURE" ] && echo "valid" || echo "invalid"Each of these, with a test against a known signature, is in infranest-examples.
The old signature header
X-InfraNest-Signature is deprecated and is sent until 2027-04-30. It carries what it always did, which depends on how the request was sent. On a test delivery or a subscribed event it is the same value as X-InfraNest-Signature-V2. On an event sent by an automation it is the HMAC of the body alone, with no timestamp, so it gives no protection against replay. A receiver that checks it should switch to X-InfraNest-Signature-V2. See the changelog.
Good practice
- Answer
2xxquickly, then do the work. Ten seconds is the limit; a slow job belongs in your own queue. - Expect a delivery twice. A retry after a timeout can repeat one your system already handled. Keep the
X-InfraNest-DeliveryIDs you have seen, or make handling idempotent. - Use HTTPS, and keep the secret where your other secrets are. Rotating it in the app stops the old one at once.
- Do not rely on order. Events can arrive out of order; use
created_atwhen order matters.
Webhooks are the push side. To ask instead, poll the API — for example GET /status for everything that is up or down.