Recipe
Export units for one property nightly to CSV
Problem
A downstream system — a BI tool, a partner feed, a spreadsheet someone refuses to give up — wants a nightly CSV of every unit in a single property. You want a stable file, generated on a schedule, with no manual steps.
This recipe is the do-it-yourself path: the public REST API plus a scheduler you already run (cron, a Celery beat task, a GitHub Action). If you'd rather PropSocket own the schedule and delivery, use managed CSV export Automationsinstead. Reach for this recipe when you want full control over the file shape, the transport, or a destination automations don't deliver to.
Code
Filter GET /v1/units by property_id, page to the end, and write the rows. Sort by created_at:asc so the page boundaries stay stable as units are added.
export_units.py / export_units.mjs
import csv
import os
import requests
BASE = "https://api.propsocket.io/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['PROPSOCKET_API_KEY']}"}
PROPERTY_ID = os.environ["PROPERTY_ID"] # the one property you're exporting
def all_units(property_id):
offset, limit = 0, 100 # 100 is the max page size
while True:
resp = requests.get(
f"{BASE}/units",
headers=HEADERS,
params={
"property_id": property_id,
"limit": limit,
"offset": offset,
"order-by": "created_at:asc", # stable order across pages
},
)
resp.raise_for_status()
body = resp.json()
yield from body["results"]
if not body["meta"]["hasMore"]:
return
offset += limit
def export(property_id, out_path):
fields = ["id", "x_id", "unit_number", "building", "status", "type", "bedrooms", "bathrooms"]
with open(out_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
for unit in all_units(property_id):
writer.writerow(unit)
if __name__ == "__main__":
export(PROPERTY_ID, "units.csv")import { writeFile } from 'node:fs/promises';
const BASE = 'https://api.propsocket.io/v1';
const HEADERS = { Authorization: `Bearer ${process.env.PROPSOCKET_API_KEY}` };
const PROPERTY_ID = process.env.PROPERTY_ID;
async function* allUnits(propertyId) {
let offset = 0;
const limit = 100; // 100 is the max page size
for (;;) {
const url = new URL(`${BASE}/units`);
url.searchParams.set('property_id', propertyId);
url.searchParams.set('limit', String(limit));
url.searchParams.set('offset', String(offset));
url.searchParams.set('order-by', 'created_at:asc'); // stable order across pages
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;
}
}
const fields = ['id', 'x_id', 'unit_number', 'building', 'status', 'type', 'bedrooms', 'bathrooms'];
const esc = (v) => {
const s = v == null ? '' : String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const rows = [fields.join(',')];
for await (const unit of allUnits(PROPERTY_ID)) {
rows.push(fields.map((f) => esc(unit[f])).join(','));
}
await writeFile('units.csv', rows.join('\n') + '\n');package main
import (
"encoding/csv"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
)
const base = "https://api.propsocket.io/v1"
var fields = []string{"id", "x_id", "unit_number", "building", "status", "type", "bedrooms", "bathrooms"}
func itoa(n int) string { return strconv.Itoa(n) }
func toStr(v any) string { if v == nil { return "" }; return fmt.Sprintf("%v", v) }
func main() {
key := os.Getenv("PROPSOCKET_API_KEY")
propertyID := os.Getenv("PROPERTY_ID") // the one property you're exporting
f, _ := os.Create("units.csv")
defer f.Close()
w := csv.NewWriter(f)
defer w.Flush()
w.Write(fields)
offset, limit := 0, 100 // 100 is the max page size
for {
q := url.Values{}
q.Set("property_id", propertyID)
q.Set("limit", "100")
q.Set("offset", itoa(offset))
q.Set("order-by", "created_at:asc") // stable order across pages
req, _ := http.NewRequest("GET", base+"/units?"+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("HTTP " + strconv.Itoa(resp.StatusCode)) }
var body struct {
Meta struct{ HasMore bool `json:"hasMore"` } `json:"meta"`
Results []map[string]any `json:"results"`
}
json.NewDecoder(resp.Body).Decode(&body)
resp.Body.Close()
for _, u := range body.Results {
row := make([]string, len(fields))
for i, fld := range fields {
row[i] = toStr(u[fld]) // encoding/csv quotes commas for you
}
w.Write(row)
}
if !body.Meta.HasMore {
break
}
offset += limit
}
}require "csv"
require "net/http"
require "json"
require "uri"
BASE = "https://api.propsocket.io/v1"
KEY = ENV.fetch("PROPSOCKET_API_KEY")
PROPERTY_ID = ENV.fetch("PROPERTY_ID") # the one property you're exporting
FIELDS = %w[id x_id unit_number building status type bedrooms bathrooms]
def all_units(property_id)
return enum_for(:all_units, property_id) unless block_given?
offset, limit = 0, 100 # 100 is the max page size
loop do
uri = URI("#{BASE}/units")
uri.query = URI.encode_www_form(
property_id: property_id, limit: limit, offset: offset,
"order-by": "created_at:asc" # stable order across pages
)
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 { |u| yield u }
return unless body.dig("meta", "hasMore")
offset += limit
end
end
CSV.open("units.csv", "w") do |csv|
csv << FIELDS
all_units(PROPERTY_ID).each { |u| csv << FIELDS.map { |f| u[f] } } # CSV quotes commas
end<?php
$base = "https://api.propsocket.io/v1";
$key = getenv("PROPSOCKET_API_KEY");
$propertyId = getenv("PROPERTY_ID"); // the one property you're exporting
$fields = ["id", "x_id", "unit_number", "building", "status", "type", "bedrooms", "bathrooms"];
function all_units(string $base, string $key, string $propertyId): Generator
{
$offset = 0;
$limit = 100; // 100 is the max page size
while (true) {
$q = http_build_query([
"property_id" => $propertyId,
"limit" => $limit,
"offset" => $offset,
"order-by" => "created_at:asc", // stable order across pages
]);
$ch = curl_init("$base/units?$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;
}
}
$out = fopen("units.csv", "w");
fputcsv($out, $fields);
foreach (all_units($base, $key, $propertyId) as $unit) {
fputcsv($out, array_map(fn ($f) => $unit[$f] ?? "", $fields)); // fputcsv quotes commas
}
fclose($out);import java.net.URI;
import java.net.http.*;
import java.io.FileWriter;
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 propertyId = System.getenv("PROPERTY_ID"); // the one property you're exporting
var fields = new String[] {"id", "x_id", "unit_number", "building", "status", "type", "bedrooms", "bathrooms"};
var client = HttpClient.newHttpClient();
var mapper = new ObjectMapper();
try (var out = new FileWriter("units.csv")) {
out.write(String.join(",", fields) + "\n");
int offset = 0, limit = 100; // 100 is the max page size
while (true) {
var uri = URI.create(base + "/units?property_id=" + propertyId
+ "&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());
for (JsonNode u : body.get("results")) {
var row = new StringBuilder();
for (int i = 0; i < fields.length; i++) {
var v = u.path(fields[i]).asText("");
if (i > 0) row.append(",");
// minimal CSV quoting for commas and quotes
row.append(v.matches(".*[\",\n].*") ? '"' + v.replace("\"", "\"\"") + '"' : v);
}
out.write(row + "\n");
}
if (!body.get("meta").get("hasMore").asBoolean()) break;
offset += limit;
}
}using System.Text;
using System.Text.Json;
var BASE = "https://api.propsocket.io/v1";
var key = Environment.GetEnvironmentVariable("PROPSOCKET_API_KEY");
var propertyId = Environment.GetEnvironmentVariable("PROPERTY_ID"); // the one property you're exporting
string[] fields = { "id", "x_id", "unit_number", "building", "status", "type", "bedrooms", "bathrooms" };
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", key);
static string Esc(string v) =>
v.IndexOfAny(new[] { ',', '"', '\n' }) >= 0 ? '"' + v.Replace("\"", "\"\"") + '"' : v;
await using var w = new StreamWriter("units.csv");
await w.WriteLineAsync(string.Join(",", fields));
int offset = 0, limit = 100; // 100 is the max page size
while (true)
{
var url = $"{BASE}/units?property_id={propertyId}&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());
var root = doc.RootElement;
foreach (var u in root.GetProperty("results").EnumerateArray())
{
var row = fields.Select(f =>
u.TryGetProperty(f, out var val) ? Esc(val.ToString()) : "");
await w.WriteLineAsync(string.Join(",", row));
}
if (!root.GetProperty("meta").GetProperty("hasMore").GetBoolean()) break;
offset += limit;
}Schedule it however you run jobs. A bare crontab entry:
crontab
# /etc/cron.d/propsocket-units-export — runs at 02:15 every night.
15 2 * * * deploy PROPSOCKET_API_KEY=ps_test_… PROPERTY_ID=prp_… /usr/bin/python3 /opt/jobs/export_units.py >> /var/log/units-export.log 2>&1Why this works
property_idis a supported filter on the Unit list endpoint, so the API does the narrowing — you never download units you'll discard. See the full filter set inFiltering & sorting.- Paging until
meta.hasMoreisfalsewithcreated_at:ascgives a deterministic walk: new units appended at the end never shift earlier pages. The pagination contract is in Pagination. - Soft-deleted units are excluded by default, so your nightly file naturally drops units that left the source. Pass
?include_deleted=trueonly if you specifically want a tombstone of what was removed.
What to watch out for
- Rate limits are per-Organization. A single property's units fit in a handful of pages, but if you schedule many exports at the same minute they share the 150 req/min pool. Stagger the cron entries or honor
Retry-Afteron a 429 — seeRate limits. - Cache freshness, not real time. You're reading PropSocket's normalized cache, which refreshes on your tier's sync cadence. A 2 AM export reflects the most recent sync, not the live PMS. If you need change-driven freshness, pair this with the
UNIT_UPDATEDwebhook instead — webhooks are on theScale plan and above. - Quote your CSV fields. Unit numbers and building names can contain commas. The Python
csvmodule handles this; the Node sample includes a minimal escaper. Ruby'sCSV, PHP'sfputcsv, Go'sencoding/csv, and .NET handle quoting too. - Keep secrets out of the crontab if you can. The inline env vars above are for illustration — prefer an
EnvironmentFileor a secrets manager in production. SeeLocal development for the.envpattern.
Next
To dump every entity (not just one property's units), useBackfill after downtime, which generalizes the same paginate-and-write loop across all four entities.