Sync engine

The architecture overview establishes the one rule that matters: PropSocket is a cache, not a proxy. The sync engine is what keeps that cache current. It is the scheduled process that pulls from each connected integration, compares the result against what's already stored, and writes the difference. This page is about the writer side — how data gets in, how fresh it is, and what happens at the edges.

The sync loop

Every run, for every integration, does the same four things in order. There is no live passthrough — a run is the only way new data enters the cache.

Fetchpull from the source

A scheduled worker authenticates with the integration and pulls the current set of records for every property you have enabled.

Diffcompare against the cache

Each fetched record is matched to the cache on its natural key — the (x_id, integration) pair. New x_ids are creates; changed fields are updates; x_ids that vanished from the source are deletions.

Upsertwrite new + changed

Creates and updates are written to the CDM cache. The source is authoritative — its values overwrite whatever the cache held.

Soft-deletemark what disappeared

Records present in the cache but missing from the latest fetch get a non-null deleted_at. Nothing is ever physically removed.

When a run finishes, the engine emits a webhook so you have a precise checkpoint — see run events below. Webhooks are on theScale plan and above; on Starter or Growth, poll for the latest data instead (pricing). The field-level contract for what each record looks like lives in theCommon Data Model; the soft-delete behavior is defined there too.

Initial sync vs. ongoing sync

The first run for a newly connected integration behaves differently from every run after it.

  • Initial sync is a silent bulk load. It pulls the entire enabled dataset and writes it to the cache without firing a per-record event for each one — otherwise connecting a 60,000-unit portfolio would fire 60,000 webhooks. It ends with a singleFIRST_SYNC_COMPLETE event, your signal that the cache is fully populated and safe to read.
  • Ongoing sync runs on your tier's cadence from then on. Each run emits per-record *_CREATED and *_UPDATED events for what actually changed, then a SYNC_COMPLETE (or SYNC_FAILED) summary at the end.

Don't backfill by replaying *_CREATED events — the initial load doesn't emit them. To pull history into a fresh consumer, read the cache over theREST API after FIRST_SYNC_COMPLETE, or follow thebackfill recipe.

Cadence & freshness

The engine runs on a cadence set by your tier. Between runs the cache is static — your data is exactly as current as the last successful sync, no more. Polling the API faster than your sync interval returns the same rows and still counts against yourrate limit; match your read cadence to your sync cadence, or react to webhooks instead. These figures match what's published onpricing.

TierSync cadenceWorst-case staleness
StarterOnce dailyUp to ~24 hours behind the source
GrowthCustomizable, up to every 4 hoursUp to ~4 hours behind the source
ScaleCustomizable, up to every 1 hourUp to ~1 hour behind the source
EnterpriseCustomizable, up to every 15 minutesUp to ~15 minutes behind the source

Worst case assumes a change lands in the source the instant after a run completes — it then waits one full interval to be picked up. For exact per-record freshness, every record carries aps_synced_at timestamp (see per-record freshness).

Run a sync on demand

You don't have to wait for the next scheduled run. From the dashboard,Integrations → your integration → Sync now enqueues a run immediately. This is useful right after you fix expired credentials, enable a new property, or want to confirm a change in the source has landed.

A manual run is the same loop as a scheduled one and emits the same events. It respects the source's own rate limits, so triggering several in quick succession won't make data arrive faster — overlapping runs queue rather than pile on the integration. If a run is already in progress, the next one waits for it to finish.

Choose what syncs

Sync is scoped per property, not all-or-nothing per integration. On the integration's properties list you can enable or disable each property individually; only enabled properties are fetched, written, and metered. Disable the properties you don't need and the engine skips them entirely on every run.

When a new property appears in the source, whether it syncs automatically depends on the integration's require-approval setting: leave it off and newly discovered properties sync by default; turn it on and they arrive disabled, waiting for you to opt them in.

Source always wins

The conflict model is deliberately simple: the source system is the authority.On every run, the values fetched from the integration overwrite the cache — there is no field-level merge, no "cache wins" case, and no way for stale cache data to survive a sync. A record that disappears from the source is soft-deleted, not resurrected.

