Add an endpoint

Sign in, open Integrations from your account, and add your endpoint’s URL under Webhooks. It must be https. Choose the events it receives, or all of them.

Nolan then shows the endpoint’s signing secret once. Keep it with your other secrets: you need it to check that a request came from Nolan. “New secret” replaces it; the old one stops working at once.

“Send test event” delivers a ping event to that endpoint right away, so you can check your receiver before real events arrive.

What Nolan sends

A POST with a JSON body and these headers:

Content-Typeapplication/json
LeaseOwl-Event-IdThe event’s id, the same on every retry. Use it to ignore an event you already handled.
LeaseOwl-Event-TypeThe event type, for example lead.created.
LeaseOwl-Delivery-IdThis delivery of the event to this endpoint.
LeaseOwl-TimestampWhen this attempt was sent, in Unix seconds.
LeaseOwl-Signaturet=<timestamp>,v1=<signature>. See below.

Check the signature

The signature is an HMAC-SHA256, in lowercase hexadecimal, of the timestamp, a period and the raw request body, keyed with the endpoint’s signing secret. Compute it over the body exactly as received, before any JSON parsing.

Refuse the request when no v1 value matches, or when the timestamp is more than five minutes away from your clock. Compare signatures in constant time. While you rotate a secret, check against both.

Node.js

import crypto from 'node:crypto';

// rawBody: the request body exactly as received, as a string.
export function isFromLeaseOwl(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(signatureHeader.split(',').map((part) => part.trim().split('=', 2)));
  const timestamp = Number(parts.t);
  if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1 ?? '', 'hex');
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}

Retries and failures

  • A delivery succeeds when your endpoint answers with a 2xx status within 10 seconds. Answer first and do the work afterwards.
  • Anything else is retried after about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours: eight attempts over about two days. Retries run every 15 minutes, so short waits can be longer.
  • Delivery is at least once. The same event can arrive twice, and events can arrive out of order: use the event id to skip duplicates, and the timestamps in the data to order them.
  • Redirects are not followed. Use the final URL.
  • After the last attempt, the delivery is marked failed. Failed deliveries are listed under Webhooks on your Integrations page, with a Retry button.
  • An endpoint that answers 410 Gone is turned off. That is how Zapier and Make unsubscribe.

The event

Every event has the same envelope. data holds the objects for its type.

{
  "id": "evt_Qm3c9rT2xV8kLw1sZp4bNg",
  "type": "showing.booked",
  "created_at": "2026-10-07T23:47:12.311Z",
  "api_version": "2026-10-05",
  "organization_id": "4c0d6f3e-2b8a-4f61-9a57-0f3c1d2e8b90",
  "data": {
    "showing": {
      "id": "b7c1f0a2-5d3e-4c8b-8e21-6a9f0d4c3b12",
      "status": "booked",
      "starts_at": "2026-10-09T22:00:00.000Z",
      "ends_at": null,
      "when": "Thursday, October 9 at 6 p.m.",
      "previous_starts_at": null,
      "address": "Beacon Street near Charles Street",
      "listing": { "id": "7f3a1c2d-...", "address": "Beacon Street near Charles Street", "rent": 2400 }
    },
    "lead": {
      "id": "b1e4a9d0-...",
      "name": "Maya Johnson",
      "phone": "+16175550142",
      "email": "",
      "status": "showing_booked",
      "lost_reason": null,
      "channel": "phone",
      "source": "Zillow",
      "source_line": "+16175550100",
      "source_page": null,
      "listing": { "id": "7f3a1c2d-...", "address": "Beacon Street near Charles Street", "rent": 2400 },
      "wants": { "move_in": "November 1", "household": "2 adults", "pets": "1 cat", "unit": "Beacon Street near Charles Street" },
      "summary": "Wants the Beacon Hill one-bedroom for November 1, works nearby, has one cat.",
      "callback_requested": false,
      "owner": null,
      "booking_id": "b7c1f0a2-5d3e-4c8b-8e21-6a9f0d4c3b12",
      "showing_at": "2026-10-09T22:00:00.000Z",
      "showing_outcome": null,
      "created_at": "2026-10-07T23:41:55.120Z",
      "last_contact_at": "2026-10-07T23:47:10.004Z"
    }
  }
}

