---
title: "Webhooks: Expired Domain Alerts Sent to Your Endpoint"
description: "Get a signed POST the moment a domain matches your alert rule, drops unregistered or a check finishes. Payloads, signature verification, retries and exampl…"
url: https://hunter.domains/docs/webhooks
language: en
---

# Webhooks

Stop polling. When something you asked for happens, we send a signed POST to your endpoint: a new match for an alert rule, a watched domain that dropped, a finished check.

## How it works

1. You add an endpoint: an `https://` address that you control.
2. An event happens, for example a newly listed domain matches one of your alert rules.
3. We send a `POST` with a JSON body and a signature header, usually within a minute.
4. Your endpoint answers with any `2xx` status. Anything else, or no answer within 8 seconds, counts as a failure and is retried.

Webhooks are part of Pro. You can have up to 3 endpoints.

## Set up

Add the endpoint on the [API page of your account](https://hunter.domains/account/api), or through the API:

```
curl -X POST "https://hunter.domains/api/v1/webhooks" \
  -H "Authorization: Bearer hd_…" -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.example.com/hunter", "events": ["alert.matched", "domain.available"]}'
```

The response contains the signing secret (`whsec_…`). It is shown once; store it next to your other secrets. Then press "Test" on the account page, or call `POST /webhooks/{id}/test`, and a `ping` event arrives at your endpoint.

The address must be `https://` on a public host name (no IP addresses, no user name or password in the URL), on port 443 or a port above 1023. Redirects are not followed.

To receive `alert.matched` you also need at least one active [alert rule](https://hunter.domains/docs/api#alert-rules).

## Events

| `type` | When it is sent |
| --- | --- |
| `alert.matched` | A newly listed domain matched one of your alert rules. Sent at once, whatever the rule’s email frequency. |
| `domain.available` | A domain you watch, or one that matches a rule, dropped and nobody registered it. |
| `domain.ending_soon` | A watched or matched domain is about to end. Fires at the reminder hours chosen in your notification settings. |
| `check.completed` | A domain check you ordered finished: `status` is `done` or `failed`. |
| `ping` | A test event, sent when you press “Test” or call the test endpoint. |

## Payload

Every event has the same envelope: an `id` that is unique to the event, the `type`, the time and the `data`.

```
{
    "id": "evt_01k6x2f0q8m3r7t9v4b5n6c8dz",
    "type": "alert.matched",
    "created_at": "2026-10-06T16:44:02Z",
    "data": {
        "alert": {
            "id": 12,
            "name": "Health, DR 30+"
        },
        "domain": {
            "domain": "example.org",
            "tld": "org",
            "status": "listed",
            "score": 78,
            "verdict": "ok",
            "flags": [],
            "topic": "Home",
            "language": "en",
            "title": "Seasonal planting guides for small gardens",
            "metrics": {
                "domain_rating": 46,
                "referring_domains": 412,
                "archive_years": 17,
                "archive_first_year": 2006,
                "archive_last_year": 2025,
                "trust_flow": 28,
                "citation_flow": 31
            },
            "listing": {
                "source": "godaddy",
                "type": "bid",
                "price_usd": 25,
                "bids": 3,
                "ends_at": "2026-10-09T17:30:00Z",
                "url": "https://auctions.godaddy.com/trpItemListing.aspx?domain=example.org"
            },
            "found_at": "2026-10-06T16:42:10Z",
            "open": true,
            "detail": "full",
            "report_url": "https://hunter.domains/domain/example.org"
        }
    }
}
```

The `domain` object is the same as in the [API](https://hunter.domains/docs/api#the-domain-object). Fetch `GET /domains/{domain}` when you need the full report.

A domain that dropped unregistered:

```
{
    "id": "evt_01k6x2f0q8m3r7t9v4b5n6c8dz",
    "type": "domain.available",
    "created_at": "2026-10-06T16:44:02Z",
    "data": {
        "domain": {
            "domain": "example.org",
            "tld": "org",
            "status": "available",
            "score": 78,
            "verdict": "ok",
            "flags": [],
            "topic": "Home",
            "language": "en",
            "title": "Seasonal planting guides for small gardens",
            "metrics": {
                "domain_rating": 46,
                "referring_domains": 412,
                "archive_years": 17,
                "archive_first_year": 2006,
                "archive_last_year": 2025,
                "trust_flow": 28,
                "citation_flow": 31
            },
            "listing": {
                "source": "godaddy",
                "type": "bid",
                "price_usd": 25,
                "bids": 3,
                "ends_at": "2026-10-09T17:30:00Z",
                "url": "https://auctions.godaddy.com/trpItemListing.aspx?domain=example.org"
            },
            "found_at": "2026-10-06T16:42:10Z",
            "open": true,
            "detail": "full",
            "report_url": "https://hunter.domains/domain/example.org"
        },
        "registration": {
            "availability": "available",
            "checked_at": "2026-10-06T16:40:00Z",
            "available_since": "2026-10-06T16:40:00Z",
            "register_url": "https://www.spaceship.com/domain-search/?query=example.org",
            "registrar": null,
            "registered_at": null,
            "expires_at": null,
            "caught_after_drop": false
        }
    }
}
```

A finished check carries the summary; the report itself is at `GET /checks/{id}`:

```
{
    "id": "evt_01k6x2f0q8m3r7t9v4b5n6c8dz",
    "type": "check.completed",
    "created_at": "2026-10-06T16:44:02Z",
    "data": {
        "check": {
            "id": "01k6x2f0q8m3r7t9v4b5n6c8dz",
            "domain": "example.org",
            "status": "done",
            "created_at": "2026-10-06T17:02:11Z",
            "finished_at": "2026-10-06T17:03:24Z",
            "score": 78,
            "verdict": "ok",
            "report_url": "https://hunter.domains/domain-checker/report/01k6x2f0q8m3r7t9v4b5n6c8dz"
        }
    }
}
```

Each request also carries these headers:

| Header | Value |
| --- | --- |
| `Hunter-Event` | The event type, for example `alert.matched`. |
| `Hunter-Delivery` | The event id. The same id is sent again on a retry. |
| `Hunter-Signature` | `t=<unix time>,v1=<signature>`. |
| `User-Agent` | `hunter.domains-webhooks/1`. |

## Verifying the signature

Anyone who learns your URL can post to it, so check the signature before you trust a request.

The signature is the HMAC-SHA256 of `<t>.<raw body>` with your signing secret as the key, written in hex. To verify:

1. Read `t` and `v1` from the `Hunter-Signature` header.
2. Compute HMAC-SHA256 over the string `t`, a dot, and the raw request body exactly as received (before any JSON parsing).
3. Compare your result with `v1` using a constant-time comparison.
4. Reject the request if `t` is more than five minutes old; that stops someone replaying a captured request.

Node.js:

```
import crypto from 'node:crypto';

export function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds;
  const given = Buffer.from(parts.v1 ?? '');
  return fresh && given.length === expected.length && crypto.timingSafeEqual(given, Buffer.from(expected));
}
```

Python:

```
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    signed = parts.get("t", "").encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    fresh = abs(time.time() - int(parts.get("t", 0))) <= tolerance
    return fresh and hmac.compare_digest(expected, parts.get("v1", ""))
```

PHP:

```
function verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
    parse_str(str_replace(',', '&', $header), $parts);
    $expected = hash_hmac('sha256', ($parts['t'] ?? '').'.'.$rawBody, $secret);

    return abs(time() - (int) ($parts['t'] ?? 0)) <= $tolerance
        && hash_equals($expected, (string) ($parts['v1'] ?? ''));
}
```

## Retries and failures

- A delivery is tried up to 6 times: at once, then after about 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours.
- Answer quickly with a `2xx` and do the slow work afterwards. A request that takes longer than 8 seconds is treated as failed, even if your code finishes later.
- The same event can arrive more than once, for example when your `2xx` was lost on the way. Use the event `id` to ignore repeats.
- Events can arrive out of order. Use `created_at`, not the order of arrival.
- When 15 deliveries in a row have failed after all their retries, we switch the endpoint off and email you. Switch it back on from the account page or with `PATCH /webhooks/{id}` and `{"active": true}`.
- The last 50 deliveries of an endpoint, with status and response code, are on the account page and at `GET /webhooks/{id}/deliveries`. They are kept for 14 days.

## Sending events to other tools

- **n8n, Make, Zapier, Pipedream:** create a webhook trigger in the tool, paste its URL as your endpoint, and build the rest of the flow there: a Slack message, a row in a sheet, a task.
- **Slack and Discord directly:** their incoming webhooks expect their own body format, so our JSON will be rejected. Put one of the tools above, or a few lines of your own code, in between.
- **Your own server:** verify the signature, answer `200`, then process the event from a queue.

## Frequently asked questions

### Can I get expired domain alerts by webhook?

Yes. Create an alert rule with the filters you want (niche, language, Domain Rating, price, extension), add a webhook endpoint, and every newly listed domain that matches the rule is posted to your endpoint as an `alert.matched` event.

### How fast are webhooks?

Usually within a minute of the event. A domain is matched against alert rules as soon as its review finishes during the daily scan.

### Are webhooks available on the free plan?

No. Webhooks and alert rules are part of Pro. On the free plan you can poll the [API](https://hunter.domains/docs/api) instead.

### What happens if my server is down?

The delivery is retried for about nine hours. If your server comes back in that time, nothing is lost. After that the delivery is marked as failed; you can still read what you missed from `GET /alerts/matches`.

### Why did my endpoint get switched off?

15 deliveries in a row failed after all their retries. Look at the response codes in the delivery list, fix the endpoint, and switch it back on.

Updated: 6 October 2026

## Get a key, make your first request

A free account can create API keys and connect the MCP server to an assistant. No card needed.

[Create a free account](https://hunter.domains/register)

## Don't miss the next good domain.

Set your rule and leave the rest to us. You hear the moment a matching domain is found.

[Get started free](https://hunter.domains/register)
