Recipe
Dedupe webhooks idempotently
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).
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)import crypto from 'node:crypto';
import express from 'express';
import { createClient } from 'redis';
const app = express();
const SIGNING_SECRET = process.env.PROPSOCKET_SIGNING_SECRET;
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
// TTL must outlast the retry window (~7h36m). 7 days is comfortable.
const SEEN_TTL = 7 * 24 * 3600;
app.use('/webhooks/propsocket', express.raw({ type: 'application/json' }));
function verify(rawBody, signature, secret) {
if (!signature) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
async function claim(eventId) {
// SET NX returns null when the key already exists (a duplicate).
const set = await redis.set(`webhook:seen:${eventId}`, '1', { NX: true, EX: SEEN_TTL });
return set !== null;
}
app.post('/webhooks/propsocket', async (req, res) => {
if (!verify(req.body, req.header('X-PropSocket-Signature'), SIGNING_SECRET)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
if (!(await claim(event.id))) {
return res.status(200).send('ok'); // already handled this evt_; ack and drop
}
await process(event);
res.status(200).send('ok');
});package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"time"
"github.com/redis/go-redis/v9"
)
var (
secret = []byte(os.Getenv("PROPSOCKET_SIGNING_SECRET"))
rdb = redis.NewClient(&redis.Options{Addr: os.Getenv("REDIS_ADDR")})
// TTL must outlast the retry window (~7h36m). 7 days is comfortable.
seenTTL = 7 * 24 * time.Hour
)
func verify(rawBody []byte, sig string, secret []byte) bool {
if sig == "" {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write(rawBody)
received, err := hex.DecodeString(sig)
return err == nil && hmac.Equal(mac.Sum(nil), received)
}
// claim returns true the first time we see this id, false on a duplicate.
func claim(ctx context.Context, eventID string) bool {
won, _ := rdb.SetNX(ctx, "webhook:seen:"+eventID, "1", seenTTL).Result()
return won
}
func receive(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body)
if !verify(rawBody, r.Header.Get("X-PropSocket-Signature"), secret) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var event struct {
ID string `json:"id"`
}
json.Unmarshal(rawBody, &event)
if !claim(r.Context(), event.ID) {
// Already handled this evt_ — ack so PropSocket stops retrying.
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
return
}
process(rawBody)
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}require "openssl"
require "json"
require "redis"
require "sinatra"
SIGNING_SECRET = ENV.fetch("PROPSOCKET_SIGNING_SECRET")
REDIS = Redis.new(url: ENV.fetch("REDIS_URL"))
# TTL must outlast the retry window (~7h36m). 7 days is comfortable.
SEEN_TTL = 7 * 24 * 3600
def verify(raw_body, signature, secret)
return false if signature.nil? || signature.empty?
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
Rack::Utils.secure_compare(expected, signature)
end
# SET NX succeeds only the first time we see this id.
# Returns true if we won the claim (first delivery), false on a duplicate.
def claim(event_id)
!!REDIS.set("webhook:seen:#{event_id}", "1", nx: true, ex: SEEN_TTL)
end
post "/webhooks/propsocket" do
request.body.rewind
raw_body = request.body.read
sig = request.env["HTTP_X_PROPSOCKET_SIGNATURE"].to_s
halt 401, "invalid signature" unless verify(raw_body, sig, SIGNING_SECRET)
event = JSON.parse(raw_body)
unless claim(event["id"])
# Already handled this evt_ — ack so PropSocket stops retrying.
return [200, "ok"]
end
process(event)
status 200
"ok"
end<?php
$signingSecret = getenv("PROPSOCKET_SIGNING_SECRET");
$redis = new Redis();
$redis->connect("127.0.0.1", 6379);
// TTL must outlast the retry window (~7h36m). 7 days is comfortable.
const SEEN_TTL = 7 * 24 * 3600;
function verify(string $rawBody, ?string $sig, string $secret): bool
{
if (empty($sig)) {
return false;
}
return hash_equals(hash_hmac("sha256", $rawBody, $secret), $sig);
}
// SET NX succeeds only the first time we see this id.
// Returns true if we won the claim (first delivery), false on a duplicate.
function claim(Redis $redis, string $eventId): bool
{
return (bool) $redis->set("webhook:seen:$eventId", "1", ["nx", "ex" => SEEN_TTL]);
}
$rawBody = file_get_contents("php://input");
$sig = $_SERVER["HTTP_X_PROPSOCKET_SIGNATURE"] ?? "";
if (!verify($rawBody, $sig, $signingSecret)) {
http_response_code(401);
exit("invalid signature");
}
$event = json_decode($rawBody, true);
if (!claim($redis, $event["id"])) {
// Already handled this evt_ — ack so PropSocket stops retrying.
http_response_code(200);
exit("ok");
}
process($event);
http_response_code(200);
echo "ok";import static spark.Spark.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.HexFormat;
import com.fasterxml.jackson.databind.*; // Jackson: the one JSON dependency
import redis.clients.jedis.JedisPooled;
import redis.clients.jedis.params.SetParams;
public class Dedupe {
static final byte[] SECRET = System.getenv("PROPSOCKET_SIGNING_SECRET").getBytes();
static final ObjectMapper MAPPER = new ObjectMapper();
static final JedisPooled REDIS = new JedisPooled(System.getenv("REDIS_URL"));
// TTL must outlast the retry window (~7h36m). 7 days is comfortable.
static final int SEEN_TTL = 7 * 24 * 3600;
static boolean verify(byte[] rawBody, String sig, byte[] secret) throws Exception {
if (sig == null || sig.isEmpty()) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
String expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
return MessageDigest.isEqual(expected.getBytes(), sig.getBytes());
}
// SET NX returns null on a duplicate; non-null means we won the claim.
static boolean claim(String eventId) {
return REDIS.set("webhook:seen:" + eventId, "1",
new SetParams().nx().ex(SEEN_TTL)) != null;
}
public static void main(String[] args) {
post("/webhooks/propsocket", (req, res) -> {
byte[] rawBody = req.bodyAsBytes();
if (!verify(rawBody, req.headers("X-PropSocket-Signature"), SECRET)) {
res.status(401);
return "invalid signature";
}
JsonNode event = MAPPER.readTree(rawBody);
if (!claim(event.get("id").asText())) {
res.status(200); // already handled this evt_; ack and drop
return "ok";
}
process(event);
res.status(200);
return "ok";
});
}
}using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secret = Encoding.UTF8.GetBytes(
Environment.GetEnvironmentVariable("PROPSOCKET_SIGNING_SECRET")!);
var redis = await ConnectionMultiplexer.ConnectAsync(
Environment.GetEnvironmentVariable("REDIS_URL")!);
var db = redis.GetDatabase();
// TTL must outlast the retry window (~7h36m). 7 days is comfortable.
var seenTtl = TimeSpan.FromDays(7);
static bool Verify(byte[] rawBody, string? sig, byte[] secret)
{
if (string.IsNullOrEmpty(sig)) 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(sig));
}
app.MapPost("/webhooks/propsocket", async (HttpRequest req) =>
{
using var ms = new MemoryStream();
await req.Body.CopyToAsync(ms);
var rawBody = ms.ToArray();
if (!Verify(rawBody, req.Headers["X-PropSocket-Signature"], secret))
return Results.Text("invalid signature", statusCode: 401);
using var doc = JsonDocument.Parse(rawBody);
var eventId = doc.RootElement.GetProperty("id").GetString()!;
// StringSet with When.NotExists returns false on a duplicate.
bool won = await db.StringSetAsync(
$"webhook:seen:{eventId}", "1", seenTtl, When.NotExists);
if (!won)
return Results.Text("ok"); // already handled this evt_; ack and drop
await Process(rawBody);
return Results.Text("ok");
});
app.Run();Why this works
- The event
idis stable across retries and replays. A retried or replayed delivery carries the sameevt_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 many
PROPERTY_UPDATEDevents, each a distinctevt_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. Ifprocess()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.