Menu

Webhooks

Lemon Domains sends a signed POST to your URL within a minute of something happening in your portfolio: a monitored domain changes, a domain gets close to its expiry date or passes it, or a registrar connection can't sync. Use it to start a workflow in n8n, Zapier or Make, or point it at Slack, Discord or Microsoft Teams for alerts in a channel.

Set one up

  1. In the app, open Webhooks and add your URL. Workspace Owners manage webhooks, because they carry events about every domain in the workspace.
  2. Pick the events. The four alerts are on by default; the portfolio events are there when you want them.
  3. Copy the signing secret (whsec_…). It's shown once; rotate it any time for a new one.
  4. Press Send test. The result shows in the delivery log on the webhook's page.

Each workspace can have 10 webhooks. URLs must use https and point to a public host. The same operations are in the REST API, the CLI and the MCP tools, so an AI app can set one up for you.

REST API
curl https://lemon.domains/api/v1/webhooks \
  -H "Authorization: Bearer $LEMON_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/lemon", "events": ["domain.changed", "domain.expiring"]}'
CLI
lemon webhooks add https://example.com/hooks/lemon --events domain.changed,domain.expiring
lemon webhooks test <id>
lemon webhooks deliveries <id> --status failed

Events

  • domain.changed: Monitoring found a change on one of your domains or a domain you watch: registrar, nameservers, transfer lock, expired, became available, DNSSEC, hosting, email provider, SSL, website, parked, blacklist or status codes.
  • domain.expiring: One of your domains reached one of the endpoint's days before expiry (30, 14, 7 and 1 by default).
  • domain.expired: The expiry date of one of your domains passed.
  • registrar.sync_failed: A registrar connection couldn't sync its domains.
  • domain.added: A domain was added to the workspace, in the app, by import, through the API or an AI app, or by a registrar sync.
  • domain.updated: A domain's fields changed: expiry date, auto-renew, status, project, registrar account, notes or archived.
  • domain.removed: A domain was deleted from the workspace.
  • dns.records_changed: DNS records were changed or a change was undone through Lemon Domains.

domain.changed can be narrowed to some change kinds, and to your own domains or the ones you watch in Monitoring. domain.expiring fires on the days you pick from 90, 60, 30, 14, 7, 3, 1 (default 30, 14, 7, 1), counted in UTC. New event types are added over time and existing ones are never renamed, so ignore types you don't know.

Payload

Every event has the same envelope. id is unique per event and stays the same on every retry, so use it to skip duplicates. data depends on the type. Payloads hold your own workspace's data and public records only: never registrant contacts, transfer codes, tokens or keys. Who made a change is shown as a channel in via (web, api, mcp, sync), never a person.

domain.changed

domain.changed
{
  "id": "1f0c9a52-4b7e-4d61-a3c8-6e2f9d0b7a11",
  "type": "domain.changed",
  "occurred_at": "2026-10-24T21:40:12.000Z",
  "workspace": {
    "id": "c4ca4238-a0b9-4382-8dcc-509a6f75849b",
    "name": "Acme"
  },
  "data": {
    "domain": "example.com",
    "domain_id": "8f14e45f-ceea-4e7a-9e2b-1c1d5f3a9b20",
    "ownership": "own",
    "change": {
      "kind": "nameservers",
      "label": "Nameservers changed",
      "area": "dns",
      "old": "ns1.old-host.com, ns2.old-host.com",
      "new": "ns1.new-host.net, ns2.new-host.net"
    },
    "detected_at": "2026-10-24T21:40:12.000Z",
    "url": "https://lemon.domains/domains/8f14e45f-ceea-4e7a-9e2b-1c1d5f3a9b20"
  }
}

domain.expiring

