Webhooks
Receive real-time event notifications via HTTP webhooks.
Overview
Webhooks deliver event payloads to a URL you control whenever something notable happens in your HGV Traders account — new enquiries, stock status changes, job applications, and more.
Registering an endpoint
Configure webhook URLs from Dashboard → Settings → Webhooks. Each endpoint can be filtered to receive only the event types you need.
When you create an endpoint, a signing secret is generated with the prefix whsec_ and shown once. Store it securely — it cannot be retrieved again.
URL requirements
Endpoint URLs must be publicly reachable HTTPS URLs. The platform runs SSRF checks at registration and on every delivery — localhost, private IP ranges, and similar targets are rejected.
Payload format
Every webhook delivery is a POST request with a JSON body:
{
"event": "enquiry.created",
"timestamp": "2024-06-01T12:00:00Z",
"data": { ... }
}Verifying signatures
Every delivery is signed with HMAC-SHA256, keyed with your webhook secret, and carries two headers:
| Header | Example | Meaning |
|---|---|---|
X-HGV-Signature | t=1717243200,v1=5f5e01a2… | The signed timestamp (t, unix seconds) and the signature (v1). |
X-HGV-Timestamp | 1717243200 | The same unix-seconds timestamp, also available on its own. |
X-HGV-Event | STOCK_PUBLISHED | The event type that triggered the delivery. |
The X-HGV-Event header carries the internal enum name (e.g. STOCK_PUBLISHED). The JSON body's event field uses a dot-separated slug (e.g. stock.published). Use the header to match your subscribed event types; use the body event field for logging.
The signature is computed over the timestamped payload "{timestamp}.{rawBody}" — not the raw body alone. Including the timestamp in the signed material lets you reject replayed deliveries.
To verify a delivery:
- Parse
tandv1out of theX-HGV-Signatureheader. - Reject the delivery if
tis outside your tolerance window (recommended: 5 minutes —300seconds — either side of your current time). This is what stops an attacker from replaying a captured-but-valid payload later. - Recompute
HMAC-SHA256(secret, "{t}.{rawBody}")and compare it tov1using a constant-time comparison.
Verify against the raw request body before JSON parsing — re-serialising can change bytes and break the signature.
import { createHmac, timingSafeEqual } from 'crypto'
const TOLERANCE_SECONDS = 300 // 5 minutes
function verifySignature(
rawBody: string,
signatureHeader: string,
secret: string,
nowSeconds: number = Math.floor(Date.now() / 1000)
): boolean {
// 1. Parse "t=<unix>,v1=<hex>"
const parts = Object.fromEntries(
signatureHeader.split(',').map((kv) => kv.split('=') as [string, string])
)
const timestamp = Number(parts.t)
const provided = parts.v1
if (!Number.isFinite(timestamp) || !provided) return false
// 2. Reject replays outside the tolerance window
if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false
// 3. Recompute over "{t}.{rawBody}" and compare in constant time
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(provided)
return a.length === b.length && timingSafeEqual(a, b)
}Retry policy
If your endpoint returns a non-2xx status or times out after 10 seconds, delivery is retried with exponential back-off — up to 5 attempts over 24 hours (Inngest-managed).
Return 2xx promptly to acknowledge receipt. Process the payload asynchronously if your handler needs more time.
Delivery logs
The dashboard shows recent delivery attempts per endpoint. Log rows older than 7 days are pruned automatically.
Testing your endpoint
Use Send test on an endpoint in Dashboard → Settings → Webhooks. The platform delivers a signed webhook.test payload:
{
"event": "webhook.test",
"timestamp": "2024-06-01T12:00:00.000Z",
"data": {
"message": "This is a test delivery from HGV Traders.",
"subscribedEvent": "ENQUIRY_CREATED"
}
}The X-HGV-Event header reflects the endpoint's first subscribed event type. Test deliveries use the same HMAC signing scheme as production events. Test requests are rate-limited (shared with auth attempts per organisation).
Available events
| Event | Description |
|---|---|
enquiry.created | A new enquiry was submitted on one of your listings |
enquiry.replied | You replied to a buyer enquiry (email or in-app message) |
stock.published | A stock item was published |
stock.unpublished | A stock item was unpublished |
stock.sold | A stock item was marked as sold |
job.application | A new job application was received |
Event payload reference
Each delivery wraps event-specific fields in data. The top-level shape is always { event, timestamp, data }.
enquiry.created
{
"event": "enquiry.created",
"timestamp": "2024-06-01T12:00:00.000Z",
"data": {
"enquiryId": "clenq123abc",
"stockId": "clstk456def",
"name": "Jane Buyer",
"email": "jane@example.com",
"message": "Is this still available?"
}
}enquiry.replied
{
"event": "enquiry.replied",
"timestamp": "2024-06-01T14:30:00.000Z",
"data": {
"enquiryId": "clenq123abc",
"channel": "email",
"stockId": "clstk456def",
"buyerEmail": "jane@example.com"
}
}channel is "email" for outbound email replies or "message" for in-app messages.
stock.published
{
"event": "stock.published",
"timestamp": "2024-06-01T09:00:00.000Z",
"data": {
"stockId": "clstk456def",
"title": "2020 DAF XF 530 6x2 Tractor Unit"
}
}stock.unpublished
{
"event": "stock.unpublished",
"timestamp": "2024-06-02T11:00:00.000Z",
"data": {
"stockId": "clstk456def",
"title": "2020 DAF XF 530 6x2 Tractor Unit"
}
}stock.sold
{
"event": "stock.sold",
"timestamp": "2024-06-03T16:00:00.000Z",
"data": {
"stockId": "clstk456def",
"title": "2020 DAF XF 530 6x2 Tractor Unit"
}
}job.application
{
"event": "job.application",
"timestamp": "2024-06-04T10:00:00.000Z",
"data": {
"jobId": "cljob789ghi"
}
}