Webhooks
PropSocket pushes signed HTTPS events the instant the sync engine sees a change in a connected source. No polling, no overnight CSV diffing. Verify the signature, deserialize the payload, ship the side effect. This page covers the event catalog, the payload shapes, signature verification in seven languages, and exactly what happens when delivery fails.
The loop
- Create a subscription in Settings → Webhooks → New subscription. Copy the signing secret — you see it once.
- Receive the POST. The signature is over the raw request body.
- Verify HMAC-SHA256 in constant time. Mismatch → 401, stop.
- Return a
2xxwithin 10 seconds, then process asynchronously.
Event catalog
Events fall into three categories: sync lifecycle (the engine finished a run),data change (something in your CDM moved), and automation (a scheduled job finished). Every event shares the same envelope; the type field tells you which is which.
Sync lifecycle
| Event | Fires when | Notes |
|---|---|---|
| FIRST_SYNC_COMPLETE | The first full sync of a newly connected source finishes. | Fires once per integration. The bulk load is silent — no per-record events during initial sync. |
| SYNC_COMPLETE | Every subsequent sync run finishes. | Fires once per run, after all data-change events for that run. A useful checkpoint. |
Data change
Entity events fire on the regular sync cadence after initial sync completes. Cadence is tier-controlled — every hour on Scale, down to every 15 minutes on Enterprise.
| Entity | Created | Updated | Lifecycle |
|---|---|---|---|
| Property | PROPERTY_CREATED | PROPERTY_UPDATED | — |
| Unit | UNIT_CREATED | UNIT_UPDATED | — |
| Resident | RESIDENT_CREATED | RESIDENT_UPDATED | — |
| Lease | — | LEASE_UPDATED | LEASE_SIGNED · LEASE_RENEWED · LEASE_ENDED · LEASE_EVICTED · LEASE_CANCELLED |
*_CREATEDevents carry the full record. First time we've seen thisx_idfrom the source.*_UPDATEDevents carry only the changed fields, plus the primary key andx_idso you can locate the record on your end. The changed fields use the same names and shapes they take in a full record, so one upsert path handles both*_CREATEDand*_UPDATED. Achangedarray names exactly which fields moved — branch on it without diffing against your own copy.- Deletions never fire as their own event type. The record's
deleted_atbecomes non-null on the next*_UPDATEDevent. We never physically delete synced records.
Automation
Scheduled automations — CSV exports today — emit an event on every run. Use them to drive downstream processing of a delivered file, or to alert when a run fails.
| Event | Fires when | Notes |
|---|---|---|
| AUTOMATION_COMPLETED | A run finished and the file was delivered. | Payload carries automation_id, automation_name,run_id, records_exported, and file_name. |
| AUTOMATION_FAILED | A run failed — immediately on a permanent error, or after exhausting retries on a transient one. | Same fields, plus error_message and attempt_number. |
Payload examples
Datetimes are UTC ISO 8601. Money is integer minor units plus a currency field — no floats, no rounding drift.
A new lease was signed
LEASE_SIGNED — full record.
{
"id": "evt_01HX9P3K2N7QZRWY4B8MJ5VCDF",
"type": "LEASE_SIGNED",
"category": "data_change",
"created_at": "2026-05-11T17:42:08Z",
"organization_id": "b3f1c2d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"integration_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"data": {
"id": "lse_01HX9P3K2N7QZRWY4B8MJ5VCDF",
"x_id": "entrata-lease-8847291",
"integration_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"property_id": "prp_01HX0G5T8N3KEWBYV2QMR4DCFA",
"unit_id": "unt_01HX1H6U9P4LFXCZW3RNS5EDGB",
"resident_ids": ["res_01HX2J7V0Q5MGYDAW4SOT6FEHC"],
"status": "active",
"type": "fixed",
"start_date": "2026-06-01",
"end_date": "2027-05-31",
"term_months": 12,
"rent_amount": { "amount": 272500, "currency": "USD" },
"security_deposit": { "amount": 285000, "currency": "USD" },
"balance": { "amount": 0, "currency": "USD" },
"signed_date": "2026-05-11",
"is_renewal": false,
"residents": [
{
"resident_id": "res_01HX2J7V0Q5MGYDAW4SOT6FEHC",
"x_id": "entrata-resident-44218",
"role": "primary"
}
],
"custom_data": {},
"created_at": "2026-05-11T17:41:52Z",
"updated_at": "2026-05-11T17:41:52Z",
"deleted_at": null,
"ps_synced_at": "2026-05-11T18:04:58Z"
}
}A resident's phone number changed
RESIDENT_UPDATED — only the changed fields appear under data, in the same shape they take in a full record. The primary key and x_id are always included, and the changed array lists exactly which fields moved.
{
"id": "evt_01HX9Q4L3O8RASVZ5C9NK6WDEG",
"type": "RESIDENT_UPDATED",
"category": "data_change",
"created_at": "2026-05-11T18:03:22Z",
"organization_id": "b3f1c2d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"integration_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"data": {
"id": "res_01HX2J7V0Q5MGYDAW4SOT6FEHC",
"x_id": "entrata-resident-44218",
"phones": [
{ "type": "mobile", "number": "+14155550118", "primary": true }
],
"updated_at": "2026-05-11T18:03:14Z",
"changed": ["phones"]
}
}A lease's rent changed
LEASE_UPDATED — any non-status-transition field change on a lease (status moves fire the lifecycle events instead). Same partial-record shape as the other *_UPDATEDevents: only the changed fields appear under data, alongside the primary key,x_id, and a changed array.
{
"id": "evt_01HX9Q7N5R0TBUWB7E1QM8YFGH",
"type": "LEASE_UPDATED",
"category": "data_change",
"created_at": "2026-05-11T18:03:31Z",
"organization_id": "b3f1c2d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"integration_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"data": {
"id": "lse_01HX9P3K2N7QZRWY4B8MJ5VCDF",
"x_id": "entrata-lease-8847291",
"rent_amount": { "amount": 280000, "currency": "USD" },
"updated_at": "2026-05-11T18:03:27Z",
"changed": ["rent_amount"]
}
}A sync run finished
SYNC_COMPLETE — a checkpoint event summarizing the run.
{
"id": "evt_01HX9R5M4P9SBTWA6D0PL7XEFH",
"type": "SYNC_COMPLETE",
"category": "sync_lifecycle",
"created_at": "2026-05-11T18:05:00Z",
"organization_id": "b3f1c2d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"integration_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"data": {
"sync_job_id": "syj_01HX9R0G1H2J3K4L5M6N7P8Q9R",
"started_at": "2026-05-11T18:00:12Z",
"finished_at": "2026-05-11T18:04:58Z",
"records_new": 14,
"records_updated": 287,
"records_soft_deleted": 2
}
}A scheduled export completed
AUTOMATION_COMPLETED — the run delivered records_exported rows asfile_name. An AUTOMATION_FAILED event carries the same shape pluserror_message and attempt_number. SeeAutomations.
{
"id": "evt_01HXA5Q9R7N2KJ4BWZ8M3VYC6D",
"type": "AUTOMATION_COMPLETED",
"category": "automation",
"created_at": "2026-06-08T06:00:14Z",
"organization_id": "b3f1c2d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"integration_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"data": {
"automation_id": "atm_01HXA4T0V8P5LFXCZW3RNS5EDG",
"automation_name": "Nightly units -> BI SFTP",
"run_id": "aru_01HXA5Q8N4M2KJ4BWZ8M3VYC6D",
"records_exported": 2483,
"file_name": "unit_2026-06-08.csv"
}
}Verify the signature
Every request carries X-PropSocket-Signature — a lowercase hex HMAC-SHA256 digest computed over the raw, unmodified request body using the signing secret you got at subscription creation. Verify it before doing anything else with the payload, and compare in constant time — never with a plain string equality check, which leaks timing.
The single most common reason a signature fails to match is parsing the JSON and re-serializing it before hashing. That re-orders keys and changes whitespace, so the bytes differ and the digest differs. Hash the bytes you received, exactly as you received them.
All seven implementations below verify against the raw body with the standard library — no third-party crypto dependency. The algorithm is identical across languages.
const crypto = require('crypto');
const express = require('express');
const app = express();
const SIGNING_SECRET = process.env.PROPSOCKET_SIGNING_SECRET;
// Capture the raw body — required for signature verification.
app.use('/webhooks/propsocket', express.raw({ type: 'application/json' }));
function verifySignature(rawBody, headerSignature, secret) {
if (!headerSignature) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const receivedBuf = Buffer.from(headerSignature, 'hex');
if (expectedBuf.length !== receivedBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}
app.post('/webhooks/propsocket', (req, res) => {
const signature = req.header('X-PropSocket-Signature');
if (!verifySignature(req.body, signature, SIGNING_SECRET)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
enqueueForProcessing(event); // acknowledge fast, process async
res.status(200).send('ok');
});import hmac
import hashlib
import os
from flask import Flask, request, abort
app = Flask(__name__)
SIGNING_SECRET = os.environ["PROPSOCKET_SIGNING_SECRET"].encode("utf-8")
def verify_signature(raw_body: bytes, header_signature: str, secret: bytes) -> bool:
if not header_signature:
return False
expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header_signature)
@app.post("/webhooks/propsocket")
def receive_webhook():
raw_body = request.get_data() # bytes, untouched
signature = request.headers.get("X-PropSocket-Signature", "")
if not verify_signature(raw_body, signature, SIGNING_SECRET):
abort(401)
event = request.get_json()
enqueue_for_processing(event) # acknowledge fast, process async
return ("ok", 200)package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
var signingSecret = []byte(os.Getenv("PROPSOCKET_SIGNING_SECRET"))
func verifySignature(rawBody []byte, headerSignature string, secret []byte) bool {
if headerSignature == "" {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write(rawBody)
expected := mac.Sum(nil)
received, err := hex.DecodeString(headerSignature)
if err != nil {
return false
}
// hmac.Equal is constant-time and length-safe.
return hmac.Equal(expected, received)
}
func receiveWebhook(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body) // exact bytes, untouched
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
sig := r.Header.Get("X-PropSocket-Signature")
if !verifySignature(rawBody, sig, signingSecret) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
enqueueForProcessing(rawBody) // acknowledge fast, process async
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}require "openssl"
require "sinatra"
SIGNING_SECRET = ENV.fetch("PROPSOCKET_SIGNING_SECRET")
def verify_signature(raw_body, header_signature, secret)
return false if header_signature.nil? || header_signature.empty?
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
# secure_compare is constant-time and length-safe.
Rack::Utils.secure_compare(expected, header_signature)
end
post "/webhooks/propsocket" do
request.body.rewind
raw_body = request.body.read # exact bytes, untouched
signature = request.env["HTTP_X_PROPSOCKET_SIGNATURE"].to_s
halt 401, "invalid signature" unless verify_signature(raw_body, signature, SIGNING_SECRET)
event = JSON.parse(raw_body)
enqueue_for_processing(event) # acknowledge fast, process async
status 200
"ok"
end<?php
// Plain PHP — read the raw body from php://input before any framework parses it.
$signingSecret = getenv("PROPSOCKET_SIGNING_SECRET");
function verify_signature(string $rawBody, ?string $headerSignature, string $secret): bool
{
if (empty($headerSignature)) {
return false;
}
$expected = hash_hmac("sha256", $rawBody, $secret);
return hash_equals($expected, $headerSignature); // constant-time, length-safe
}
$rawBody = file_get_contents("php://input"); // exact bytes, untouched
$signature = $_SERVER["HTTP_X_PROPSOCKET_SIGNATURE"] ?? "";
if (!verify_signature($rawBody, $signature, $signingSecret)) {
http_response_code(401);
echo "invalid signature";
exit;
}
$event = json_decode($rawBody, true);
enqueue_for_processing($event); // acknowledge fast, process async
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;
public class Webhook {
static final byte[] SECRET =
System.getenv("PROPSOCKET_SIGNING_SECRET").getBytes();
static boolean verifySignature(byte[] rawBody, String headerSignature, byte[] secret)
throws Exception {
if (headerSignature == null || headerSignature.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(), headerSignature.getBytes());
}
public static void main(String[] args) {
post("/webhooks/propsocket", (req, res) -> {
byte[] rawBody = req.bodyAsBytes(); // exact bytes, untouched
String sig = req.headers("X-PropSocket-Signature");
if (!verifySignature(rawBody, sig, SECRET)) {
res.status(401);
return "invalid signature";
}
enqueueForProcessing(rawBody); // acknowledge fast, process async
res.status(200);
return "ok";
});
}
}using System.Security.Cryptography;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secret = Encoding.UTF8.GetBytes(
Environment.GetEnvironmentVariable("PROPSOCKET_SIGNING_SECRET")!);
static bool VerifySignature(byte[] rawBody, string? headerSignature, byte[] secret)
{
if (string.IsNullOrEmpty(headerSignature)) 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(headerSignature));
}
app.MapPost("/webhooks/propsocket", async (HttpRequest req) =>
{
using var ms = new MemoryStream();
await req.Body.CopyToAsync(ms);
var rawBody = ms.ToArray(); // exact bytes, untouched
var sig = req.Headers["X-PropSocket-Signature"].ToString();
if (!VerifySignature(rawBody, sig, secret))
return Results.Text("invalid signature", statusCode: 401);
EnqueueForProcessing(rawBody); // acknowledge fast, process async
return Results.Text("ok");
});
app.Run();Signature still mismatching after this? Walk the checklist inTroubleshooting → Webhook signature mismatch.
Delivery guarantees
PropSocket delivers webhooks at least once. Your endpoint must be idempotent — see Dedupe webhooks idempotently for the worked pattern. Here is exactly what happens when something goes wrong.
Retry schedule
Exponential backoff. Any non-2xx response, or a connection that hangs past the 10-second timeout, counts as a failed attempt. After the final retry — roughly 7 hours 36 minutes of total elapsed time — the event lands in your dead-letter queue.
attempt 1 → immediate
attempt 2 → 30 seconds later
attempt 3 → 1 minute later
attempt 4 → 5 minutes later
attempt 5 → 30 minutes later
attempt 6 → 1 hour later
attempt 7 → 6 hours laterDead-letter queue
Failed events surface in Dashboard → Webhooks → Dead-letter queue with the full payload, every attempt's response code, and a one-click replay. Nothing is silently dropped.
Secret rotation
Rotate secret with a grace window (default 24h). During it, events are signed with both secrets — X-PropSocket-Signature andX-PropSocket-Signature-Previous. Verify against either.
Ordering
Events are dispatched in production order, but retries can re-order delivery. Don't depend on receipt order — use the event created_at and the record's updated_at.
Idempotency
Because delivery is at-least-once, your handler will occasionally see the same event twice — after a retry, or after a manual replay. Dedupe on the event id (a ULID, prefixedevt_): record the IDs you've successfully processed and short-circuit on repeats.
# Idempotency: dedupe on the event id (a ULID, prefixed evt_).
# At-least-once delivery means duplicates are expected, not exceptional.
import redis
r = redis.from_url(os.environ["REDIS_URL"])
def already_processed(event_id: str) -> bool:
# SET NX returns True only the first time we see this id.
# 7-day TTL comfortably outlasts the ~7h36m retry window.
return not r.set(f"webhook:seen:{event_id}", "1", nx=True, ex=7 * 24 * 3600)
# in your handler, after signature verification:
event = json.loads(raw_body)
if already_processed(event["id"]):
return ("ok", 200) # ack and drop; we've handled this one
process(event)// Idempotency: dedupe on the event id (a ULID, prefixed evt_).
// At-least-once delivery means duplicates are expected, not exceptional.
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
async function alreadyProcessed(eventId) {
// SET NX returns null when the key already exists.
// 7-day TTL comfortably outlasts the ~7h36m retry window.
const set = await redis.set(`webhook:seen:${eventId}`, '1', {
NX: true,
EX: 7 * 24 * 3600,
});
return set === null;
}
// in your handler, after signature verification:
const event = JSON.parse(rawBody);
if (await alreadyProcessed(event.id)) {
return res.status(200).send('ok'); // ack and drop; already handled
}
process(event);// Idempotency: dedupe on the event id (a ULID, prefixed evt_).
// At-least-once delivery means duplicates are expected, not exceptional.
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
var rdb = redis.NewClient(&redis.Options{Addr: "localhost:6379"})
func alreadyProcessed(ctx context.Context, eventID string) bool {
// SetNX returns false (won=false) when the key already exists.
// 7-day TTL comfortably outlasts the ~7h36m retry window.
won, _ := rdb.SetNX(ctx, "webhook:seen:"+eventID, "1", 7*24*time.Hour).Result()
return !won
}
// in your handler, after signature verification:
// if alreadyProcessed(ctx, event.ID) { return /* ack 200 and drop */ }
// process(event)# Idempotency: dedupe on the event id (a ULID, prefixed evt_).
# At-least-once delivery means duplicates are expected, not exceptional.
require "redis"
REDIS = Redis.new(url: ENV.fetch("REDIS_URL"))
def already_processed?(event_id)
# SET NX returns false when the key already exists.
# 7-day TTL comfortably outlasts the ~7h36m retry window.
!REDIS.set("webhook:seen:#{event_id}", "1", nx: true, ex: 7 * 24 * 3600)
end
# in your handler, after signature verification:
event = JSON.parse(raw_body)
return [200, {}, ["ok"]] if already_processed?(event["id"]) # ack and drop
process(event)<?php
// Idempotency: dedupe on the event id (a ULID, prefixed evt_).
// At-least-once delivery means duplicates are expected, not exceptional.
$redis = new Redis();
$redis->connect("127.0.0.1", 6379);
function already_processed(Redis $redis, string $eventId): bool
{
// SET with NX returns false when the key already exists.
// 7-day TTL comfortably outlasts the ~7h36m retry window.
return !$redis->set("webhook:seen:$eventId", "1", ["nx", "ex" => 7 * 24 * 3600]);
}
// in your handler, after signature verification:
$event = json_decode($rawBody, true);
if (already_processed($redis, $event["id"])) {
http_response_code(200);
echo "ok"; // ack and drop; already handled
exit;
}
process($event);// Idempotency: dedupe on the event id (a ULID, prefixed evt_).
// At-least-once delivery means duplicates are expected, not exceptional.
import redis.clients.jedis.JedisPooled;
import redis.clients.jedis.params.SetParams;
JedisPooled jedis = new JedisPooled(System.getenv("REDIS_URL"));
boolean alreadyProcessed(String eventId) {
// SET NX returns null when the key already exists.
// 7-day TTL comfortably outlasts the ~7h36m retry window.
String set = jedis.set("webhook:seen:" + eventId, "1",
new SetParams().nx().ex(7 * 24 * 3600));
return set == null;
}
// in your handler, after signature verification:
// if (alreadyProcessed(event.get("id").asText())) return; // ack 200 and drop
// process(event);// Idempotency: dedupe on the event id (a ULID, prefixed evt_).
// At-least-once delivery means duplicates are expected, not exceptional.
using StackExchange.Redis;
var redis = await ConnectionMultiplexer.ConnectAsync(
Environment.GetEnvironmentVariable("REDIS_URL")!);
var db = redis.GetDatabase();
async Task<bool> AlreadyProcessed(string eventId)
{
// StringSet with When.NotExists returns false when the key already exists.
// 7-day TTL comfortably outlasts the ~7h36m retry window.
bool won = await db.StringSetAsync(
$"webhook:seen:{eventId}", "1",
TimeSpan.FromDays(7), When.NotExists);
return !won;
}
// in your handler, after signature verification:
// if (await AlreadyProcessed(eventId)) return Results.Text("ok"); // ack and drop
// await Process(evt);The full recipe, including how this interacts with replays from the dashboard, is inDedupe webhooks idempotently.
Local development
You don't need a deployed staging environment to integrate webhooks. Tunnel to localhost withngrok orcloudflared and point it at your local server:
ngrok http 3000
# forwarding https://abcd-1234.ngrok-free.app -> http://localhost:3000Paste the HTTPS forwarding URL as your subscription endpoint. Real events flow to your local handler with the real signing secret, so your verification code is exercised end-to-end. To eyeball payloads before you write a receiver, drop awebhook.site URL into a subscription. For a tight loop, replay any delivery from Dashboard → Webhooks → Recent deliveries(retained 30 days). The full setup lives in Local development.
FAQ
Are events delivered in order?
Within a single integration, events are dispatched in the order the sync engine produced them. Retries can re-order what your endpoint actually receives, so treat ordering as best-effort. Usecreated_at on the envelope and updated_at on the record to resolve out-of-order updates.
How should I handle replays and idempotency?
Every event carries a unique id (ULID, prefixed evt_). Store the IDs you've processed and short-circuit on repeats. We deliver at-least-once, so duplicates are expected. See the dedupe recipe.
Can I subscribe to a subset of events?
Yes. Subscription configuration is event-by-event. You can also run multiple subscriptions per integration to route different events to different endpoints (lease events to billing, resident events to your CRM sync).
What's the maximum payload size?
Standard payloads are well under 100 KB. We cap any single delivery at 1 MB; nothing we emit today comes close.
What happens if my endpoint is down for hours?
The retry schedule runs out at roughly 7 hours 36 minutes (6 attempts after the initial). After that, the event moves to your dead-letter queue with the full payload preserved. When your endpoint recovers, replay from the dashboard — individually or in bulk.
Are the headers signed, or just the body?
The signature covers the raw body only. Don't trust header values for anything authentication-sensitive. The body contains everything that matters — includingorganization_id, integration_id, and the event type — so verifying the body is sufficient.
Next
Wire a specific use case with Listen for new leases. Recovering missed events after an outage? See Reconcile after an outage.