Webhooks
A webhook subscription asks HoundDog to POST to your endpoint whenever an event of a given type happens on your account. Subscriptions are managed on the Webhooks page of the dashboard or through the API (/api/v2/webhook_subscriptions). Webhook subscriptions created before v2 keep their current payload unchanged; they also get the address check before every delivery and the Webhook Delivery Failing event described below.
Requirements
- The endpoint must be
https://. - The endpoint may not resolve to a private, loopback, link-local or cloud-metadata address. We check at creation and again before every delivery, and each delivery connects only to the addresses that check approved.
- Event types come from the catalog (
GET /api/v2/webhook_event_types): every customer-visible event type the app records. Internal, staff-only events are never delivered.
Payload
{
"event_id": 123456,
"event": "Vendor Became Compliant",
"occurred_at": "2026-10-07T14:03:11Z",
"account_id": 4127,
"party_id": 191537,
"certificate_id": null,
"project_id": null,
"user_id": null,
"api_key": null,
"w9_id": null,
"signing_request_id": null,
"signing_template_id": null,
"parent_event_id": null,
"message": "Acme Electric became compliant",
"data": { },
"links": { "party": "/api/v2/parties/191537" }
}
| Member | Meaning |
|---|---|
event_id, event, occurred_at, account_id, message, data, links |
The event (same id as in Events), its type, and a readable sentence |
party_id, certificate_id, project_id, user_id, api_key |
What the event is about and who acted, when they apply |
parent_event_id |
The event this one belongs to; set on the email-health child events (Email Skipped (Suppressed), Email Recipients Skipped), which carry no party or request id of their own |
w9_id |
The W-9 a W-9 event is about, when the event carries one; null otherwise |
signing_request_id, signing_template_id |
The signing request and template a signing event is about, null for other events |
data is the event's structured details when they parse as JSON, else {"text": "<details>"}.
A Party Edited delivery carries {"changes": [...]} there: every field the edit changed with its old and new value, in the shape described under Edit details. A version 1 subscription receives the event too, with the message naming the changed fields and no details.
The custom field definition events (Custom Field Definition Created, Updated, Archived, Restored and Deleted, in the catalog's "Custom fields" group) carry the field there, in the shape described under Custom field events; they are account-level, so party_id is null.
Email deliverability events (bounce, complaint, block) include the address and reason there.
A W-9 never appears in a payload with its tax identification number.
Fields of features your account does not use are present with an empty value.
links.party is omitted for an event about one of your individuals. Such an event is still delivered, but its ids (the event, the individual, a certificate) answer 404 on the v2 reads, which never serve individuals.
Signature
Every delivery from a v2 subscription carries:
| Header | Value |
|---|---|
X-HoundDog-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256> |
X-HoundDog-Event |
the event type |
X-HoundDog-Delivery-Id |
stable for one event and subscription across retries |
X-HoundDog-Attempt |
1 on the first attempt, incrementing on retries |
X-HoundDog-Payload-Version |
2 |
The signing secret is shown once when the subscription is created and can be rotated (POST /api/v2/webhook_subscriptions/:id/rotate_secret, or the Rotate button on the Webhooks page). To verify a delivery:
- Read
tandv1from the signature header. - Compute
HMAC-SHA256(secret, "<t>.<raw request body>")and compare it tov1with a constant-time comparison. - Reject deliveries whose
tis more than 5 minutes from your clock, to defeat replay.
t, v1 = header.split(",").map { |kv| kv.split("=", 2).last }
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
valid = ActiveSupport::SecurityUtils.secure_compare(expected, v1) && (Time.now.to_i - t.to_i).abs <= 300
Answer 2xx quickly and do your work afterwards; we wait 30 seconds for a response.
Retries and failure
- A connection error, a timeout, or a failed DNS lookup of your host is retried with exponential backoff for about three weeks.
- A
5xxresponse to a version 2 subscription is retried on the same schedule, and so is a version 2 delivery that could not be signed (signing failedorsigning unavailablein the delivery log;signing unavailableis a temporary problem on our side and never counts toward disabling the subscription). - Any other non-
2xxresponse to a version 2 subscription, and every non-2xxresponse to a version 1 subscription, is recorded as a failed attempt and not retried. - The first failed attempt in a run of failures writes a Webhook Delivery Failing event to your account's event log (visible in the dashboard and itself subscribable). It names the subscription by id, event type and host only, never the endpoint's path or query, which often carry a hook's token. A failed delivery of a Webhook Delivery Failing or Webhook Disabled event itself still counts toward the failures but writes no further failing event.
- A version 2 subscription whose every attempted delivery for 7 days failed is disabled with a Webhook Disabled event (version 1 subscriptions are never disabled); re-enable it from the Webhooks page once your endpoint is healthy. Only real deliveries count toward this and toward a run of failures: a test delivery never starts a run or ends one, so a successful test does not reset a failing subscription.
- Recent deliveries, with status codes and durations, are listed at
GET /api/v2/webhook_subscriptions/:id/deliveries;POST /api/v2/webhook_subscriptions/:id/testsends a test delivery (the HTTP request is limited to 10 seconds in total; a host whose DNS does not answer can add a few seconds before it).