Quickstart
Five steps from a ps_test_ key to a verified webhook. Every step has a runnablecurl plus equivalents in Python, Node, Go, Ruby, PHP, Java, and .NET — paste them as-is once you've swapped in your key. A ps_test_ key reads your real connected data againstapi.propsocket.io, so reads are safe to run as-is.
What you'll have at the end
- A 200 response from
GET /v1/propertieswith your real connected CDM data. - A pagination loop that walks every page until
hasMoreisfalse. - A webhook subscription pointed at an endpoint you control.
- Working HMAC-SHA256 verification on the first event that lands.
Plan for about 20 minutes end-to-end — roughly half of that is waiting for your first integration to finish syncing, which runs once and varies by connector.
Step 1 — Get a test key
Test keys are prefixed ps_test_ and are scoped to your Organization. They read your real connected data and dry-run writes — see Isolated Test Modefor how that works and how live differs. Store the key in an environment variable; don't paste it into source:
export PROPSOCKET_API_KEY="ps_test_YOUR_TEST_KEY"Both ps_test_ and ps_live_ keys authenticate against the same host,api.propsocket.io — the prefix selects the mode, not the URL. Full detail on the auth model lives in Authentication.
Step 2 — Your first 200
List properties. The response is the standard envelope — a meta object and aresults array. Up to 25 records come back by default.
curl https://api.propsocket.io/v1/properties \
-H "Authorization: Bearer ps_test_YOUR_TEST_KEY"import os
import requests
BASE = "https://api.propsocket.io/v1"
KEY = os.environ["PROPSOCKET_API_KEY"] # your ps_test_ key
resp = requests.get(
f"{BASE}/properties",
headers={"Authorization": f"Bearer {KEY}"},
)
resp.raise_for_status()
body = resp.json()
print(body["meta"]) # {'limit': 25, 'offset': 0, 'hasMore': True}
print(len(body["results"])) # up to 25 propertiesconst BASE = "https://api.propsocket.io/v1";
const KEY = process.env.PROPSOCKET_API_KEY; // your ps_test_ key
const resp = await fetch(`${BASE}/properties`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const body = await resp.json();
console.log(body.meta); // { limit: 25, offset: 0, hasMore: true }
console.log(body.results.length); // up to 25 propertiespackage main
import (
"encoding/json"
"fmt"
"net/http"
"os"
)
const BASE = "https://api.propsocket.io/v1"
func main() {
req, _ := http.NewRequest("GET", BASE+"/properties", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("PROPSOCKET_API_KEY")) // your ps_test_ key
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var body struct {
Meta map[string]any `json:"meta"`
Results []map[string]any `json:"results"`
}
json.NewDecoder(resp.Body).Decode(&body)
fmt.Println(body.Meta) // map[hasMore:true limit:25 offset:0]
fmt.Println(len(body.Results)) // up to 25 properties
}require "net/http"
require "json"
require "uri"
BASE = "https://api.propsocket.io/v1"
KEY = ENV.fetch("PROPSOCKET_API_KEY") # your ps_test_ key
uri = URI("#{BASE}/properties")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{KEY}"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
raise "HTTP #{res.code}" unless res.code.to_i == 200
body = JSON.parse(res.body)
puts body["meta"] # {"limit"=>25, "offset"=>0, "hasMore"=>true}
puts body["results"].length # up to 25 properties<?php
$base = "https://api.propsocket.io/v1";
$key = getenv("PROPSOCKET_API_KEY"); // your ps_test_ key
$ch = curl_init("$base/properties");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $key"],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException("HTTP $status");
}
$body = json_decode($raw, true);
print_r($body["meta"]); // ["limit" => 25, "offset" => 0, "hasMore" => true]
echo count($body["results"]), "\n"; // up to 25 propertiesimport java.net.URI;
import java.net.http.*;
import com.fasterxml.jackson.databind.*; // Jackson: the one JSON dependency
var base = "https://api.propsocket.io/v1";
var key = System.getenv("PROPSOCKET_API_KEY"); // your ps_test_ key
var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder(URI.create(base + "/properties"))
.header("Authorization", "Bearer " + key)
.build();
HttpResponse<String> resp = client.send(request, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) throw new RuntimeException("HTTP " + resp.statusCode());
JsonNode body = new ObjectMapper().readTree(resp.body());
System.out.println(body.get("meta")); // {"limit":25,"offset":0,"hasMore":true}
System.out.println(body.get("results").size()); // up to 25 propertiesusing System.Text.Json;
var BASE = "https://api.propsocket.io/v1";
var key = Environment.GetEnvironmentVariable("PROPSOCKET_API_KEY"); // your ps_test_ key
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", key);
var resp = await client.GetAsync($"{BASE}/properties");
resp.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
Console.WriteLine(doc.RootElement.GetProperty("meta")); // {"limit":25,"offset":0,"hasMore":true}
Console.WriteLine(doc.RootElement.GetProperty("results").GetArrayLength()); // up to 25 propertiesYou'll get back something shaped like this:
{
"meta": { "limit": 25, "offset": 0, "hasMore": true },
"results": [
{
"id": "prp_01HX0G5T8N3KEWBYV2QMR4DCFA",
"x_id": "entrata-property-3391",
"integration_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Maple Court Apartments",
"type": "apartment",
"status": "active",
"address": {
"line_1": "1400 Maple Court",
"line_2": null,
"city": "Austin",
"state": "TX",
"postal_code": "78704",
"country": "US"
},
"total_units": 188,
"created_at": "2026-05-01T09:12:44Z",
"updated_at": "2026-05-11T18:04:58Z",
"deleted_at": null
}
]
}Money fields are integer minor units plus a currency, datetimes are UTC ISO 8601, andx_id is the native PMS identifier. The full set of conventions is inthe conventions reference.
Step 3 — Paginate
The envelope tells you whether more pages exist. Loop until meta.hasMore isfalse, bumping offset by your page size each time. Use the max page size (limit=100) for backfills, and sort by created_at:asc so the ordering is stable as new records arrive.
# Page two: skip the first 100, take the next 100.
curl "https://api.propsocket.io/v1/properties?limit=100&offset=100&order-by=created_at:asc" \
-H "Authorization: Bearer ps_test_YOUR_TEST_KEY"import os
import requests
BASE = "https://api.propsocket.io/v1"
KEY = os.environ["PROPSOCKET_API_KEY"]
HEADERS = {"Authorization": f"Bearer {KEY}"}
def all_properties():
offset, limit = 0, 100 # 100 is the max page size
while True:
resp = requests.get(
f"{BASE}/properties",
headers=HEADERS,
params={"limit": limit, "offset": offset, "order-by": "created_at:asc"},
)
resp.raise_for_status()
body = resp.json()
yield from body["results"]
if not body["meta"]["hasMore"]:
return
offset += limit
print(sum(1 for _ in all_properties()))const BASE = "https://api.propsocket.io/v1";
const KEY = process.env.PROPSOCKET_API_KEY;
const HEADERS = { Authorization: `Bearer ${KEY}` };
async function* allProperties() {
let offset = 0;
const limit = 100; // 100 is the max page size
for (;;) {
const url = new URL(`${BASE}/properties`);
url.searchParams.set("limit", String(limit));
url.searchParams.set("offset", String(offset));
url.searchParams.set("order-by", "created_at:asc");
const resp = await fetch(url, { headers: HEADERS });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const body = await resp.json();
yield* body.results;
if (!body.meta.hasMore) return;
offset += limit;
}
}
let count = 0;
for await (const _ of allProperties()) count++;
console.log(count);package main
import (
"encoding/json"
"net/http"
"net/url"
"os"
"fmt"
)
const base = "https://api.propsocket.io/v1"
func allProperties(yield func(map[string]any)) {
offset, limit := 0, 100 // 100 is the max page size
key := os.Getenv("PROPSOCKET_API_KEY")
for {
q := url.Values{}
q.Set("limit", fmt.Sprint(limit))
q.Set("offset", fmt.Sprint(offset))
q.Set("order-by", "created_at:asc")
req, _ := http.NewRequest("GET", base+"/properties?"+q.Encode(), nil)
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
if resp.StatusCode != 200 {
resp.Body.Close()
panic(fmt.Sprintf("HTTP %d", resp.StatusCode))
}
var body struct {
Meta struct{ HasMore bool `json:"hasMore"` } `json:"meta"`
Results []map[string]any `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
resp.Body.Close()
panic(err)
}
resp.Body.Close()
for _, r := range body.Results {
yield(r)
}
if !body.Meta.HasMore {
return
}
offset += limit
}
}
func main() {
count := 0
allProperties(func(_ map[string]any) { count++ })
fmt.Println(count)
}require "net/http"
require "json"
require "uri"
BASE = "https://api.propsocket.io/v1"
KEY = ENV.fetch("PROPSOCKET_API_KEY")
def all_properties
return enum_for(:all_properties) unless block_given?
offset, limit = 0, 100 # 100 is the max page size
loop do
uri = URI("#{BASE}/properties")
uri.query = URI.encode_www_form(
limit: limit, offset: offset, "order-by": "created_at:asc"
)
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{KEY}"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
raise "HTTP #{res.code}" unless res.code.to_i == 200
body = JSON.parse(res.body)
body["results"].each { |r| yield r }
return unless body.dig("meta", "hasMore")
offset += limit
end
end
puts all_properties.count<?php
$base = "https://api.propsocket.io/v1";
$key = getenv("PROPSOCKET_API_KEY");
function all_properties(string $base, string $key): Generator
{
$offset = 0;
$limit = 100; // 100 is the max page size
while (true) {
$q = http_build_query([
"limit" => $limit,
"offset" => $offset,
"order-by" => "created_at:asc",
]);
$ch = curl_init("$base/properties?$q");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $key"],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException("HTTP $status");
}
$body = json_decode($raw, true);
yield from $body["results"];
if (!$body["meta"]["hasMore"]) {
return;
}
$offset += $limit;
}
}
echo iterator_count(all_properties($base, $key)), "\n";import java.net.URI;
import java.net.http.*;
import com.fasterxml.jackson.databind.*; // Jackson: the one JSON dependency
var base = "https://api.propsocket.io/v1";
var key = System.getenv("PROPSOCKET_API_KEY");
var client = HttpClient.newHttpClient();
var mapper = new ObjectMapper();
int offset = 0, limit = 100, count = 0; // 100 is the max page size
while (true) {
var uri = URI.create(base + "/properties?limit=" + limit
+ "&offset=" + offset + "&order-by=created_at:asc");
var request = HttpRequest.newBuilder(uri)
.header("Authorization", "Bearer " + key).build();
var resp = client.send(request, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) throw new RuntimeException("HTTP " + resp.statusCode());
JsonNode body = mapper.readTree(resp.body());
count += body.get("results").size();
if (!body.get("meta").get("hasMore").asBoolean()) break;
offset += limit;
}
System.out.println(count);using System.Text.Json;
var BASE = "https://api.propsocket.io/v1";
var key = Environment.GetEnvironmentVariable("PROPSOCKET_API_KEY");
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", key);
int offset = 0, limit = 100, count = 0; // 100 is the max page size
while (true)
{
var url = $"{BASE}/properties?limit={limit}&offset={offset}&order-by=created_at:asc";
var resp = await client.GetAsync(url);
resp.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
count += doc.RootElement.GetProperty("results").GetArrayLength();
if (!doc.RootElement.GetProperty("meta").GetProperty("hasMore").GetBoolean()) break;
offset += limit;
}
Console.WriteLine(count);Don't fan out parallel requests with pre-computed offsets — inserts between requests will shift the window and you'll skip or double-read records. The why-and-how is inthe pagination guide.
Step 4 — Subscribe to a webhook
Scale planWebhooks require the Scale plan.
Webhook subscriptions are available on the Scale plan and above. If you're on Starter or Growth, you can still read and page through the CDM via the API — see thepricing page for what each tier includes.
Reading is half the picture; the other half is reacting to change without polling. Create a subscription in the dashboard:
- Open Settings → Webhooks → New subscription.
- Paste an HTTPS endpoint you control. For local development, tunnel to your machine with
ngrok http 3000and paste the forwarding URL — thewebhooks guide walks through this. - Pick the events you want — start with
LEASE_SIGNEDto see a full record land. - Save. PropSocket generates a signing secret at creation time. Copy it into your secret manager now — you see it once.
Store the secret the same way you stored your API key — it's the input to signature verification in the next step:
export PROPSOCKET_SIGNING_SECRET="whsec_YOUR_SIGNING_SECRET"Step 5 — Verify the signature
Every event arrives as an HTTP POST carrying an X-PropSocket-Signature header — a lowercase hex HMAC-SHA256 digest over the raw, unmodified request body. Compute the same digest with your signing secret and compare in constant time. The single most common mistake is parsing the JSON and re-serializing it before hashing — that changes the bytes and the signature won't match.
# There's no curl for HMAC verification — the signature is computed
# in your receiver against the raw request body. See the snippets below,
# then read /docs/webhooks for full receiver examples.import hmac
import hashlib
# raw_body is the exact bytes you received — do NOT json.loads then re-dump.
def verify(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)const crypto = require("crypto");
// rawBody is the exact bytes you received — do NOT JSON.parse then re-stringify.
function verify(rawBody, headerSignature, secret) {
if (!headerSignature) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(headerSignature, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
// rawBody is the exact bytes you received — do NOT unmarshal then re-marshal.
func verify(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
}
return hmac.Equal(expected, received) // constant-time
}require "openssl"
# raw_body is the exact bytes you received — do NOT parse then re-dump.
def verify(raw_body, header_signature, secret)
return false if header_signature.nil? || header_signature.empty?
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
Rack::Utils.secure_compare(expected, header_signature) # constant-time
end<?php
// $rawBody is the exact bytes you received — do NOT decode then re-encode.
function verify(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
}import java.security.MessageDigest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.HexFormat;
// rawBody is the exact bytes you received — do NOT parse then re-serialize.
static boolean verify(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));
// constant-time compare
return MessageDigest.isEqual(
expected.getBytes(), headerSignature.getBytes());
}using System.Security.Cryptography;
using System.Text;
// rawBody is the exact bytes you received — do NOT parse then re-serialize.
static bool Verify(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));
}These are the verification cores. For full receiver scaffolding — capturing the raw body, acknowledging fast, processing async, and handling retries — readthe webhooks guide.
Next: pick a recipe
You can now read the CDM, page through it, and react to changes with a verified signature. From here, head to the recipes for task-shaped how-tos — listening for new leases, exporting units nightly, backfilling a warehouse — or browsethe API reference for the full response shapes.