How to Handle Document Webhooks Reliably
Webhook demos end at “200 OK.” Production starts there: your receiver will be down during a deploy exactly when a hot prospect opens the proposal, and the question isn't whether you'll miss a delivery — it's whether you'll know, and whether replaying it is safe.
This build answers both with one Java file and the execution log: acknowledge fast, dedupe durably, reconcile on a schedule.
Know the delivery contract
Three facts about how Apdf delivers automation webhooks, each one a design constraint for your receiver:
- One attempt, 10-second timeout. No automatic redelivery — so answer fast and never block the response on your own processing.
- Every attempt is recorded.
GET /api/automations/executionslists each delivery with its status, response, duration, error and the full payload that was sent — which makes missed deliveries recoverable. - Replays will happen. Your own reconciliation (and any manual retry) means the same payload can arrive twice; the receiver must be idempotent or your CRM gets double entries.
The receiver — plain JDK, no framework
Single file, runs with java Receiver.java. The
three duties from step 1, made structural:
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Production-shaped webhook receiver:
* - acknowledge fast (the sender times out after 10s and does not retry)
* - process on a worker thread, never on the request thread
* - dedupe on (session_id, event) so replays are harmless
*/
public class Receiver {
private static final Set<String> seen = ConcurrentHashMap.newKeySet();
private static final ExecutorService worker = Executors.newFixedThreadPool(4);
private static final Path log = Path.of("events.jsonl");
private static final Pattern SESSION = Pattern.compile("\"session_id\"\\s*:\\s*\"([^\"]+)\"");
private static final Pattern EVENT = Pattern.compile("\"matched_event\"\\s*:\\s*\"([^\"]+)\"");
public static void main(String[] args) throws IOException {
rebuildSeenFromLog(); // dedupe state must survive restarts
HttpServer server = HttpServer.create(new InetSocketAddress(8095), 0);
server.createContext("/webhooks/apdf", exchange -> {
byte[] body = exchange.getRequestBody().readAllBytes();
// 1. ack immediately — heavy work must not ride the request thread
byte[] ok = "ok".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, ok.length);
try (OutputStream os = exchange.getResponseBody()) { os.write(ok); }
// 2. process async, idempotently
worker.submit(() -> process(new String(body, StandardCharsets.UTF_8)));
});
server.start();
System.out.println("listening on :8095");
}
private static void rebuildSeenFromLog() throws IOException {
if (!Files.exists(log)) return;
for (String line : Files.readAllLines(log)) {
Matcher s = SESSION.matcher(line);
Matcher e = EVENT.matcher(line);
if (s.find() && e.find()) seen.add(s.group(1) + ":" + e.group(1));
}
System.out.println("rebuilt dedupe state: " + seen.size() + " keys");
}
private static void process(String payload) {
Matcher s = SESSION.matcher(payload);
Matcher e = EVENT.matcher(payload);
if (!s.find() || !e.find()) return;
String key = s.group(1) + ":" + e.group(1);
if (!seen.add(key)) {
System.out.println("duplicate ignored: " + key);
return; // already handled — replay-safe
}
try {
Files.writeString(log, payload + System.lineSeparator(),
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
System.out.println("stored: " + key);
} catch (IOException ex) {
seen.remove(key); // let a replay try again
ex.printStackTrace();
}
}
}
The rebuildSeenFromLog() line earned its place
the hard way: our first version kept the dedupe set only in memory, restarted
cleanly — and stored the same event twice on the next replay. Idempotency
that doesn't survive a restart isn't idempotency.
Run the failure drill
We stopped the receiver and let a reader open a document. The delivery failed — and the execution log caught everything, payload included:
GET /api/automations/executions
{
"status": "failed",
"error": "cURL error 7: Failed to connect to 127.0.0.1 port 8095 after 0 ms: Could not connect to server (see https://curl.se/libcurl/c/libcurl-errors.html) for http://127.0.0.1:8095/webhooks/apdf",
"duration_ms": 5,
"created_at": "2026-07-23T11:00:46.000000Z",
"payload": {
"automation": { "id": "b8ff0-a0ee4-a308b", "name": "CRM sync" },
"trigger": { "matched_event": "document:loaded", "conditions_evaluated": false },
"event": {
"session_id": "javaDownE0000E2q",
"recipient": { "name": "Elena Novak", "email": "elena@novak.example" }
}
}
}
(Payload shown with its nested objects abbreviated to the identifying fields.) Because the log carries the payload, reconciliation is a loop, not an apology:
# replay every failed delivery straight from the execution log
curl -s "https://apdf.io/api/automations/executions?per_page=100" \
-H "Authorization: Bearer $API_TOKEN" -H "Accept: application/json" |
jq -c '.data[] | select(.status == "failed") | .payload' |
while read -r P; do
curl -s -o /dev/null -w "replayed: %{http_code}\n" \
-X POST https://your-app.example/webhooks/apdf \
-H "Content-Type: application/json" -d "$P"
done
replayed: 200
stored: javaDownE0000E2q:document:loaded ← the missed delivery, recovered
# and replaying an already-delivered payload:
duplicate ignored: javaRecv00000D1z:page:read ← idempotency holding
Where to go from here
What to build behind the now-reliable receiver.
Related tutorials
Stream PDF Reader Events into Your Own Database
Your warehouse answers questions no analytics UI anticipates. One automation fires on every engagement moment; a small Go receiver lands each webhook in Postgres — attributed, session-enriched, and idempotent under redelivery — so reading behavior becomes a column you can join against your CRM.
Get Notified Only for PDF Engagement That Matters
The ping-on-every-open automation gets muted within a week. The Only-if box fixes it: stack conditions like total reading time and completion rate, and a 15-second skim stays silent while the cover-to-cover read sends exactly one notification — proven live with two readers.
How to Create HubSpot Tasks from PDF Engagement
Your CRM should know when a prospect actually reads the quote. A Page Read automation with a completion-rate condition fires only on real reads, a Zap matches the reader to their HubSpot contact by email, and a follow-up task — with the session numbers in its notes — files itself. No code.