Connect GrailHound to your own workflow.
What is a webhook?
A webhook is a notification delivery method that allows GrailHound to talk to your own solution and pass on the listing's details when a tracker finds a new result.
Setting up a webhook
A webhook must be triggered through a notification group. To set up a webhook for your tracker, go to its Notification page and enable webhooks for an existing notification group, or create a new one with webhook notification enabled. You can toggle webhooks on or off at any time.
The request
Each new hit triggers a POST request with a JSON body:
POST /your/path HTTP/1.1
Content-Type: application/json
X-GrailHound-Timestamp: 1792672496
X-GrailHound-Signature: sha256=5f0c…e91a{
"event": "notification",
"type": "tracker_hit",
"notification_uuid": "0192f1a4-7c3e-7b10-9d2a-5e6f7a8b9c0d",
"created_at": "2026-10-22T12:34:56+00:00",
"title": "Air Jordan 1 Chicago 1985, size 9",
"body": "jordan 1 chicago on eBay",
"url": "https://www.ebay.com/itm/1234567890",
"web_url": "https://web.grailhound.app/hit/0192f1a4-0000-7000-8000-000000000001",
"thumbnail_url": "https://i.ebayimg.com/images/g/abc/s-l500.jpg",
"price": { "amount": 45000, "currency": "USD" },
"marketplace": "eBay",
"tracker": { "uuid": "0192f1a4-0000-7000-8000-000000000002", "name": "jordan 1 chicago" },
"hit": { "uuid": "0192f1a4-0000-7000-8000-000000000001" },
"rule": { "uuid": "0192f1a4-0000-7000-8000-000000000003", "name": "Under $500" }
}| Field | Description |
|---|---|
| eventstring | Always notification. |
| typestring | tracker_hit for a new hit. |
| notification_uuidstring | Unique to the notification. |
| created_atstring | When the notification was created, as an ISO 8601 timestamp with a UTC offset. |
| titlestring | The listing's title. |
| bodystring | The tracker keyword and the marketplace, as in "jordan 1 chicago on eBay". |
| urlstring | The listing's marketplace URL. |
| web_urlstring | The hit's URL in the GrailHound web app. |
| thumbnail_urlstring nullable | The listing's photo. |
| priceobject nullable | The listing's price at the time of discovery. |
| price.amountinteger | An integer in the currency's smallest unit. Divide it by 10 to the power of the currency's ISO 4217 minor unit: 45000 is $450.00 in USD and ¥45,000 in JPY. |
| price.currencystring | The three-letter ISO 4217 code, such as USD, EUR or JPY. It's the listing's own currency, never converted to the one your account displays. |
| marketplacestring nullable | The marketplace name as the app shows it. |
| trackerobject | The tracker that found the hit. |
| tracker.uuidstring | The tracker's ID, the same on every hit it finds. |
| tracker.namestring | The tracker's keyword, as in "jordan 1 chicago". |
| hitobject | The hit. |
| hit.uuidstring | The hit's ID, the same one as in web_url. |
| ruleobject nullable | The rule the webhook is on. |
| rule.uuidstring | The rule's ID. |
| rule.namestring | The rule's name, or an empty string if it has none. |
Signature validation
Every request is signed with HMAC-SHA256 using your account's signing key. To verify a request:
- Build the signed payload by concatenating the
X-GrailHound-Timestampvalue, a literal.and the raw request body:{timestamp}.{body}. - Compute the HMAC-SHA256 of the signed payload with your signing key and hex-encode the digest.
- Prepend
sha256=and compare the result toX-GrailHound-Signatureusing a constant-time comparison. - Reject the request if the timestamp is more than 300 seconds from your server's current time.
Verify against the raw request body. Parsing and re-serializing the JSON changes the bytes, and verification will fail.
import crypto from 'node:crypto';
// rawBody: the request body exactly as received, before any JSON parsing.
export function isFromGrailHound(secret, rawBody, timestamp, signature) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature ?? '');
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_GRAILHOUND_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_GRAILHOUND_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
$fresh = abs(time() - (int) $timestamp) < 300;
if (!$fresh || !hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}import hashlib, hmac, time
def is_from_grailhound(secret: str, raw_body: bytes, timestamp: str, signature: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
fresh = abs(time.time() - int(timestamp)) < 300
return fresh and hmac.compare_digest(expected, signature)Delivery and retries
| Response | Outcome |
|---|---|
2xx | Delivered. |
3xx, 429, 5xx, timeout | Retried with backoff. |
Any other 4xx | Failed permanently. Not retried, and counts toward auto-disable. |
Retries
A delivery gets up to five attempts. The first is sent as soon as the listing is discovered and passes the filters. Each retry follows the previous failed attempt by 30 seconds, 1 minute, 5 minutes and 15 minutes. Connection errors and responses slower than the 10-second timeout are retried the same way.
If you need us to back off, respond with a 429 and a Retry-After header in seconds. We'll wait at least that long before the next attempt, up to an hour.
Auto-disable
Five consecutive permanent failures disable the webhook. Retried errors don't count, even when they run out of attempts. A URL that resolves to a private address fails permanently without a request being sent. A successful delivery resets the count.
Duplicates
If your server processes a request but responds after the timeout, the retry delivers the same hit again. Deduplicate on notification_uuid together with rule.uuid.
Batching and fan-out
Each request carries one hit. Webhooks are never batched into digests or held for quiet hours. When several rules on a tracker match the same hit, each rule with a webhook sends its own request, even when they share a URL.
Rate limit
Each account can receive up to 1,200 webhook requests per hour. Requests over the limit wait for the next window and are still delivered.
FAQs
01 Do webhooks cost extra?
No. AI checks on the webhook's rule still count toward your monthly AI-check allowance.
02 Do I need SSL?
Yes, a valid SSL certificate is required.
03 Can I test a webhook on my own computer?
Yes, with a tunnel like ngrok or Cloudflare Tunnel. We can't send to localhost or private IP addresses.
04 Can I send hits to Slack or Telegram?
Not directly. You'd need Zapier, Make or a script of your own to turn our JSON into a Slack or Telegram message.
Let GrailHound keep watch.
Free to start, no card required.