Logo
Apdf tutorials October 2026 5 min read

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.

What you'll build
A receiver that survived the failure drill: delivery missed while down → found in the execution log → replayed → duplicate ignored
Automations are a Pro feature (14-day trial, no card)
1

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/executions lists 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.
2

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.

3

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
Tip: Put the reconcile loop on a cron (every 15 minutes is plenty) and the system is self-healing: fast acks keep deliveries succeeding, the execution log catches the ones that don't, replay recovers them, and dedupe makes recovery safe. That combination replaces a retry queue.

Where to go from here

What to build behind the now-reliable receiver.

Ready to see who reads?