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-Type | application/json |
LeaseOwl-Event-Id | The event’s id, the same on every retry. Use it to ignore an event you already handled. |
LeaseOwl-Event-Type | The event type, for example lead.created. |
LeaseOwl-Delivery-Id | This delivery of the event to this endpoint. |
LeaseOwl-Timestamp | When this attempt was sent, in Unix seconds. |
LeaseOwl-Signature | t=<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": "Rue Saint-Denis near Rue Rachel",
"listing": { "id": "7f3a1c2d-...", "address": "Rue Saint-Denis near Rue Rachel", "rent": 1950 }
},
"lead": {
"id": "b1e4a9d0-...",
"name": "Maya Tremblay",
"phone": "+15145550142",
"email": "",
"status": "showing_booked",
"lost_reason": null,
"channel": "phone",
"source": "Kijiji",
"source_line": "+15145550100",
"source_page": null,
"listing": { "id": "7f3a1c2d-...", "address": "Rue Saint-Denis near Rue Rachel", "rent": 1950 },
"wants": { "move_in": "November 1", "household": "2 adults", "pets": "1 cat", "unit": "Rue Saint-Denis near Rue Rachel" },
"summary": "Wants the Plateau 4½ 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.created | A renter reached you for the first time, or a lead was added by import, the API or Zapier. data: lead. |
lead.updated | Anything 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_changed | A lead moved: new, contacted, qualified, showing_booked, showed, applied, leased or lost. Also sent as lead.updated. data: lead, previous_status, status, detail. |
conversation.message | A 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.booked | A showing was booked, or moved (showing.status is rescheduled). data: showing, lead. |
showing.cancelled | A showing was cancelled. data: showing, lead. |
showing.showed | The renter came to the showing. data: showing, lead. |
The lead object
id | Nolan’s id for the lead. |
name, phone, email | As the renter gave them. Phone numbers are in E.164 (+15145550142). |
status, lost_reason | new, contacted, qualified, showing_booked, showed, applied, leased or lost, and the team’s reason when a lead is lost (else null). |
channel | How the renter first reached you: phone, sms, chat, email, messenger or instagram. |
source, source_line, source_page | Where 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. |
listing | The listing the renter asked about (id, address, rent), or null. |
wants | What the renter is looking for: move_in, household, pets, and unit (the address of the unit they want, once known). |
summary | Nolan’s summary of the conversations so far. |
owner | The team member who owns the lead (id, email, name), or null. |
callback_requested | true when the renter asked your team to call back. |
booking_id, showing_at, showing_outcome | The 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_at | When 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/me | The organization the key belongs to. Use it to test a key. |
| GET | /api/v1/events?type=lead.created&limit=50 | Recent events, newest first, kept 30 days. For polling instead of webhooks. |
| POST | /api/v1/hooks | Subscribe 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=50 | Leads, most recent contact first. Merged and deleted leads are left out. |
| POST | /api/v1/leads | Add 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/listings | Your 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 Tremblay", "phone": "514-555-0142", "source": "Kijiji", "summary": "Asked about the Plateau 4½"}'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.