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.

Availability. Webhooks are on theScale plan and above (Scale and Enterprise). On Starter or Growth you can still read and page through the CDM via the API — see pricing.

The loop

  1. Create a subscription in Settings → Webhooks → New subscription. Copy the signing secret — you see it once.
  2. Receive the POST. The signature is over the raw request body.
  3. Verify HMAC-SHA256 in constant time. Mismatch → 401, stop.
  4. Return a 2xx within 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

EventFires whenNotes
FIRST_SYNC_COMPLETEThe 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_COMPLETEEvery 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.

EntityCreatedUpdatedLifecycle
PropertyPROPERTY_CREATEDPROPERTY_UPDATED
UnitUNIT_CREATEDUNIT_UPDATED
ResidentRESIDENT_CREATEDRESIDENT_UPDATED
LeaseLEASE_UPDATEDLEASE_SIGNED · LEASE_RENEWED · LEASE_ENDED · LEASE_EVICTED · LEASE_CANCELLED
  • *_CREATED events carry the full record. First time we've seen thisx_id from the source.
  • *_UPDATED events carry only the changed fields, plus the primary key and x_id so 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*_CREATED and *_UPDATED. A changed array 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_at becomes non-null on the next *_UPDATED event. 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.

EventFires whenNotes
AUTOMATION_COMPLETEDA run finished and the file was delivered.Payload carries automation_id, automation_name,run_id, records_exported, and file_name.
AUTOMATION_FAILEDA 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.

POST /your-endpoint
{
  "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.

POST /your-endpoint
{
  "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.

POST /your-endpoint
{
  "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.

POST /your-endpoint
{
  "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.

POST /your-endpoint
{
  "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.

Verify X-PropSocket-Signature
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');
});

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 later

Dead-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.

Dedupe on event id
# 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)

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:3000

Paste 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.