# 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](https://infranest.io/docs/connect-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: | Header | What it carries | |---|---| | `X-InfraNest-Signature-V2` | `sha256=` and the HMAC-SHA256 of `"."`, in hex. See [Verify the signature](#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](#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 ```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**, `"."`, 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. ::: code-group ```js [JavaScript] 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 [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] $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 [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 [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 [Shell] # 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](https://github.com/InfraNest-Infrastructure-Organized/infranest-examples/tree/main/examples/verify-webhook). ### 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](changelog.md). ## 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`](/reference/monitoring#get-organizations-organization-status) for everything that is up or down.