Webhooks
Signed, thin events: the key and new status of a listing that changed. Read the listing for the details.
Events
- Event
listing.created- When
- A listing was entered
- Who hears it
- Sent to the owner, and to licensees when the new listing may be publicly marketed.
- Event
listing.updated- When
- A listing changed
- Who hears it
- Sent on any change, photos included, to the owner, and to licensees when the listing was or now is publicly marketed, so a feed can take it down.
- Event
listing.conflict- When
- Same address as another brokerage
- Who hears it
- Sent to the owners of both listings when a listing is entered at, or moved to, the address of another brokerage's active listing, and again when the platform operator decides it. Read /api/v1/conflicts?listing={listingKey} for the details. Never sent to licensees.
Owners hear every event about their own listings. Licensees hear only about listings that may be publicly marketed, plus the change that takes one out of public view, so a feed can take it down. A listing that has never been publicly marketable never reaches a licensee. listing.conflict goes only to the owners of the two listings at one address, and only to a subscription that names it.
Subscribe
curl -X POST "https://listonce.io/api/v1/webhooks" \
-H "Authorization: Bearer $LISTONCE_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/listings", "events": ["listing.updated"]}'The answer holds the signing secret, shown this once. The URL must be https on the standard port, carry no user or password, and resolve only to public addresses; the address is checked again at every send. Redirects are not followed. At most 10 webhooks per organization.
Payload
{
"id": "OL-4K7Q2M:5:a7a332fd-bef7-4c6a-a362-9e9a3467e2d8",
"event": "listing.created",
"listingKey": "OL-4K7Q2M",
"status": "Active",
"updatedAt": "2026-10-26T16:40:05.902+00:00"
}- Header
X-ListOnce-Signature- Holds
sha256=then the hex HMAC SHA-256 oftimestamp.bodyunder the webhook's secret.
- Header
X-ListOnce-Timestamp- Holds
- Unix seconds when this attempt was signed. Refuse old ones to stop replays.
- Header
X-ListOnce-Delivery- Holds
- Stable across retries; equals the payload id. Dedupe on it.
Verify the signature
Compute HMAC SHA-256 over timestamp.body with your secret, using the raw body before any JSON parsing, and compare in constant time. Refuse timestamps more than five minutes old.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received, before JSON parsing.
export function verify(secret, headers, rawBody, toleranceSeconds = 300) {
const timestamp = headers["x-listonce-timestamp"];
const signature = headers["x-listonce-signature"] ?? "";
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const expected = "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
return signature.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
import hashlib
import hmac
import time
# raw_body: the request body bytes exactly as received, before JSON parsing.
def verify(secret: str, headers, raw_body: bytes, tolerance_seconds: int = 300) -> bool:
timestamp = headers.get("X-ListOnce-Timestamp", "")
signature = headers.get("X-ListOnce-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
signed = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Retries
Answer any 2xx within 8 seconds. Anything else, a timeout or a refused connection is retried, eight attempts in all, then the delivery is marked failed.
- Attempt
- 1
- About when
- Right away
- Attempt
- 2
- About when
- 1 min later
- Attempt
- 3
- About when
- 3 min later
- Attempt
- 4
- About when
- 7 min later
- Attempt
- 5
- About when
- 15 min later
- Attempt
- 6
- About when
- 31 min later
- Attempt
- 7
- About when
- 1 h 3 min later
- Attempt
- 8
- About when
- 2 h 3 min later
A scheduler runs each minute, so times can slip by up to a minute. The last result shows on GET /api/v1/webhooks as lastDelivery.
Idempotency
A delivery can arrive more than once, for instance when your answer was lost. X-ListOnce-Delivery (equal to the payload id) stays the same across retries: store it and skip repeats. Events can arrive out of order; compare updatedAt, or read the listing, which is always current.