Writes follow the same principle. When you push a change through PropSocket, it is applied to the source integration first; the cache is then reconciled from the source — a targeted re-pull and merge for the affected records — so what you read back always matches what the system of record actually accepted. You never write directly to the cache, which is why the cache can never drift from the source.

Async write-back

PropSocket's write surface is async by design. Every mutation request is handed off to the source PMS and the result is reconciled back into the cache — a round trip that involves at least one external API call and a targeted re-sync. Because that work cannot complete synchronously within a single HTTP response window, write endpoints return202 Accepted immediately with an operation resource(opn_-prefixed id). The operation is your handle for tracking the work to completion.

Write endpoints

  • PATCH /v1/residents/{id} — update resident contact info, name, or address
  • PATCH /v1/leases/{id} — update lease occupants, move-in date, or rent
  • POST /v1/leases — create a new lease
  • POST /v1/units — create a new unit

All four endpoints follow the same contract: submit your payload, receive a202 with an operation, poll or listen for completion.

Idempotency

Every write request requires an Idempotency-Key request header — a client-generated string (UUID v4 recommended) that uniquely identifies your intent. PropSocket uses it to deduplicate retries safely:

  • Reusing the same key with the same body returns the original operation — no duplicate work is dispatched to the source.
  • Reusing the same key with a different body returns409 Conflict — the key is already bound to different input and cannot be reused. Generate a new key for a genuinely different request.

Keys are scoped to your organization. A missing Idempotency-Key header returns400 Bad Request.

The operation resource

The operation has a two-axis shape. The top-level status is the terminal answer (pendingsucceeded / failed). Two nested objects track the sub-steps:

  • source.state — whether PropSocket has sent the write to the source PMS and whether the source accepted it:pending / applied / rejected.
  • reconcile.state — whether the PropSocket cache has been re-pulled and reconciled against the source after the write landed:pending / reconciled / stale.

status: "succeeded" means source.state: "applied" — the source PMS accepted the write. Reconciliation (reconcile.state: "reconciled") follows shortly after and is independent; the cache catches up via a targeted re-sync regardless of whether you are polling.

The guarantee: A 202 and a resulting operationstatus: "succeeded" guarantee that PropSocket applied your write to the source PMS and the source accepted it; if reconcile.state is not yet"reconciled", the change is already live at the system of record and our cache (and the *_UPDATED webhook) will catch up shortly — a succeededoperation never means "maybe," and only status: "failed" means the source did not accept the write.

Tracking completion

You have two options for learning when an operation finishes:

  • Poll GET /v1/operations/{id} until status is no longer pending. Use exponential backoff — most operations complete within a few seconds for a responsive source, but source-side latency varies.
  • Subscribe to the *_UPDATED webhook for the affected resource type (e.g., RESIDENT_UPDATED). PropSocket fires it whenreconcile.state reaches "reconciled" — by which pointstatus is already "succeeded".

Webhooks are on the Scale plan and above; on Starter or Growth, pollGET /v1/operations/{id} — see pricing.

PATCH /v1/residents/{id} → 202 Accepted
HTTP/1.1 202 Accepted
Content-Type: application/json

{
  "id": "opn_01HX9T4P2R8SDUWC9F2RM9ZGHK",
  "status": "pending",
  "source": {
    "state": "pending"
  },
  "reconcile": {
    "state": "pending"
  },
  "resource_type": "resident",
  "resource_id": "res_01HX1H6U9P4LFXCZW3RNS5EDGB",
  "created_at": "2026-05-11T18:10:00Z",
  "updated_at": "2026-05-11T18:10:00Z"
}
GET /v1/operations/{id} — succeeded + reconciled
{
  "id": "opn_01HX9T4P2R8SDUWC9F2RM9ZGHK",
  "status": "succeeded",
  "source": {
    "state": "applied"
  },
  "reconcile": {
    "state": "reconciled"
  },
  "resource_type": "resident",
  "resource_id": "res_01HX1H6U9P4LFXCZW3RNS5EDGB",
  "created_at": "2026-05-11T18:10:00Z",
  "updated_at": "2026-05-11T18:10:14Z"
}
GET /v1/operations/{id} — failed (source rejected)
{
  "id": "opn_01HX9T4P2R8SDUWC9F2RM9ZGHK",
  "status": "failed",
  "source": {
    "state": "rejected"
  },
  "reconcile": {
    "state": "pending"
  },
  "resource_type": "resident",
  "resource_id": "res_01HX1H6U9P4LFXCZW3RNS5EDGB",
  "error": {
    "code": "source_rejected",
    "message": "Entrata rejected the update: field 'email' failed validation."
  },
  "created_at": "2026-05-11T18:10:00Z",
  "updated_at": "2026-05-11T18:10:08Z"
}

