Recipe

Dedupe webhooks idempotently

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.

Problem

PropSocket delivers webhooks at least once. The same event will occasionally arrive twice — after a retry triggered by a slow response, or after you replay a delivery from the dashboard. If your handler isn't idempotent, that means a double-charge, a duplicate row, or a second notification. You want exactly-once processing on top of at-least-once delivery.

Code

Every event carries a unique id — a ULID, prefixed evt_. Atomically claim that id the first time you see it; if the claim fails, you've already processed the event, so acknowledge and drop. The example uses Redis SET NX, but any store with an atomic insert-if-absent works (a unique constraint on a processed_events table is just as good).

Dedupe on event id
import hashlib
import hmac
import json
import os

import redis
from flask import Flask, request, abort

app = Flask(__name__)
SIGNING_SECRET = os.environ["PROPSOCKET_SIGNING_SECRET"].encode("utf-8")
r = redis.from_url(os.environ["REDIS_URL"])

# TTL must outlast the retry window (~7h36m). 7 days is comfortable.
SEEN_TTL = 7 * 24 * 3600


def verify(raw_body: bytes, signature: str, secret: bytes) -> bool:
    if not signature:
        return False
    expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


def claim(event_id: str) -> bool:
    # SET NX succeeds only the first time we see this id.
    # Returns True if we won the claim (first delivery), False on a duplicate.
    return bool(r.set(f"webhook:seen:{event_id}", "1", nx=True, ex=SEEN_TTL))


@app.post("/webhooks/propsocket")
def receive():
    raw_body = request.get_data()
    if not verify(raw_body, request.headers.get("X-PropSocket-Signature", ""), SIGNING_SECRET):
        abort(401)

    event = json.loads(raw_body)
    if not claim(event["id"]):
        # Already handled this evt_ — ack so PropSocket stops retrying.
        return ("ok", 200)

    process(event)
    return ("ok", 200)

Why this works

  • The event id is stable across retries and replays. A retried or replayed delivery carries the same evt_ id (and the same signature) as the original. Keying on it means "have I seen this exact event?" — not "have I seen this kind of event?"
  • The claim is atomic. SET NX (or a unique-constraint insert) makes "check and record" a single race-free step, so two concurrent deliveries of the same id can't both pass the check.
  • Don't dedupe on the record id. A Property can legitimately emit manyPROPERTY_UPDATED events, each a distinct evt_ with the samedata.id. Deduping on the record id would silently swallow real updates. Dedupe on the event id.

What to watch out for

  • Claim only after the work is durable, or accept at-least-once within your system.The example claims before processing, which is correct when process() is itself idempotent. If process() isn't, claim after a successful, committed write — otherwise a crash between claim and commit drops the event for good.
  • Pick a TTL longer than the retry window. Retries span up to ~7h36m. A 7-day TTL leaves comfortable margin while keeping the dedupe store from growing without bound. Too short a TTL re-opens the duplicate window.
  • Verify the signature first. Dedupe is not a substitute for authentication. An unsigned or wrongly-signed request should be rejected with 401 before it ever reaches the claim step — see Webhooks → Verify the signature.
  • Always acknowledge duplicates with a 2xx. Returning an error on a duplicate makes PropSocket retry it, which produces more duplicates. Ack and drop.

Next

Idempotency is the prerequisite for safe recovery — seeReconcile after an outage, which leans on this pattern so bulk replays don't double-process.