domain.expiring
{
  "id": "1f0c9a52-4b7e-4d61-a3c8-6e2f9d0b7a11",
  "type": "domain.expiring",
  "occurred_at": "2026-10-24T21:40:12.000Z",
  "workspace": {
    "id": "c4ca4238-a0b9-4382-8dcc-509a6f75849b",
    "name": "Acme"
  },
  "data": {
    "domain": "example.com",
    "domain_id": "8f14e45f-ceea-4e7a-9e2b-1c1d5f3a9b20",
    "expiry_date": "2026-11-07",
    "days_left": 14,
    "auto_renew": false,
    "registrar": "Porkbun",
    "project": "Client sites",
    "url": "https://lemon.domains/domains/8f14e45f-ceea-4e7a-9e2b-1c1d5f3a9b20"
  }
}

domain.expired

domain.expired
{
  "id": "1f0c9a52-4b7e-4d61-a3c8-6e2f9d0b7a11",
  "type": "domain.expired",
  "occurred_at": "2026-10-24T21:40:12.000Z",
  "workspace": {
    "id": "c4ca4238-a0b9-4382-8dcc-509a6f75849b",
    "name": "Acme"
  },
  "data": {
    "domain": "example.com",
    "domain_id": "8f14e45f-ceea-4e7a-9e2b-1c1d5f3a9b20",
    "expiry_date": "2026-10-23",
    "auto_renew": false,
    "registrar": "Porkbun",
    "project": "Client sites",
    "url": "https://lemon.domains/domains/8f14e45f-ceea-4e7a-9e2b-1c1d5f3a9b20"
  }
}

registrar.sync_failed

registrar.sync_failed
{
  "id": "1f0c9a52-4b7e-4d61-a3c8-6e2f9d0b7a11",
  "type": "registrar.sync_failed",
  "occurred_at": "2026-10-24T21:40:12.000Z",
  "workspace": {
    "id": "c4ca4238-a0b9-4382-8dcc-509a6f75849b",
    "name": "Acme"
  },
  "data": {
    "registrar": "Namecheap",
    "registrar_account": "Main",
    "registrar_account_id": "3c59dc04-8e1f-4a2b-9b0d-5f8a6f2c1e77",
    "error": "Namecheap refused the API key. Check it in Registrars.",
    "started_at": "2026-10-24T21:15:03.000Z",
    "url": "https://lemon.domains/registrars"
  }
}

domain.added

domain.added
{
  "id": "1f0c9a52-4b7e-4d61-a3c8-6e2f9d0b7a11",
  "type": "domain.added",
  "occurred_at": "2026-10-24T21:40:12.000Z",
  "workspace": {
    "id": "c4ca4238-a0b9-4382-8dcc-509a6f75849b",
    "name": "Acme"
  },
  "data": {
    "domain": "example.com",
    "domain_id": "8f14e45f-ceea-4e7a-9e2b-1c1d5f3a9b20",
    "via": "sync",
    "changes": [],
    "url": "https://lemon.domains/domains/8f14e45f-ceea-4e7a-9e2b-1c1d5f3a9b20"
  }
}

domain.updated

domain.updated
{
  "id": "1f0c9a52-4b7e-4d61-a3c8-6e2f9d0b7a11",
  "type": "domain.updated",
  "occurred_at": "2026-10-24T21:40:12.000Z",
  "workspace": {
    "id": "c4ca4238-a0b9-4382-8dcc-509a6f75849b",
    "name": "Acme"
  },
  "data": {
    "domain": "example.com",
    "domain_id": "8f14e45f-ceea-4e7a-9e2b-1c1d5f3a9b20",
    "via": "api",
    "changes": [
      {
        "field": "Expiry date",
        "old": "2026-11-07",
        "new": "2027-11-07"
      }
    ],
    "url": "https://lemon.domains/domains/8f14e45f-ceea-4e7a-9e2b-1c1d5f3a9b20"
  }
}

domain.removed

domain.removed
{
  "id": "1f0c9a52-4b7e-4d61-a3c8-6e2f9d0b7a11",
  "type": "domain.removed",
  "occurred_at": "2026-10-24T21:40:12.000Z",
  "workspace": {
    "id": "c4ca4238-a0b9-4382-8dcc-509a6f75849b",
    "name": "Acme"
  },
  "data": {
    "domain": "example.com",
    "domain_id": null,
    "via": "web",
    "changes": [],
    "url": null
  }
}