Event types

lead.createdA renter reached you for the first time, or a lead was added by import, the API or Zapier. data: lead.
lead.updatedAnything about a lead changed: its details, owner, summary, status or showing, whether Nolan or your team changed it. data: lead, changed (the fields that changed, named as in the lead object: name, wants.move_in, owner...).
lead.status_changedA lead moved: new, contacted, qualified, showing_booked, showed, applied, leased or lost. Also sent as lead.updated. data: lead, previous_status, status, detail.
conversation.messageA message was received or sent in a text, chat or email thread. A call arrives as one message with its summary. A draft Nolan wrote is sent once your team approves it. data: conversation, message (direction inbound or outbound, author renter, charlie or team), lead.
showing.bookedA showing was booked, or moved (showing.status is rescheduled). data: showing, lead.
showing.cancelledA showing was cancelled. data: showing, lead.
showing.showedThe renter came to the showing. data: showing, lead.

The lead object

idNolan’s id for the lead.
name, phone, emailAs the renter gave them. Phone numbers are in E.164 (+15145550142).
status, lost_reasonnew, contacted, qualified, showing_booked, showed, applied, leased or lost, and the team’s reason when a lead is lost (else null).
channelHow the renter first reached you: phone, sms, chat, email, messenger or instagram.
source, source_line, source_pageWhere the lead came from, when known: the listing site (or a CSV import, Zapier), the Nolan number called or texted, and the page the chat was opened on. Each is null when unknown.
listingThe listing the renter asked about (id, address, rent), or null.
wantsWhat the renter is looking for: move_in, household, pets, and unit (the address of the unit they want, once known).
summaryNolan’s summary of the conversations so far.
ownerThe team member who owns the lead (id, email, name), or null.
callback_requestedtrue when the renter asked your team to call back.
booking_id, showing_at, showing_outcomeThe current showing: its id, when it starts (ISO 8601), and showed or no_show once your team says. null when there is none.
created_at, last_contact_atWhen the lead was created, and when the renter last wrote or called (ISO 8601).

REST API

Zapier, Make and your own scripts use the same API at https://getnolan.ai/api/v1/. Create a key under “API keys” on your Integrations page. It is shown once.

Send the key in the Authorization header as a bearer token, never in a URL. A key reaches one organization: everything else answers 404.

GET/api/v1/meThe organization the key belongs to. Use it to test a key.
GET/api/v1/events?type=lead.created&limit=50Recent events, newest first, kept 30 days. For polling instead of webhooks.
POST/api/v1/hooksSubscribe a URL to one event type (REST Hooks). Body: url, event. Answers with the hook’s id and signing secret.
DELETE/api/v1/hooks/{id}Unsubscribe.
GET/api/v1/leads?status=new&channel=sms&limit=50Leads, most recent contact first. Merged and deleted leads are left out.
POST/api/v1/leadsAdd a lead. It needs a phone number or an email address. A lead with the same phone number or email is filled in rather than duplicated: empty fields only, never what Nolan or your team recorded.
GET/api/v1/listingsYour listings, active first.

Add a lead

curl https://getnolan.ai/api/v1/leads \
  -H "Authorization: Bearer $LEASEOWL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Maya Johnson", "phone": "617-555-0142", "source": "Zillow", "summary": "Asked about the Beacon Hill one-bedroom"}'

Errors answer with a status and a JSON body: { "error": { "code": "...", "message": "..." } }.

Versions

Each event carries api_version. New fields and new event types can appear at any time, so ignore what you don’t know. A change that removes or renames a field gets a new version, announced on this page first.

Open your integrations