What is and isn't writable

The writable surface reflects what Entrata (the current connector) exposes as mutable via its API. Everything not listed below is read-only — PropSocket has no method to push those changes upstream.

ResourceWritable fields / operationsNot writable
ResidentContact info, name, address (update)
LeaseOccupants, move-in date, rent (update); create new leaseend_date, notice_date, move_out_date — Entrata exposes no write method for these
UnitCreate new unitUpdates to existing units — Entrata exposes no write method
PropertyAll fields — Entrata exposes no write method for properties

As connectors for additional PMS platforms are added, the writable surface may expand. Check the per-connector reference for the source you are using.

Run events

Every run announces itself so you can react without polling. All sync-lifecycle events share the standard webhook envelope and carrycategory: "sync_lifecycle".

  • FIRST_SYNC_COMPLETE — fires once per integration, at the end of the initial bulk load. The cache is now fully populated.
  • SYNC_COMPLETE — fires at the end of every successful ongoing run, with a summary of what changed.
  • SYNC_FAILED — fires when a run could not complete. Your cache is untouched and still holds the last successful run's data.
SYNC_COMPLETE
{
  "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
  }
}

When a sync fails

A failed run is safe by design: the cache is only ever advanced by a successful run, so a failure leaves your data stale but consistent — never half-written. The common causes are source-side credential changes or outages, and transient source rate-limiting.

Recoverable failures retry automatically with backoff. When a run finally fails,SYNC_FAILED carries an error_code, a human-readableerror_message, and whether a retry is scheduled. Persistent per-integration or per-property degradation also raises a HEALTH_CHANGE event so you can alert on it.

SYNC_FAILED
{
  "id": "evt_01HX9S6N5Q0TBUWB7E1QM8YFGJ",
  "type": "SYNC_FAILED",
  "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",
    "failed_at": "2026-05-11T18:01:39Z",
    "error_code": "source_auth_failed",
    "error_message": "Source rejected credentials (HTTP 401).",
    "will_retry": true,
    "next_retry_at": "2026-05-11T18:06:39Z"
  }
}

To self-diagnose: open Integrations → your integration → Sync history in the dashboard for the run's status, counts, and error. If you missed changes during your own downtime, use the reconcile recipe; thetroubleshooting guide covers stuck and lagging syncs. If a sync stays failed, reach out with the integration_id, the sync_job_id, the time window, and the x-request-id from any failing API call — a real engineer replies within one business day.

Per-record freshness

Cadence tells you the worst case; ps_synced_at tells you the exact case, per record. Every record carries it, set to the UTC timestamp of the last successful sync run that covered that record — even if nothing about the record changed. Use it to show "updated 12 minutes ago" in your UI, or to decide whether a re-read is worth it.

It is distinct from updated_at, which moves only when the record's data changed: a record untouched for months still gets a fresh ps_synced_at each time we re-confirm it against the source. And because freshness is per record, a response spanning two integrations — a Yardi unit and an Entrata unit in the same list — carries the correct sync time on each row, with no response-level timestamp having to approximate. ps_synced_at lives in theps_ namespace reserved for PropSocket operational fields; seeConventions.

ps_synced_at vs updated_at on a Unit
{
  "id": "unt_01HX1H6U9P4LFXCZW3RNS5EDGB",
  "updated_at": "2026-02-02T08:15:00Z",
  "ps_synced_at": "2026-05-11T18:04:58Z"
}

Next

See the shape of what the engine writes in theCommon Data Model, the cross-cutting rules inConventions, or wire up the run events in thewebhooks guide.