Recipe
Listen for new leases
Availability. Webhooks are on theScale plan and above (Scale and Enterprise). On Starter or Growth, poll
GET /v1/leases instead — see pricing.Problem
You want to do something the moment a lease is signed in the connected PMS — start a billing record, notify a leasing team, kick off a downstream sync — without pollingGET /v1/leases on a timer.
Code
Subscribe to LEASE_SIGNED in Settings → Webhooks, then verify the signature, confirm the event type, and act on the full lease record. LEASE_SIGNEDis a lifecycle event that carries the complete lease (not a diff), so everything you need is in event.data.
Receive LEASE_SIGNED
import hashlib
import hmac
import json
import os
from flask import Flask, request, abort
app = Flask(__name__)
SIGNING_SECRET = os.environ["PROPSOCKET_SIGNING_SECRET"].encode("utf-8")
def verify(raw_body: bytes, signature: str, secret: bytes) -> bool:
if not signature:
return False
expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/webhooks/propsocket")
def receive():
raw_body = request.get_data() # exact bytes — do not re-serialize
if not verify(raw_body, request.headers.get("X-PropSocket-Signature", ""), SIGNING_SECRET):
abort(401)
event = json.loads(raw_body)
# Subscribe to LEASE_SIGNED in the dashboard, but still guard here:
# one subscription may carry several event types.
if event["type"] != "LEASE_SIGNED":
return ("ignored", 200)
lease = event["data"]
upsert_lease(
lease_id=lease["id"],
x_id=lease["x_id"],
property_id=lease["property_id"],
unit_id=lease["unit_id"],
# market_rent is integer minor units + currency, e.g. {"amount": 285000, "currency": "USD"}
market_rent_minor=lease["market_rent"]["amount"],
currency=lease["market_rent"]["currency"],
start_date=lease["start_date"], # "YYYY-MM-DD"
residents=lease["residents"],
)
emit_downstream("lease.created", lease["id"]) # your event bus
return ("ok", 200)const crypto = require('crypto');
const express = require('express');
const app = express();
const SIGNING_SECRET = process.env.PROPSOCKET_SIGNING_SECRET;
app.use('/webhooks/propsocket', express.raw({ type: 'application/json' }));
function verify(rawBody, signature, secret) {
if (!signature) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhooks/propsocket', (req, res) => {
if (!verify(req.body, req.header('X-PropSocket-Signature'), SIGNING_SECRET)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
if (event.type !== 'LEASE_SIGNED') return res.status(200).send('ignored');
const lease = event.data;
upsertLease({
leaseId: lease.id,
xId: lease.x_id,
propertyId: lease.property_id,
unitId: lease.unit_id,
// market_rent is integer minor units + currency, e.g. { amount: 285000, currency: 'USD' }
marketRentMinor: lease.market_rent.amount,
currency: lease.market_rent.currency,
startDate: lease.start_date, // 'YYYY-MM-DD'
residents: lease.residents,
});
emitDownstream('lease.created', lease.id); // your event bus
res.status(200).send('ok');
});package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
)
var secret = []byte(os.Getenv("PROPSOCKET_SIGNING_SECRET"))
func verify(rawBody []byte, sig string, secret []byte) bool {
if sig == "" {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write(rawBody)
received, err := hex.DecodeString(sig)
return err == nil && hmac.Equal(mac.Sum(nil), received)
}
func receive(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body) // exact bytes — do not re-serialize
if !verify(rawBody, r.Header.Get("X-PropSocket-Signature"), secret) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var event struct {
Type string `json:"type"`
Data struct {
ID string `json:"id"`
XID string `json:"x_id"`
PropertyID string `json:"property_id"`
UnitID string `json:"unit_id"`
MarketRent struct {
Amount int `json:"amount"`
Currency string `json:"currency"`
} `json:"market_rent"`
StartDate string `json:"start_date"`
Residents []map[string]any `json:"residents"`
} `json:"data"`
}
json.Unmarshal(rawBody, &event)
// Subscribe to LEASE_SIGNED in the dashboard, but still guard here.
if event.Type != "LEASE_SIGNED" {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ignored"))
return
}
lease := event.Data
upsertLease(lease.ID, lease.XID, lease.PropertyID, lease.UnitID,
// market_rent is integer minor units + currency, e.g. {amount: 285000, currency: "USD"}
lease.MarketRent.Amount, lease.MarketRent.Currency,
lease.StartDate, lease.Residents) // start_date is "YYYY-MM-DD"
emitDownstream("lease.created", lease.ID) // your event bus
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}require "openssl"
require "json"
require "sinatra"
SIGNING_SECRET = ENV.fetch("PROPSOCKET_SIGNING_SECRET")
def verify(raw_body, signature, secret)
return false if signature.nil? || signature.empty?
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
Rack::Utils.secure_compare(expected, signature)
end
post "/webhooks/propsocket" do
request.body.rewind
raw_body = request.body.read # exact bytes — do not re-serialize
sig = request.env["HTTP_X_PROPSOCKET_SIGNATURE"].to_s
halt 401, "invalid signature" unless verify(raw_body, sig, SIGNING_SECRET)
event = JSON.parse(raw_body)
# Subscribe to LEASE_SIGNED in the dashboard, but still guard here:
# one subscription may carry several event types.
return [200, "ignored"] unless event["type"] == "LEASE_SIGNED"
lease = event["data"]
upsert_lease(
lease_id: lease["id"],
x_id: lease["x_id"],
property_id: lease["property_id"],
unit_id: lease["unit_id"],
# market_rent is integer minor units + currency, e.g. {"amount"=>285000, "currency"=>"USD"}
market_rent_minor: lease["market_rent"]["amount"],
currency: lease["market_rent"]["currency"],
start_date: lease["start_date"], # "YYYY-MM-DD"
residents: lease["residents"]
)
emit_downstream("lease.created", lease["id"]) # your event bus
status 200
"ok"
end<?php
$signingSecret = getenv("PROPSOCKET_SIGNING_SECRET");
function verify(string $rawBody, ?string $sig, string $secret): bool
{
if (empty($sig)) {
return false;
}
return hash_equals(hash_hmac("sha256", $rawBody, $secret), $sig);
}
$rawBody = file_get_contents("php://input"); // exact bytes — do not re-serialize
$sig = $_SERVER["HTTP_X_PROPSOCKET_SIGNATURE"] ?? "";
if (!verify($rawBody, $sig, $signingSecret)) {
http_response_code(401);
exit("invalid signature");
}
$event = json_decode($rawBody, true);
// One subscription may carry several event types — guard here.
if ($event["type"] !== "LEASE_SIGNED") {
http_response_code(200);
exit("ignored");
}
$lease = $event["data"];
upsert_lease(
lease_id: $lease["id"],
x_id: $lease["x_id"],
property_id: $lease["property_id"],
unit_id: $lease["unit_id"],
// market_rent is integer minor units + currency, e.g. ["amount" => 285000, "currency" => "USD"]
market_rent_minor: $lease["market_rent"]["amount"],
currency: $lease["market_rent"]["currency"],
start_date: $lease["start_date"], // "YYYY-MM-DD"
residents: $lease["residents"],
);
emit_downstream("lease.created", $lease["id"]); // your event bus
http_response_code(200);
echo "ok";import static spark.Spark.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.HexFormat;
import com.fasterxml.jackson.databind.*; // Jackson: the one JSON dependency
public class NewLease {
static final byte[] SECRET = System.getenv("PROPSOCKET_SIGNING_SECRET").getBytes();
static final ObjectMapper MAPPER = new ObjectMapper();
static boolean verify(byte[] rawBody, String sig, byte[] secret) throws Exception {
if (sig == null || sig.isEmpty()) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
String expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
return MessageDigest.isEqual(expected.getBytes(), sig.getBytes());
}
public static void main(String[] args) {
post("/webhooks/propsocket", (req, res) -> {
byte[] rawBody = req.bodyAsBytes(); // exact bytes — do not re-serialize
if (!verify(rawBody, req.headers("X-PropSocket-Signature"), SECRET)) {
res.status(401);
return "invalid signature";
}
JsonNode event = MAPPER.readTree(rawBody);
// One subscription may carry several event types — guard here.
if (!event.get("type").asText().equals("LEASE_SIGNED")) {
res.status(200);
return "ignored";
}
JsonNode lease = event.get("data");
JsonNode rent = lease.get("market_rent"); // integer minor units + currency
upsertLease(
lease.get("id").asText(),
lease.get("x_id").asText(),
lease.get("property_id").asText(),
lease.get("unit_id").asText(),
rent.get("amount").asInt(),
rent.get("currency").asText(),
lease.get("start_date").asText(), // "YYYY-MM-DD"
lease.get("residents"));
emitDownstream("lease.created", lease.get("id").asText()); // your event bus
res.status(200);
return "ok";
});
}
}using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secret = Encoding.UTF8.GetBytes(
Environment.GetEnvironmentVariable("PROPSOCKET_SIGNING_SECRET")!);
static bool Verify(byte[] rawBody, string? sig, byte[] secret)
{
if (string.IsNullOrEmpty(sig)) return false;
using var hmac = new HMACSHA256(secret);
var expected = Convert.ToHexString(hmac.ComputeHash(rawBody)).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(sig));
}
app.MapPost("/webhooks/propsocket", async (HttpRequest req) =>
{
using var ms = new MemoryStream();
await req.Body.CopyToAsync(ms);
var rawBody = ms.ToArray(); // exact bytes — do not re-serialize
if (!Verify(rawBody, req.Headers["X-PropSocket-Signature"], secret))
return Results.Text("invalid signature", statusCode: 401);
using var doc = JsonDocument.Parse(rawBody);
var root = doc.RootElement;
// One subscription may carry several event types — guard here.
if (root.GetProperty("type").GetString() != "LEASE_SIGNED")
return Results.Text("ignored");
var lease = root.GetProperty("data");
var rent = lease.GetProperty("market_rent"); // integer minor units + currency
UpsertLease(
leaseId: lease.GetProperty("id").GetString(),
xId: lease.GetProperty("x_id").GetString(),
propertyId: lease.GetProperty("property_id").GetString(),
unitId: lease.GetProperty("unit_id").GetString(),
marketRentMinor: rent.GetProperty("amount").GetInt32(),
currency: rent.GetProperty("currency").GetString(),
startDate: lease.GetProperty("start_date").GetString(), // "YYYY-MM-DD"
residents: lease.GetProperty("residents"));
EmitDownstream("lease.created", lease.GetProperty("id").GetString()); // your event bus
return Results.Text("ok");
});
app.Run();Why this works
LEASE_SIGNEDis a lease lifecycle event. Lifecycle events carry the full record, so you don't need a follow-upGET /v1/leases/{id}to enrich it.- Verifying HMAC-SHA256 over the raw body proves the event came from PropSocket. The signature mechanics are in Webhooks → Verify the signature.
- Money arrives as integer minor units plus a currency (
{ "amount": 285000, "currency": "USD" }= $2,850.00). Store the integer; never parse it into a float.
What to watch out for
- One subscription can deliver several event types. Guard on
event.typein code even if you only tickedLEASE_SIGNEDin the dashboard — it keeps the handler correct if someone widens the subscription later. - Delivery is at-least-once. The same
LEASE_SIGNEDcan arrive twice after a retry. Makeupsert_leaseidempotent on the leaseid, or dedupe on the eventidper Dedupe webhooks idempotently. - Acknowledge fast. Return a
2xxwithin 10 seconds, then do the real work asynchronously. A slow handler triggers retries and duplicate processing. - Initial sync is silent. Leases that already existed when you connected the source arrive during the bulk load with no per-record events — only
FIRST_SYNC_COMPLETE. To capture those, run a one-timebackfill.
Next
To pull in leases that predate your subscription, runBackfill after downtime.