Recipe
Reconcile after a webhook outage
Problem
Your webhook receiver was down — a bad deploy, an expired cert, a cloud incident. Events kept flowing. Some retried into your dead-letter queue; some aged out. You're back online and need to guarantee your datastore reflects everything that changed while you were dark, without re-importing the entire dataset.
Option A — Replay from the dashboard (preferred for short outages)
Every delivery is logged in Dashboard → Webhooks → Recent deliveries for 30 days, and failed deliveries sit in the Dead-letter queue with full payloads. If your outage was inside that window, replay the affected deliveries — individually or in bulk. Replays carry the original signature and the original event id, so yourdedupe layer makes overlapping replays harmless.
Option B — Sweep what changed (for long or uncertain outages)
When the outage is longer than you can comfortably replay, or you're not sure exactly what you missed, pull the diff directly from the API: list every entity whose updated_at is at or after your last-good watermark, and apply each record. Include soft-deletes so you also catch records that were removed while you were down.
import os
import requests
BASE = "https://api.propsocket.io/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['PROPSOCKET_API_KEY']}"}
ENTITIES = ["properties", "units", "residents", "leases"]
# The instant your receiver went down. Everything updated at/after this needs
# re-checking. Persist your real "last successfully processed" watermark.
SINCE = "2026-05-20T00:00:00Z"
def changed_since(entity, since):
offset, limit = 0, 100
while True:
resp = requests.get(
f"{BASE}/{entity}",
headers=HEADERS,
params={
"updated_after": since, # everything changed since your watermark
"include_deleted": "true", # catch records soft-deleted while down
"limit": limit,
"offset": offset,
"order-by": "updated_at:asc",
},
)
resp.raise_for_status()
body = resp.json()
yield from body["results"]
if not body["meta"]["hasMore"]:
return
offset += limit
def reconcile():
for entity in ENTITIES:
for record in changed_since(entity, SINCE):
if record.get("deleted_at"):
apply_soft_delete(entity, record)
else:
upsert(entity, record) # idempotent — safe to overlap with replays
if __name__ == "__main__":
reconcile()const BASE = 'https://api.propsocket.io/v1';
const HEADERS = { Authorization: `Bearer ${process.env.PROPSOCKET_API_KEY}` };
const ENTITIES = ['properties', 'units', 'residents', 'leases'];
// The instant your receiver went down. Persist your real watermark.
const SINCE = '2026-05-20T00:00:00Z';
async function* changedSince(entity, since) {
let offset = 0;
const limit = 100;
for (;;) {
const url = new URL(`${BASE}/${entity}`);
url.searchParams.set('updated_after', since); // everything changed since your watermark
url.searchParams.set('include_deleted', 'true'); // catch records soft-deleted while down
url.searchParams.set('limit', String(limit));
url.searchParams.set('offset', String(offset));
url.searchParams.set('order-by', 'updated_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;
}
}
for (const entity of ENTITIES) {
for await (const record of changedSince(entity, SINCE)) {
if (record.deleted_at) applySoftDelete(entity, record);
else await upsert(entity, record); // idempotent — safe to overlap with replays
}
}package main
import (
"encoding/json"
"net/http"
"net/url"
"os"
"strconv"
)
const base = "https://api.propsocket.io/v1"
var entities = []string{"properties", "units", "residents", "leases"}
// The instant your receiver went down. Persist your real watermark.
const since = "2026-05-20T00:00:00Z"
func changedSince(entity, since string, yield func(map[string]any)) {
offset, limit := 0, 100
key := os.Getenv("PROPSOCKET_API_KEY")
for {
q := url.Values{}
q.Set("updated_after", since) // everything changed since your watermark
q.Set("include_deleted", "true") // catch records soft-deleted while down
q.Set("limit", strconv.Itoa(limit))
q.Set("offset", strconv.Itoa(offset))
q.Set("order-by", "updated_at:asc")
req, _ := http.NewRequest("GET", base+"/"+entity+"?"+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 _, r := range body.Results {
yield(r)
}
if !body.Meta.HasMore {
return
}
offset += limit
}
}
func main() {
for _, entity := range entities {
changedSince(entity, since, func(record map[string]any) {
if record["deleted_at"] != nil {
applySoftDelete(entity, record)
} else {
upsert(entity, record) // idempotent — safe to overlap with replays
}
})
}
}require "net/http"
require "json"
require "uri"
BASE = "https://api.propsocket.io/v1"
KEY = ENV.fetch("PROPSOCKET_API_KEY")
ENTITIES = %w[properties units residents leases]
# The instant your receiver went down. Persist your real watermark.
SINCE = "2026-05-20T00:00:00Z"
def changed_since(entity, since)
return enum_for(:changed_since, entity, since) unless block_given?
offset, limit = 0, 100
loop do
uri = URI("#{BASE}/#{entity}")
uri.query = URI.encode_www_form(
updated_after: since, # everything changed since your watermark
include_deleted: "true", # catch records soft-deleted while down
limit: limit, offset: offset, "order-by": "updated_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
ENTITIES.each do |entity|
changed_since(entity, SINCE).each do |record|
if record["deleted_at"]
apply_soft_delete(entity, record)
else
upsert(entity, record) # idempotent — safe to overlap with replays
end
end
end<?php
$base = "https://api.propsocket.io/v1";
$key = getenv("PROPSOCKET_API_KEY");
$entities = ["properties", "units", "residents", "leases"];
// The instant your receiver went down. Persist your real watermark.
$since = "2026-05-20T00:00:00Z";
function changed_since(string $base, string $key, string $entity, string $since): Generator
{
$offset = 0;
$limit = 100;
while (true) {
$q = http_build_query([
"updated_after" => $since, // everything changed since your watermark
"include_deleted" => "true", // catch records soft-deleted while down
"limit" => $limit,
"offset" => $offset,
"order-by" => "updated_at:asc",
]);
$ch = curl_init("$base/$entity?$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;
}
}
foreach ($entities as $entity) {
foreach (changed_since($base, $key, $entity, $since) as $record) {
if (!empty($record["deleted_at"])) {
apply_soft_delete($entity, $record);
} else {
upsert($entity, $record); // idempotent — safe to overlap with replays
}
}
}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 entities = new String[] {"properties", "units", "residents", "leases"};
var client = HttpClient.newHttpClient();
var mapper = new ObjectMapper();
// The instant your receiver went down. Persist your real watermark.
var since = "2026-05-20T00:00:00Z";
for (String entity : entities) {
int offset = 0, limit = 100;
boolean more = true;
while (more) {
var uri = URI.create(base + "/" + entity
+ "?updated_after=" + since
+ "&include_deleted=true"
+ "&limit=" + limit + "&offset=" + offset
+ "&order-by=updated_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 record : body.get("results")) {
if (!record.path("deleted_at").isNull() && record.has("deleted_at")) {
applySoftDelete(entity, record);
} else {
upsert(entity, record); // idempotent — safe to overlap with replays
}
}
more = body.get("meta").get("hasMore").asBoolean();
offset += limit;
}
}using System.Text.Json;
var BASE = "https://api.propsocket.io/v1";
var key = Environment.GetEnvironmentVariable("PROPSOCKET_API_KEY");
string[] entities = { "properties", "units", "residents", "leases" };
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", key);
// The instant your receiver went down. Persist your real watermark.
var since = "2026-05-20T00:00:00Z";
foreach (var entity in entities)
{
int offset = 0, limit = 100;
bool more = true;
while (more)
{
var url = $"{BASE}/{entity}?updated_after={since}&include_deleted=true"
+ $"&limit={limit}&offset={offset}&order-by=updated_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 record in root.GetProperty("results").EnumerateArray())
{
if (record.TryGetProperty("deleted_at", out var del)
&& del.ValueKind != JsonValueKind.Null)
{
ApplySoftDelete(entity, record);
}
else
{
Upsert(entity, record); // idempotent — safe to overlap with replays
}
}
more = root.GetProperty("meta").GetProperty("hasMore").GetBoolean();
offset += limit;
}
}Why this works
- A watermark, not a guess. Persisting your last successfully-processed
updated_at(or the timestamp your receiver went down) lets the sweep ask for exactly the window you missed, instead of re-importing everything. include_deleted=truerecovers removals. A record soft-deleted while you were offline won't appear in a default list — it's filtered out. Including deletes and checkingdeleted_atis the only way to learn it left.- Sorting by
updated_at:ascmakes the sweep resumable. If it dies partway, restart from the lastupdated_atyou applied. - Idempotent apply. Because both options can deliver the same change twice (replay overlaps with a sweep, or vice-versa), every write must be safe to repeat — the whole reason dedupe comes first.
What to watch out for
- The 30-day delivery-log window caps Option A. Deliveries older than 30 days aren't replayable. For anything beyond that, the sweep (Option B) or a fullbackfill is the fallback.
- Mind the rate limit. A wide sweep is request-heavy and shares the 150 req/min per-Organization pool. Honor
Retry-Afteron 429s — seeRate limits. - Use UTC ISO 8601 for the watermark.
updated_atis UTC; pass yourSINCEas UTC ISO 8601 to avoid an off-by-one-timezone gap.
Next
Reconciliation depends on idempotent processing. For a cold start rather than a recovery, seeBackfill after downtime.