dns.records_changed

dns.records_changed
{
  "id": "1f0c9a52-4b7e-4d61-a3c8-6e2f9d0b7a11",
  "type": "dns.records_changed",
  "occurred_at": "2026-10-24T21:40:12.000Z",
  "workspace": {
    "id": "c4ca4238-a0b9-4382-8dcc-509a6f75849b",
    "name": "Acme"
  },
  "data": {
    "domain": "example.com",
    "domain_id": "8f14e45f-ceea-4e7a-9e2b-1c1d5f3a9b20",
    "via": "mcp",
    "undo": false,
    "changes": [
      "Add TXT record @: \"v=spf1 include:_spf.example.net ~all\""
    ],
    "url": "https://lemon.domains/domains/8f14e45f-ceea-4e7a-9e2b-1c1d5f3a9b20"
  }
}

Verify the signature

Signatures follow the open Standard Webhooks format, so any of its libraries can check them. Each request has three headers:

  • webhook-id: the event id.
  • webhook-timestamp: when this attempt was sent, in seconds. Reject anything more than 5 minutes off, so an old request can't be replayed.
  • webhook-signature: v1, and the base64 HMAC-SHA256 of id.timestamp.body, keyed with the base64 part of your secret. For a day after you rotate the secret there are two, separated by a space; accept either.
Node.js
import { Webhook } from "standardwebhooks"; // npm install standardwebhooks

const wh = new Webhook(process.env.LEMON_WEBHOOK_SECRET); // whsec_...

// Express: keep the raw body, the signature covers it byte for byte.
app.post("/hooks/lemon", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = wh.verify(req.body.toString("utf8"), req.headers);
  } catch {
    return res.status(400).send("bad signature");
  }
  // event.id is the same on every retry: skip ids you've already handled.
  console.log(event.type, event.data.domain);
  res.sendStatus(204);
});
Node.js, no library
import crypto from "node:crypto";

function verify(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = "v1," + crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64");
  return headers["webhook-signature"].split(" ").some(
    (sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
  );
}
Python
from standardwebhooks.webhooks import Webhook  # pip install standardwebhooks

wh = Webhook(os.environ["LEMON_WEBHOOK_SECRET"])  # whsec_...

@app.post("/hooks/lemon")
def lemon_hook():
    try:
        event = wh.verify(request.get_data(), dict(request.headers))
    except Exception:
        return "bad signature", 400
    print(event["type"], event["data"].get("domain"))
    return "", 204

Retries

Answer with any 2xx within 10 seconds and the delivery is done. Anything else is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours: 8 attempts over about two days. Redirects aren't followed. Answer 410 Gone and the webhook is paused straight away.

A webhook whose every attempt has failed for three days is paused, and the workspace's Owners get an email. Resume it from its page once the endpoint works, and what waited in the last three days is sent. Deliveries aren't guaranteed to arrive in order; use occurred_at if order matters. The delivery log keeps 30 days, with each payload and the start of your response, and any delivery can be resent.

Deliveries come from Lemon Domains' servers, whose addresses can change. Check the signature rather than allowing an IP address.

Slack, Discord and Microsoft Teams

Paste an incoming webhook URL from Slack (hooks.slack.com), Discord (discord.com/api/webhooks) or a Teams workflow, and Lemon Domains posts a short readable message instead of JSON: the domain and what happened, the old and new value, and a link to it in Lemon Domains. The format is picked from the URL.

n8n, Zapier and Make

  • n8n: add a Webhook trigger node, set the method to POST, and use its production URL here.
  • Zapier: start a Zap with Webhooks by Zapier, Catch Hook, and use the URL it gives you.
  • Make: start a scenario with a Custom webhook and use its address here.

Send a test from Lemon Domains so the tool learns the fields, then filter on type and map data.domain and the rest.