webhookgate-sdk 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # WebhookGate
2
+
3
+ WebhookGate guarantees **no duplicate webhook side effects**.
4
+
5
+ https://webhookgate.com
6
+
7
+ It does this by combining:
8
+ - **durable webhook intake and de-duplication** at the gateway layer, and
9
+ - a **consumer-side idempotency SDK** that makes duplicate side effects **structurally impossible** within the consumer.
10
+
11
+ WebhookGate sits in front of webhook consumers and ensures that each `(provider, eventId)` is **accepted exactly once**, even under retries, replays, or noisy providers - and that downstream effects are never duplicated when the consumer uses the SDK.
12
+
13
+ ---
14
+
15
+ ## What WebhookGate guarantees (MVP)
16
+
17
+ WebhookGate provides a durable webhook “inbox” in front of your consumer.
18
+
19
+ ### Gateway guarantees
20
+
21
+ - **Exactly-once acceptance** per `(provider, eventId)`
22
+ - **De-duplication at intake**: repeated deliveries of the same event are not re-accepted
23
+ - **Durable delivery jobs + transport retries**: downstream delivery is retried on network/transport failure
24
+ - **Deterministic Idempotency-Key propagation** on every downstream delivery
25
+
26
+ The gateway delivers events **at-least-once** downstream, always with the same
27
+ Idempotency-Key, even across retries, crashes, or restarts.
28
+
29
+ ---
30
+
31
+ ## Consumer SDK: No duplicate side effects
32
+
33
+ The consumer SDK converts delivery guarantees into **hard correctness**.
34
+
35
+ ### What the SDK guarantees
36
+
37
+ - **At-most-once execution** of the handler per Idempotency-Key
38
+ - **Duplicate deliveries never re-run side effects**
39
+ - **Crash-safe behavior**: if the process crashes mid-handler, the handler is never re-entered (row remains processing)
40
+ - **Error behavior (terminal)**: if the handler throws, the idempotency row transitions to 'failed' and is never re-entered automatically
41
+ - **Transactional DB effects** for database operations performed via the provided db client
42
+
43
+ Once a key is successfully claimed, the handler will **never** be executed again — even if the process crashes, restarts, or receives the same event repeatedly.
44
+
45
+ > Result: side effects are never duplicated.
46
+
47
+ ---
48
+
49
+ ## Exactly-once effects (clarified)
50
+
51
+ WebhookGate guarantees that your handler runs at most once per Idempotency-Key.
52
+
53
+ If your handler calls external systems (payments, email providers, APIs), those systems must respect idempotency keys to achieve end-to-end exactly-once effects across system boundaries.
54
+
55
+ This design deliberately favors **safety over re-execution**:
56
+ WebhookGate will never risk double-charging, double-emailing, or double-writing.
57
+
58
+ ---
59
+
60
+ ## Install
61
+
62
+ ```bash
63
+ npm install webhookgate-sdk
64
+ ```
65
+
66
+ The SDK automatically creates the required idempotency tables on first use.
67
+
68
+ ---
69
+
70
+ ## What the developer writes (locked API)
71
+
72
+ ```js
73
+ import { idempotent } from "webhookgate-sdk";
74
+
75
+ app.post(
76
+ "/webhooks/stripe",
77
+ idempotent(async ({ event, db }) => {
78
+ await chargeCustomer(event); // never executed twice
79
+ await sendReceipt(event); // never executed twice
80
+ })
81
+ );
82
+ ```
83
+
84
+ ---
85
+
86
+ ## Proof: No duplicate side effects (60 seconds)
87
+
88
+ 1. Start Postgres
89
+ 2. Install dependencies
90
+ npm install
91
+ 3. Start the consumer
92
+ npm run consumer
93
+ 4. Start the gateway
94
+ npm run dev
95
+ 5. Send a replay storm
96
+ npm run chaos -- evt_test_1
97
+
98
+ Result:
99
+ - Gateway receives 50 duplicate events
100
+ - Consumer executes the handler once
101
+ - /stats shows charges = 1
102
+
103
+ Crash test:
104
+ - Start consumer with CRASH_ONCE=true
105
+ - Re-run chaos
106
+ - Restart consumer
107
+ - Charges still = 1
108
+
109
+ ---
110
+
111
+ ## When you need production-grade durability
112
+
113
+ WebhookGate adds durable intake, replay protection, and operational safeguards for environments where local correctness is no longer sufficient.
114
+
115
+ Learn more at https://webhookgate.com
package/idempotent.js ADDED
@@ -0,0 +1,142 @@
1
+ import { Pool } from "pg";
2
+
3
+ const pool = new Pool({
4
+ host: process.env.PGHOST,
5
+ port: process.env.PGPORT ? Number(process.env.PGPORT) : undefined,
6
+ database: process.env.PGDATABASE,
7
+ user: process.env.PGUSER,
8
+ password: process.env.PGPASSWORD,
9
+ ssl: process.env.PGSSL === "true" ? { rejectUnauthorized: false } : undefined,
10
+ });
11
+
12
+ let _readyPromise = null;
13
+
14
+ async function ensureTables() {
15
+ if (_readyPromise) return _readyPromise;
16
+
17
+ _readyPromise = (async () => {
18
+ await pool.query(`
19
+ CREATE TABLE IF NOT EXISTS webhook_idempotency (
20
+ idempotency_key TEXT PRIMARY KEY,
21
+ status TEXT NOT NULL CHECK (status IN ('processing','done','failed')),
22
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
23
+ completed_at TIMESTAMPTZ
24
+ );
25
+ `);
26
+
27
+ await pool.query(`
28
+ CREATE TABLE IF NOT EXISTS demo_charges (
29
+ id BIGSERIAL PRIMARY KEY,
30
+ idempotency_key TEXT NOT NULL UNIQUE,
31
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
32
+ );
33
+ `);
34
+ })();
35
+
36
+ return _readyPromise;
37
+ }
38
+
39
+ function getIdempotencyKey(req) {
40
+ // Primary: what our gateway sends
41
+ const k = req.get("Idempotency-Key") || req.get("idempotency-key");
42
+ return k ? String(k).trim() : "";
43
+ }
44
+
45
+ /**
46
+ * Locked API:
47
+ * app.post("/webhooks/stripe", idempotent(async ({ req, event, db }) => {}))
48
+ *
49
+ * Guarantees:
50
+ * - first time key seen: handler runs
51
+ * - any repeat of same key: handler does NOT run (returns 200)
52
+ * - crash or error mid-handler: key transitions to 'failed' and is never re-run
53
+ */
54
+ export function idempotent(handler) {
55
+ return async function idempotentMiddleware(req, res) {
56
+ await ensureTables();
57
+
58
+ const key = getIdempotencyKey(req);
59
+
60
+ if (!key) {
61
+ return res.status(400).json({
62
+ ok: false,
63
+ error: "Missing Idempotency-Key header",
64
+ });
65
+ }
66
+
67
+ // IMPORTANT: the SDK assumes req.body already contains the parsed event
68
+ const event = req.body;
69
+
70
+ const client = await pool.connect();
71
+ let inTxn = false;
72
+ try {
73
+ // Atomic "claim"
74
+ // If it inserts: we own execution.
75
+ // If it conflicts: someone already claimed (or finished) => skip.
76
+ const claimed = await client.query(
77
+ `
78
+ INSERT INTO webhook_idempotency (idempotency_key, status)
79
+ VALUES ($1, 'processing')
80
+ ON CONFLICT (idempotency_key) DO NOTHING
81
+ RETURNING idempotency_key
82
+ `,
83
+ [key]
84
+ );
85
+
86
+ if (claimed.rowCount === 0) {
87
+ // Already seen => do nothing (exactly-once effects)
88
+ return res.status(200).json({ ok: true, deduped: true });
89
+ }
90
+
91
+ // 2) Transaction boundary for DB effects inside handler
92
+ await client.query("BEGIN");
93
+ inTxn = true;
94
+
95
+ // Run user handler
96
+ await handler({ req, res, event, db: client });
97
+
98
+ // Mark done
99
+ await client.query(
100
+ `
101
+ UPDATE webhook_idempotency
102
+ SET status='done', completed_at=now()
103
+ WHERE idempotency_key=$1
104
+ `,
105
+ [key]
106
+ );
107
+
108
+ await client.query("COMMIT");
109
+ inTxn = false;
110
+
111
+ // If handler already wrote to res, don't double-send.
112
+ if (!res.headersSent) {
113
+ return res.status(200).json({ ok: true, deduped: false });
114
+ }
115
+ } catch (err) {
116
+ // best-effort rollback if handler started a txn
117
+ try {
118
+ if (inTxn) await client.query("ROLLBACK");
119
+ } catch (_) {}
120
+
121
+ // Optional: mark failed (still prevents reruns, which is the contract you chose)
122
+ try {
123
+ await client.query(
124
+ `
125
+ UPDATE webhook_idempotency
126
+ SET status='failed', completed_at=now()
127
+ WHERE idempotency_key=$1 AND status='processing'
128
+ `,
129
+ [key]
130
+ );
131
+ } catch (_) {
132
+ // ignore secondary failure
133
+ }
134
+
135
+ if (!res.headersSent) {
136
+ return res.status(500).json({ ok: false, error: err?.message || String(err) });
137
+ }
138
+ } finally {
139
+ client.release();
140
+ }
141
+ };
142
+ }
package/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // packages/sdk/index.js
2
- export { idempotent } from "../../src/idempotent.js";
2
+ export { idempotent } from "./idempotent.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhookgate-sdk",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "exports": {