Skip to content

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:

json
{
  "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:

HeaderWhat it carries
X-InfraNest-Signature-V2sha256= and the HMAC-SHA256 of "<timestamp>.<body>", in hex. See Verify the signature.
X-InfraNest-TimestampUnix time, in seconds, that the signature covers.
X-InfraNest-EventThe event name, the same as event in the body.
X-InfraNest-DeliveryThe delivery's ID, unique per delivery. Use it to ignore a delivery you already handled.
X-InfraNest-SignatureDeprecated, sent until 2027-04-30. See The old signature header.
Content-Typeapplication/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
json
{
  "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
json
{
  "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
json
{
  "event": "domain.dnssec_broken",
  "created_at": "2026-01-15T09:30:00.000000Z",
  "data": {
    "domain": "example.com",
    "sample": true
  }
}
server.status_changed
json
{
  "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
json
{
  "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
json
{
  "event": "dns.records_changed",
  "created_at": "2026-01-15T09:30:00.000000Z",
  "data": {
    "zone": "example",
    "provider": "aws",
    "sample": true
  }
}
dns.live_mismatch
json
{
  "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
json
{
  "event": "watched_domain.dropping_soon",
  "created_at": "2026-01-15T09:30:00.000000Z",
  "data": {
    "domain": "example.com",
    "tld": "com",
    "sample": true
  }
}
watched_domain.available
json
{
  "event": "watched_domain.available",
  "created_at": "2026-01-15T09:30:00.000000Z",
  "data": {
    "domain": "example.com",
    "tld": "com",
    "sample": true
  }
}
watched_domain.registered
json
{
  "event": "watched_domain.registered",
  "created_at": "2026-01-15T09:30:00.000000Z",
  "data": {
    "domain": "example.com",
    "tld": "com",
    "sample": true
  }
}
watched_domain.failed
json
{
  "event": "watched_domain.failed",
  "created_at": "2026-01-15T09:30:00.000000Z",
  "data": {
    "domain": "example.com",
    "tld": "com",
    "sample": true
  }
}
watched_domain.in_auction
json
{
  "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
json
{
  "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
json
{
  "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
json
{
  "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
json
{
  "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
json
{
  "event": "certificate.expired",
  "created_at": "2026-01-15T09:30:00.000000Z",
  "data": {
    "subject": "example",
    "sample": true
  }
}
certificate.changed
json
{
  "event": "certificate.changed",
  "created_at": "2026-01-15T09:30:00.000000Z",
  "data": {
    "subject": "example",
    "sample": true
  }
}
certificate.weak
json
{
  "event": "certificate.weak",
  "created_at": "2026-01-15T09:30:00.000000Z",
  "data": {
    "subject": "example",
    "issues": [
      "example"
    ],
    "sample": true
  }
}
certificate.unexpected
json
{
  "event": "certificate.unexpected",
  "created_at": "2026-01-15T09:30:00.000000Z",
  "data": {
    "subject": "example",
    "sample": true
  }
}
certificate.revoked
json
{
  "event": "certificate.revoked",
  "created_at": "2026-01-15T09:30:00.000000Z",
  "data": {
    "subject": "example",
    "issuer": "example",
    "host": "example",
    "sample": true
  }
}
dynamic_ip.changed
json
{
  "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.

js
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)
}
python
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
<?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'));
go
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))
}
powershell
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))
}
sh
# 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 2xx quickly, 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-Delivery IDs 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_at when order matters.

Webhooks are the push side. To ask instead, poll the API — for example GET /status for everything that is up or down.