# Webhooks A webhook is the opposite direction from the API: instead of you calling us, we `POST` to a URL of yours when something happens on your account. No API key is involved — a delivery is trusted because it is signed, not because it is authenticated. Endpoints are managed in the account area under **Settings → Webhooks**, or over the API; see [Webhook endpoints](https://ydomain.com/docs/api/webhooks). ## Events Subscribe an endpoint to the events you care about; anything you did not subscribe to is never sent. ### Domains | Event | Sent when | | --- | --- | | `domain.created` | A listing is added | | `domain.updated` | A listing changes | | `domain.sold` | A listing moves to status `sold` | | `domain.deleted` | A listing is deleted | ### Leads | Event | Sent when | | --- | --- | | `lead.created` | A new enquiry arrives | | `lead.updated` | The lead record changes | | `lead.status_changed` | Its status changes | | `lead.deleted` | It is moved to the trash | | `lead.replied` | You answer the buyer | ### Messages | Event | Sent when | | --- | --- | | `lead.message_received` | The buyer writes back | | `lead.message_sent` | A reply goes out | ### Subscription and payments | Event | Sent when | | --- | --- | | `subscription.created` | A plan starts | | `subscription.updated` | A plan changes | | `subscription.cancelled` | A plan ends | | `payment.received` | A payment succeeds | | `payment.failed` | A payment fails | Billing is not live yet, so these five are never delivered today. They can already be subscribed to, so an integration is ready when billing arrives. ## What a delivery looks like ```http POST /hooks/ydomain HTTP/1.1 Host: example.com Content-Type: application/json User-Agent: ydomain-webhooks/1.0 X-Ydomain-Event: lead.created X-Ydomain-Signature: t=1789595100,v1=6f1a8c4e2b7d09f3a5c8e1b4d7a2f9c6e3b0d8a5f2c7e4b1d9a6f3c0e7b4d1a8 { "event": "lead.created", "created_at": "2026-09-14T11:02:19+00:00", "data": { "id": "01920fc4-7b21-7d90-8e55-9a1c3f7d2b08", "domain": "example.net", "sender_name": "Pieter Jansen", "offer_amount": 1500, "offer_currency": "EUR" } } ``` The envelope is always `event`, `created_at` and `data`. What sits inside `data` depends on the event: a lead event carries at least `lead_id`, a domain event carries `domain`. Treat `data` as a pointer, not as the whole record — read the record back over the API if you need every field. Answer with any `2xx` within ten seconds. Queue your own work instead of doing it inline; a slow answer counts as a failure. ## Verifying a delivery Never trust a delivery you have not verified. Sign the timestamp and the **raw** body with the endpoint's signing secret and compare in constant time. ``` signed_payload = timestamp + "." + raw_request_body expected = hmac_sha256(secret, signed_payload) valid = hash_equals(expected, v1) and abs(now - timestamp) <= 300 ``` PHP: ```php [$t, $v1] = sscanf($request->header('X-Ydomain-Signature'), 't=%d,v1=%s'); $expected = hash_hmac('sha256', $t.'.'.$request->getContent(), $secret); $valid = hash_equals($expected, $v1) && abs(time() - $t) <= 300; ``` Node: ```js const [, t, v1] = /t=(\d+),v1=([a-f0-9]+)/.exec(req.headers['x-ydomain-signature']); const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); const valid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)) && Math.abs(Date.now() / 1000 - Number(t)) <= 300; ``` Two things go wrong most often: - **Using the parsed and re-encoded JSON** instead of the raw body. Key order and whitespace change, and the digest no longer matches. Capture the raw body before your framework parses it. - **Ignoring the timestamp.** The tolerance is what stops a captured delivery being replayed at you later; five minutes is the intended window. The secret is shown in the account area under **Settings → Webhooks**: open the endpoint and press **Show**. **Rotate** replaces it, at which point deliveries signed with the old secret stop validating, so update your receiver first. ## Retries and failures - A failed attempt is retried five times, with backoff at 10s, 1m, 5m, 30m and 2h. - After 10 consecutive failures the endpoint is disabled. Saving it again re-enables it and resets the counter. - Endpoints must be publicly routable HTTPS URLs. Private, loopback and link-local addresses are refused, and checked again on every attempt, so an endpoint whose DNS is later repointed inward stops being called. - Redirects are not followed. - Recent deliveries, their status codes and response snippets are listed under **Settings → Webhooks**. Deliveries are at-least-once: a receiver that timed out after doing its work will see the same event again. Key your handling on the event plus the record id and make it idempotent. ## Managing endpoints Endpoints are added, edited and deleted in the account area under **Settings → Webhooks**. The same three actions are available over the HTTP API; see [Webhook endpoints](https://ydomain.com/docs/api/webhooks).