# ydomain developer documentation Three separate things live here. They share one account and one set of listings, and nothing else: you can use any of them without the others. | | What it is | Start at | | --- | --- | --- | | **API** | An HTTP API you call. Manage listings, read and answer leads, manage endpoints. Needs an API key. | [Authentication](https://ydomain.com/docs/api/authentication) | | **Webhooks** | Calls we make to you. A signed `POST` to a URL of yours whenever something changes. Needs no key. | [Events and deliveries](https://ydomain.com/docs/webhooks) | | **Embeds** | Two script tags. Put a contact form, or the whole landing page, on a domain you own. Needs nothing at all. | [Scripts](https://ydomain.com/docs/embeds) | Most integrations use two of the three: webhooks to hear that something happened, the API to read the details and act. ## The API - **Base URL:** `https://ydomain.com/api/v1` - **Format:** JSON in, JSON out. Send `Accept: application/json`. - **Authentication:** a bearer API key on every request. - **Version:** `v1`. A breaking change gets a new prefix; fields are only ever added inside `v1`. | Page | What is in it | | --- | --- | | [Authentication](https://ydomain.com/docs/api/authentication) | Keys, abilities, rate limits, pagination, errors | | [Domains](https://ydomain.com/docs/api/domains) | List, read, create, update and delete listings | | [Leads](https://ydomain.com/docs/api/leads) | Read enquiries, change status, reply to the buyer | | [Webhook endpoints](https://ydomain.com/docs/api/webhooks) | Create and remove endpoints over the API | ### A first request ```bash curl https://ydomain.com/api/v1/me \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json" ``` ```json { "data": { "id": "01920f1a-6c4e-7a51-9d0b-4c8f2b7e1a33", "name": "Renee de Vries", "email": "renee@example.com", "locale": "nl", "currency": "EUR", "abilities": ["domains:read", "leads:read"], "counts": { "domains": 148, "leads": 27 } } } ``` `GET /me` needs no ability beyond a valid key, so it is the quickest way to check that a key works and to see what it is allowed to do. ### Conventions - **Identifiers** are UUIDv7 strings. A listing can also be addressed by its full domain name, so `GET /domains/example.com` works as well as the id. - **Timestamps** are ISO 8601 with an offset: `2026-02-14T09:31:07+00:00`. - **Money** is a number plus a separate three-letter currency, never a formatted string. `4950` and `"EUR"`, not `"€4.950"`. - **A single record** is returned under `data`. **A list** is returned under `data` with `links` and `meta` beside it. - **Nothing is returned for a delete**: the status is `204` with an empty body. - **Unknown fields in a request body are ignored**, so sending a whole object back after changing one field is safe. ## Webhooks A webhook is the other direction: we `POST` to a URL of yours, signed, whenever something happens on your account. Endpoints are set up under **Settings → Webhooks** in the account area, or over the API. Read [Events and deliveries](https://ydomain.com/docs/webhooks) for the list of events, the body we send and how to verify the signature. ## Embeds Two script tags, for a domain you already own: one puts the whole landing page on it, the other puts only a contact form somewhere on your own page. No key, no account setup, nothing to configure — the script works out which listing it belongs to from the page it runs on. Read [Scripts](https://ydomain.com/docs/embeds). ## Reading these docs as text Every page is also served as Markdown, which is what you want if you are feeding this to a model or a script: - One page: `https://ydomain.com/docs/api/domains.md` - Everything at once: `https://ydomain.com/docs/llms-full.txt` - The index: `https://ydomain.com/llms.txt` --- # Authentication Every request carries an API key as a bearer token. Keys are created in the account area under **Settings → API keys**, with the **New key** button. The key is shown once, on creation, and only a hash is stored, so copy it there and then. A key looks like `12|kX9fQ2...`: a number, a pipe, then a long random string. Send all of it, number and pipe included, after `Bearer`. ```bash curl https://ydomain.com/api/v1/domains \ -H "Authorization: Bearer 12|kX9fQ2..." \ -H "Accept: application/json" ``` A missing or unknown key gives `401`: ```json { "message": "Unauthenticated." } ``` ## Abilities A key holds an explicit list of abilities. Every endpoint declares the one it needs, so a read-only key can never write, whatever it is pointed at. | Ability | Grants | | --- | --- | | `domains:read` | Read listings | | `domains:write` | Create, update and delete listings | | `leads:read` | Read leads and their message threads | | `leads:write` | Change lead status and reply to a buyer | | `webhooks:manage` | List, create and delete webhook endpoints | `GET /me` works with any valid key and reports the abilities the current key holds. Using an endpoint outside them gives `403`: ```json { "message": "Invalid ability provided." } ``` Give a key only what it needs. A script that imports listings does not need `leads:read`, and a reporting job needs nothing beyond the two read abilities. ## Expiry and revoking A key can be given an expiry when it is created; after that moment it is refused like an unknown key. Keys can be revoked one by one under **Settings → API keys**, or all at once under **Settings → Security**. Changing your password revokes every key as well. ## Rate limits - **120 requests per minute** per key. - Unauthenticated requests are limited to 20 per minute per IP address. Every response carries the usual headers: ```http X-RateLimit-Limit: 120 X-RateLimit-Remaining: 117 ``` Over the limit you get `429` with a `Retry-After` header in seconds: ```json { "message": "Too Many Attempts." } ``` Back off for that long rather than retrying immediately; retries inside the window count against the limit too. ## Pagination List endpoints are paginated. `per_page` defaults to 25 and is capped at 100. ```bash curl "https://ydomain.com/api/v1/domains?per_page=2&page=2" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json" ``` ```json { "data": [ { "domain": "example.net" }, { "domain": "example.org" } ], "links": { "first": "https://ydomain.com/api/v1/domains?page=1", "last": "https://ydomain.com/api/v1/domains?page=74", "prev": "https://ydomain.com/api/v1/domains?page=1", "next": "https://ydomain.com/api/v1/domains?page=3" }, "meta": { "current_page": 2, "from": 3, "last_page": 74, "per_page": 2, "to": 4, "total": 148 } } ``` Walk a list by following `links.next` until it is `null`, rather than counting pages yourself: a listing added while you are paging shifts the offsets. ## Errors | Status | Means | | --- | --- | | `401` | No key, an unknown key, or an expired one | | `403` | The key lacks the ability, or the record belongs to someone else | | `404` | No such record on your account | | `422` | The body failed validation | | `429` | Rate limited; wait for `Retry-After` | | `5xx` | Our side. Retry with backoff; a `POST` is not automatically idempotent | A `422` names every field that failed: ```json { "message": "The extension field is required.", "errors": { "extension": ["The extension field is required."], "price": ["The price field must be a number."] } } ``` Note that a record belonging to another account gives `404`, not `403`: the API does not confirm that an id exists elsewhere. --- # Domains A domain is one listing on your account. Everything here needs `domains:read`, and the three writing endpoints need `domains:write`; see [Authentication](https://ydomain.com/docs/api/authentication) for keys and abilities. | Method | Path | Ability | | --- | --- | --- | | `GET` | `/domains` | `domains:read` | | `GET` | `/domains/{domain}` | `domains:read` | | `POST` | `/domains` | `domains:write` | | `PATCH` | `/domains/{domain}` | `domains:write` | | `DELETE` | `/domains/{domain}` | `domains:write` | `{domain}` is either the UUID or the full domain name, so `/domains/example.net` and `/domains/01920f1a-…` address the same listing. ## The domain object ```json { "id": "01920f1a-6c4e-7a51-9d0b-4c8f2b7e1a33", "name": "example", "extension": "net", "domain": "example.net", "description": "Short and easy to say out loud.", "status": "active", "featured": false, "pricing": { "price": 1950, "sale_price": null, "sale_ends_at": null, "effective_price": 1950, "offer_from": 950, "currency": "EUR", "accepts_offers": true }, "metrics": { "seo_score": 29, "moz_da": 31, "moz_links": 623, "moz_spam": 11, "majestic_tf": 28, "majestic_cf": 23, "traffic_per_month": 120, "updated_at": "2026-09-01T03:14:00+00:00" }, "valuation": { "marketplace": 2400, "auction": 1400, "brokerage": 3100, "source": "estivai", "updated_at": "2026-09-01T03:14:00+00:00" }, "categories": ["tech", "short"], "stats": { "views": 412, "leads": 3 }, "url": "https://ydomain.com/domains/example.net", "created_at": "2026-01-08T10:22:41+00:00", "updated_at": "2026-09-12T19:03:55+00:00" } ``` `categories` is present when the listing was loaded with them, which is the case on every endpoint here. ### Status | Value | Meaning | | --- | --- | | `draft` | Not published; only you can see it | | `active` | Published on the marketplace | | `parked` | Published, but says nothing about a sale | | `contact` | Published, no amount shown, offers welcome | | `pending` | Under offer | | `sold` | Sold | | `expired` | Registration lapsed | | `hidden` | Taken off the marketplace, kept on your account | A `parked` listing, and any listing without a price, sale price or minimum offer, shows no price, no offer invitation and no for-sale wording anywhere: not on its page, not in a card, not in structured data. An `offer_amount` sent to such a listing is dropped. This is a legal requirement, not a display preference, so it is enforced on the server and cannot be switched off. `sold` cannot be set through `POST /domains`; it is set by `PATCH` and stamps `sold_at` for you. ### Pricing - `price` — the asking price. - `sale_price` — a temporary lower price; must be below `price`. - `sale_ends_at` — when that sale price stops applying. - `effective_price` — what a buyer actually sees right now: the sale price while it runs, otherwise the price. - `offer_from` — the minimum offer you will consider. - `accepts_offers` — whether the listing takes offers at all. ## List your domains ```bash curl "https://ydomain.com/api/v1/domains?status=active&per_page=2" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json" ``` | Parameter | Default | Notes | | --- | --- | --- | | `status` | — | One of the status values above | | `per_page` | `25` | Capped at 100 | | `page` | `1` | | ```json { "data": [ { "id": "01920f1a-6c4e-7a51-9d0b-4c8f2b7e1a33", "domain": "example.net", "status": "active", "pricing": { "effective_price": 1950, "currency": "EUR" }, "stats": { "views": 412, "leads": 3 } }, { "id": "01920f1a-8d13-7b02-8a71-2f5c9e4d7b10", "domain": "example.org", "status": "parked", "pricing": { "effective_price": null, "currency": "EUR" }, "stats": { "views": 88, "leads": 0 } } ], "links": { "next": "https://ydomain.com/api/v1/domains?page=2" }, "meta": { "current_page": 1, "last_page": 74, "per_page": 2, "total": 148 } } ``` The objects above are abbreviated; the full shape is the one at the top of this page. Newest listings come first. ## Read one domain ```bash curl https://ydomain.com/api/v1/domains/example.net \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json" ``` ```json { "data": { "id": "01920f1a-6c4e-7a51-9d0b-4c8f2b7e1a33", "domain": "example.net", "status": "active", "pricing": { "price": 1950, "currency": "EUR", "accepts_offers": true } } } ``` A domain that is not yours gives `404`. ## Create a domain `name` and `extension` are separate, and the pair must be unique across the marketplace. Everything else is optional; `currency` falls back to your account currency and `status` to `active`. ```bash curl -X POST https://ydomain.com/api/v1/domains \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "name": "example", "extension": "org", "description": "A calm, brandable name for a lighting or wellness brand.", "price": 7250, "offer_from": 3500, "currency": "EUR", "status": "active", "categories": ["tech", "short"] }' ``` | Field | Rules | | --- | --- | | `name` | Required, max 63, letters, digits and hyphens | | `extension` | Required, max 31; `co.uk` is fine | | `description` | Optional, max 5000 | | `status` | Optional, any status except `sold` | | `price`, `sale_price`, `offer_from` | Optional numbers, 0–99,999,999 | | `sale_price` | Must be lower than `price` | | `sale_ends_at` | Optional date in the future | | `currency` | Optional, three letters, a supported currency | | `has_website` | Optional boolean | | `redirect` | Optional `https` URL | | `categories` | Optional, up to 5 category slugs | `201 Created`: ```json { "data": { "id": "01920f2b-51aa-7c14-9e02-6b3d8f1c4e77", "name": "example", "extension": "org", "domain": "example.org", "status": "active", "pricing": { "price": 7250, "effective_price": 7250, "offer_from": 3500, "currency": "EUR", "accepts_offers": true }, "categories": ["short", "tech"], "url": "https://ydomain.com/domains/example.org", "created_at": "2026-09-17T08:12:03+00:00" } } ``` A name that is already listed gives `422`: ```json { "message": "That domain is already listed.", "errors": { "name": ["That domain is already listed."] } } ``` This also fires the `domain.created` webhook. ## Update a domain Send only what changes. Leaving `categories` out keeps the current ones; sending `[]` clears them. ```bash curl -X PATCH https://ydomain.com/api/v1/domains/example.org \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{"price": 6500, "sale_price": 5900, "sale_ends_at": "2026-12-31T23:59:59+00:00"}' ``` ```json { "data": { "domain": "example.org", "pricing": { "price": 6500, "sale_price": 5900, "sale_ends_at": "2026-12-31T23:59:59+00:00", "effective_price": 5900, "currency": "EUR" }, "updated_at": "2026-09-17T08:20:44+00:00" } } ``` Setting `"status": "sold"` stamps the sale date and fires `domain.sold`. Every other change fires `domain.updated`. ## Delete a domain ```bash curl -X DELETE https://ydomain.com/api/v1/domains/example.org \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json" ``` ```http HTTP/1.1 204 No Content ``` The listing is soft deleted: it disappears from the marketplace and from the API, and its leads are kept. Fires `domain.deleted`. --- # Leads A lead is one enquiry about one listing, together with the whole conversation that followed: the buyer's messages, your replies and your private notes. Reading needs `leads:read`, writing needs `leads:write`; see [Authentication](https://ydomain.com/docs/api/authentication). | Method | Path | Ability | | --- | --- | --- | | `GET` | `/leads` | `leads:read` | | `GET` | `/leads/{lead}` | `leads:read` | | `PATCH` | `/leads/{lead}` | `leads:write` | | `POST` | `/leads/{lead}/messages` | `leads:write` | `{lead}` is the lead's UUID. ## The lead object ```json { "id": "01920fc4-7b21-7d90-8e55-9a1c3f7d2b08", "status": "new", "source": "website", "domain": "example.net", "sender": { "name": "Pieter Jansen", "email": "pieter@example.com", "company": "Jansen Bouw", "phone": null }, "message": "Is this name still available, and is the price negotiable?", "offer": { "amount": 1500, "currency": "EUR" }, "reply_to": "example.net.48217@inboxxa.com", "spam_score": 2, "created_at": "2026-09-14T11:02:19+00:00", "last_message_at": "2026-09-15T08:44:51+00:00" } ``` - `offer` is `null` when no amount was named. A listing that may not advertise a sale never carries one, even if an amount was posted to it. - `reply_to` is the unique address for this conversation. Mail sent to it lands back on this lead, which is how email replies stay in the thread. - `spam_score` runs 0–10; anything the filter was sure about arrives with status `spam` and is kept out of your inbox. - `messages` is only present on `GET /leads/{lead}`. ### Status | Value | Meaning | | --- | --- | | `new` | Not answered yet | | `open` | In conversation | | `negotiating` | Talking about price | | `sold` | Ended in a sale | | `lost` | Ended without one | | `spam` | Filtered out | ### Source | Value | Came from | | --- | --- | | `website` | The listing page on the marketplace | | `embed` | An embedded form on your own domain | | `email` | A reply to the lead's own address | | `api` | Created over the API | | `manual` | Entered by you in the account area | ## List leads ```bash curl "https://ydomain.com/api/v1/leads?status=new&domain=example.net" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json" ``` | Parameter | Default | Notes | | --- | --- | --- | | `status` | — | One of the status values above | | `domain` | — | Full domain name, e.g. `example.net` | | `per_page` | `25` | Capped at 100 | | `page` | `1` | | ```json { "data": [ { "id": "01920fc4-7b21-7d90-8e55-9a1c3f7d2b08", "status": "new", "source": "website", "domain": "example.net", "sender": { "name": "Pieter Jansen", "email": "pieter@example.com" }, "offer": { "amount": 1500, "currency": "EUR" }, "created_at": "2026-09-14T11:02:19+00:00" } ], "links": { "next": null }, "meta": { "current_page": 1, "last_page": 1, "per_page": 25, "total": 1 } } ``` Newest first. Poll this if you like, but the `lead.created` webhook is cheaper and arrives in seconds. ## Read one lead The single-lead response adds the full thread. ```bash curl https://ydomain.com/api/v1/leads/01920fc4-7b21-7d90-8e55-9a1c3f7d2b08 \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json" ``` ```json { "data": { "id": "01920fc4-7b21-7d90-8e55-9a1c3f7d2b08", "status": "open", "domain": "example.net", "sender": { "name": "Pieter Jansen", "email": "pieter@example.com" }, "messages": [ { "id": "01920fc4-7b30-71d2-a0f4-1e7b9c5d3a21", "direction": "inbound", "from": "pieter@example.com", "to": "example.net.48217@inboxxa.com", "subject": "Question about example.net", "body_text": "Is this name still available, and is the price negotiable?", "body_html": null, "delivered_at": null, "failed_at": null, "created_at": "2026-09-14T11:02:19+00:00" }, { "id": "01920fc4-9a11-7c03-9b18-77c2e4d18f55", "direction": "outbound", "from": "example.net.48217@inboxxa.com", "to": "pieter@example.com", "subject": "Re: Question about example.net", "body_text": "It is available. I can do 1750 EUR.", "body_html": "
It is available. I can do 1750 EUR.
", "delivered_at": "2026-09-15T08:44:53+00:00", "failed_at": null, "created_at": "2026-09-15T08:44:51+00:00" } ] } } ``` `direction` is `inbound` from the buyer, `outbound` from you, and `note` for a private note that is never sent to anyone. ## Change the status ```bash curl -X PATCH https://ydomain.com/api/v1/leads/01920fc4-7b21-7d90-8e55-9a1c3f7d2b08 \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{"status": "negotiating"}' ``` ```json { "data": { "id": "01920fc4-7b21-7d90-8e55-9a1c3f7d2b08", "status": "negotiating", "domain": "example.net" } } ``` `status` is the only field this endpoint accepts. It fires `lead.status_changed` and `lead.updated`. ## Reply to a lead The reply is emailed to the buyer from the lead's own address, so their answer comes back into the same thread. It is stored as an `outbound` message. ```bash curl -X POST https://ydomain.com/api/v1/leads/01920fc4-7b21-7d90-8e55-9a1c3f7d2b08/messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "body": "It is available. I can do 1750 EUR, transfer through ydomain.", "subject": "Re: Question about example.net" }' ``` | Field | Rules | | --- | --- | | `body` | Required, 2–20,000 characters. Basic HTML is allowed and sanitised | | `subject` | Optional, max 250. Defaults to the thread's subject | `201 Created`: ```json { "data": { "id": "01920fc4-9a11-7c03-9b18-77c2e4d18f55", "direction": "outbound", "from": "example.net.48217@inboxxa.com", "to": "pieter@example.com", "subject": "Re: Question about example.net", "body_text": "It is available. I can do 1750 EUR, transfer through ydomain.", "delivered_at": null, "created_at": "2026-09-15T08:44:51+00:00" } } ``` `delivered_at` is filled in once the mail has actually gone out, which is a moment later; `failed_at` is filled in if it could not be delivered. Replying fires `lead.replied` and `lead.message_sent`. The sender name and address of a thread are fixed the first time you reply, in the account area. After that first reply they no longer change, so the buyer keeps seeing the same sender. --- # Webhook endpoints Creating and removing the endpoints that receive callbacks. What those callbacks look like, and how to verify them, is in [Webhooks](https://ydomain.com/docs/webhooks). All three endpoints need the `webhooks:manage` ability. | Method | Path | | --- | --- | | `GET` | `/webhooks` | | `POST` | `/webhooks` | | `DELETE` | `/webhooks/{webhook}` | ### List endpoints ```bash curl https://ydomain.com/api/v1/webhooks \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json" ``` ```json { "data": [ { "id": "01920fd8-2c19-7e44-b3a7-5d8e1f0c6b92", "name": "Zapier", "url": "https://example.com/hooks/ydomain", "events": ["lead.created", "domain.sold"], "is_active": true, "consecutive_failures": 0, "disabled_at": null, "last_delivered_at": "2026-09-16T21:40:02+00:00", "created_at": "2026-05-02T09:15:44+00:00" } ] } ``` The signing secret is not in this response. It is returned once, when the endpoint is created, and after that only in the account area. ### Create an endpoint ```bash curl -X POST https://ydomain.com/api/v1/webhooks \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "name": "Zapier", "url": "https://example.com/hooks/ydomain", "events": ["lead.created", "lead.replied"] }' ``` | Field | Rules | | --- | --- | | `name` | Optional, max 120 | | `url` | Required, a public HTTPS URL, max 2048 | | `events` | Required, at least one event from the tables above | `201 Created`, with the secret: ```json { "data": { "id": "01920fd8-2c19-7e44-b3a7-5d8e1f0c6b92", "name": "Zapier", "url": "https://example.com/hooks/ydomain", "events": ["lead.created", "lead.replied"], "is_active": true, "secret": "whsec_kF4RbTWsCkyKi782Nk2qt4gKYBriPxCj1eRefzla", "created_at": "2026-09-17T08:31:12+00:00" } } ``` Store that secret now. A URL that resolves to a private address is refused: ```json { "message": "The url must be a publicly reachable address.", "errors": { "url": ["The url must be a publicly reachable address."] } } ``` ### Delete an endpoint ```bash curl -X DELETE https://ydomain.com/api/v1/webhooks/01920fd8-2c19-7e44-b3a7-5d8e1f0c6b92 \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json" ``` ```http HTTP/1.1 204 No Content ``` --- # 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). --- # Embeds Two script tags, for the two things sellers want on a domain they already own: the whole landing page, or just a contact form somewhere on their own page. Both work out which listing they belong to from the host page, so the same line works on every domain on your account. There is no API key here and nothing to configure. Enquiries from an embed arrive as ordinary leads, so they show up in your inbox, over the [API](https://ydomain.com/docs/api/leads) and in the `lead.created` [webhook](https://ydomain.com/docs/webhooks) like any other. Each listing also shows its own snippets in the account area under **Domains → Edit → Integrate**. ## Full landing page Put this on the site the domain itself serves. It replaces the page with the listing's landing page: name, description, metrics, the contact form, and the price when the listing may show one. ```html ``` It is the marketplace listing page, minus the marketplace header and the breadcrumb that leads back into it. The framed copy is `noindex`, so it never competes with the listing itself in search results. | Attribute | Default | Use | | --- | --- | --- | | `data-domain` | The page host | When the page is not served by the listing | | `data-locale` | `` | Force a language | ```html ``` A host that is not listed with us leaves the page untouched, so an accidental install on the wrong site does nothing. ## Contact form only Put this where you want the form. It renders in place, in a frame that reports its own height, so it grows and shrinks with its content. ```html ``` | Attribute | Default | Use | | --- | --- | --- | | `data-domain` | The page host | When the page is not served by the listing | | `data-locale` | `` | Force a language | | `data-target` | Next to the script tag | CSS selector to render into | | `data-max-width` | `560` | Widest the form may get, in pixels | | `data-height` | `720` | Height to start at, before the form reports its own | ```html ``` What to expect: - Enquiries land in your normal lead inbox, tagged with source `embed`. - Views from an embed are counted apart from marketplace views, so the stats page shows parked traffic separately. - A subdomain falls back to the listing above it: a form on `offers.example.net` belongs to `example.net`. - On a listing that may not advertise a sale the form is still there, without any offer field or sale wording. ### Without JavaScript Frame it yourself. This is the only page of ours that may be framed on another site. ```html ``` The framed form posts its height to the parent window, if you want to size it yourself: ```js window.addEventListener('message', (event) => { if (event.origin !== 'https://ydomain.com') return; const payload = event.data && event.data.ydomainEmbed; if (!payload || payload.type !== 'height') return; document.querySelector('iframe').style.height = `${payload.value}px`; }); ``` ## Resolving a host yourself Both scripts use one small public endpoint, and so can you. It needs no authentication and is CORS-open. ```bash curl "https://ydomain.com/embed/resolve?host=offers.example.net&locale=nl" ``` | Parameter | Notes | | --- | --- | | `host` | Hostname to look up. A leading `www.` is ignored | | `locale` | Optional; falls back to the default language | ```json { "found": true, "domain": "example.net", "url": "https://ydomain.com/nl/embed/lead-form/example.net", "landing_url": "https://ydomain.com/nl/embed/landing/example.net", "title": "Make an offer for example.net", "height": 720 } ``` An unknown, hidden or unpublished listing answers plainly, and the scripts then render nothing: ```json { "found": false } ``` `title` never promises a sale the listing may not advertise: on a parked name it comes back as "Contact the owner of example.net". Answers are cached for five minutes, so a listing you have just published can take that long to appear on a parked page.