Webhooks

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:

Headers
POST /your/path HTTP/1.1
Content-Type: application/json
X-GrailHound-Timestamp: 1792672496
X-GrailHound-Signature: sha256=5f0c…e91a
Body
{
  "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" }
}
FieldDescription
eventstringAlways notification.
typestringtracker_hit for a new hit.
notification_uuidstringUnique to the notification.
created_atstringWhen the notification was created, as an ISO 8601 timestamp with a UTC offset.
titlestringThe listing's title.
bodystringThe tracker keyword and the marketplace, as in "jordan 1 chicago on eBay".
urlstringThe listing's marketplace URL.
web_urlstringThe hit's URL in the GrailHound web app.
thumbnail_urlstring nullableThe listing's photo.
priceobject nullableThe listing's price at the time of discovery.
price.amountintegerAn 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.currencystringThe 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 nullableThe marketplace name as the app shows it.
trackerobjectThe tracker that found the hit.
tracker.uuidstringThe tracker's ID, the same on every hit it finds.
tracker.namestringThe tracker's keyword, as in "jordan 1 chicago".
hitobjectThe hit.
hit.uuidstringThe hit's ID, the same one as in web_url.
ruleobject nullableThe rule the webhook is on.
rule.uuidstringThe rule's ID.
rule.namestringThe 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:

  1. Build the signed payload by concatenating the X-GrailHound-Timestamp value, a literal . and the raw request body: {timestamp}.{body}.
  2. Compute the HMAC-SHA256 of the signed payload with your signing key and hex-encode the digest.
  3. Prepend sha256= and compare the result to X-GrailHound-Signature using a constant-time comparison.
  4. 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

ResponseOutcome
2xxDelivered.
3xx, 429, 5xx, timeoutRetried with backoff.
Any other 4xxFailed 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.

Get started for free

GrailHound

GrailHound is available on your app store. Your first tracker is free.