hookarmor 1.0.0
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 +173 -0
- package/bin/hookarmor.js +132 -0
- package/index.js +11 -0
- package/package.json +52 -0
- package/src/alerter.js +63 -0
- package/src/dispatcher.js +337 -0
- package/src/public/blog/index.html +167 -0
- package/src/public/blog/sqs-webhook-deduplication-trap.html +256 -0
- package/src/public/blog/stripe-webhook-signature-verification-failed.html +284 -0
- package/src/public/index.html +1158 -0
- package/src/public/landing.html +549 -0
- package/src/public/og-preview.png +0 -0
- package/src/public/robots.txt +5 -0
- package/src/public/sitemap.xml +27 -0
- package/src/server.js +365 -0
- package/src/storage.js +266 -0
- package/src/worker.js +62 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const https = require('https');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const EventEmitter = require('events');
|
|
5
|
+
const Alerter = require('./alerter');
|
|
6
|
+
|
|
7
|
+
class Dispatcher extends EventEmitter {
|
|
8
|
+
constructor(storage, options = {}) {
|
|
9
|
+
super();
|
|
10
|
+
this.storage = storage;
|
|
11
|
+
this.defaultConcurrency = options.defaultConcurrency || 5;
|
|
12
|
+
this.inFlightCount = new Map();
|
|
13
|
+
this.waitQueues = new Map();
|
|
14
|
+
this.defaultTimeoutMs = options.defaultTimeoutMs || 25000;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Verify signature at ingress (preventing HookArmor from acting as an open signature oracle)
|
|
18
|
+
static verifyIngressSignature(provider, rawBody, headers, secret, toleranceSec = 300) {
|
|
19
|
+
if (!secret) return { valid: true };
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
if (provider === 'stripe') {
|
|
23
|
+
const sigHeader = headers['stripe-signature'];
|
|
24
|
+
if (!sigHeader) return { valid: false, reason: 'Missing Stripe-Signature header' };
|
|
25
|
+
|
|
26
|
+
const parts = sigHeader.split(',').reduce((acc, item) => {
|
|
27
|
+
const [k, v] = item.split('=');
|
|
28
|
+
if (k && v) acc[k.trim()] = v.trim();
|
|
29
|
+
return acc;
|
|
30
|
+
}, {});
|
|
31
|
+
|
|
32
|
+
if (!parts.t || !parts.v1) return { valid: false, reason: 'Malformed Stripe-Signature header' };
|
|
33
|
+
|
|
34
|
+
const timestamp = parseInt(parts.t, 10);
|
|
35
|
+
const now = Math.floor(Date.now() / 1000);
|
|
36
|
+
if (Math.abs(now - timestamp) > toleranceSec) {
|
|
37
|
+
return { valid: false, reason: `Timestamp outside tolerance (${Math.abs(now - timestamp)}s > ${toleranceSec}s)` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const expectedSig = crypto
|
|
41
|
+
.createHmac('sha256', secret)
|
|
42
|
+
.update(`${parts.t}.${rawBody}`)
|
|
43
|
+
.digest('hex');
|
|
44
|
+
|
|
45
|
+
const expectedBuf = Buffer.from(expectedSig);
|
|
46
|
+
const actualBuf = Buffer.from(parts.v1);
|
|
47
|
+
if (expectedBuf.length !== actualBuf.length || !crypto.timingSafeEqual(expectedBuf, actualBuf)) {
|
|
48
|
+
return { valid: false, reason: 'Signature mismatch' };
|
|
49
|
+
}
|
|
50
|
+
return { valid: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (provider === 'shopify') {
|
|
54
|
+
const hmac = headers['x-shopify-hmac-sha256'];
|
|
55
|
+
if (!hmac) return { valid: false, reason: 'Missing X-Shopify-Hmac-Sha256 header' };
|
|
56
|
+
|
|
57
|
+
const expectedSig = crypto
|
|
58
|
+
.createHmac('sha256', secret)
|
|
59
|
+
.update(rawBody)
|
|
60
|
+
.digest('base64');
|
|
61
|
+
|
|
62
|
+
const expectedBuf = Buffer.from(expectedSig);
|
|
63
|
+
const actualBuf = Buffer.from(hmac);
|
|
64
|
+
if (expectedBuf.length !== actualBuf.length || !crypto.timingSafeEqual(expectedBuf, actualBuf)) {
|
|
65
|
+
return { valid: false, reason: 'Shopify HMAC mismatch' };
|
|
66
|
+
}
|
|
67
|
+
return { valid: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (provider === 'github') {
|
|
71
|
+
const sig = headers['x-hub-signature-256'];
|
|
72
|
+
if (!sig) return { valid: false, reason: 'Missing X-Hub-Signature-256 header' };
|
|
73
|
+
|
|
74
|
+
const expectedSig = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
|
|
75
|
+
const expectedBuf = Buffer.from(expectedSig);
|
|
76
|
+
const actualBuf = Buffer.from(sig);
|
|
77
|
+
if (expectedBuf.length !== actualBuf.length || !crypto.timingSafeEqual(expectedBuf, actualBuf)) {
|
|
78
|
+
return { valid: false, reason: 'GitHub signature mismatch' };
|
|
79
|
+
}
|
|
80
|
+
return { valid: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { valid: true };
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return { valid: false, reason: err.message };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Fresh re-signing on forward/replay to defeat the 5-minute Stripe expiration trap
|
|
90
|
+
signHeaders(provider, rawBody, headers, secret) {
|
|
91
|
+
if (!secret) return headers;
|
|
92
|
+
|
|
93
|
+
const modified = { ...headers };
|
|
94
|
+
try {
|
|
95
|
+
if (provider === 'stripe') {
|
|
96
|
+
const freshTimestamp = Math.floor(Date.now() / 1000);
|
|
97
|
+
const freshSignature = crypto
|
|
98
|
+
.createHmac('sha256', secret)
|
|
99
|
+
.update(`${freshTimestamp}.${rawBody}`)
|
|
100
|
+
.digest('hex');
|
|
101
|
+
modified['stripe-signature'] = `t=${freshTimestamp},v1=${freshSignature}`;
|
|
102
|
+
} else if (provider === 'shopify') {
|
|
103
|
+
const freshHmac = crypto
|
|
104
|
+
.createHmac('sha256', secret)
|
|
105
|
+
.update(rawBody)
|
|
106
|
+
.digest('base64');
|
|
107
|
+
modified['x-shopify-hmac-sha256'] = freshHmac;
|
|
108
|
+
} else if (provider === 'github') {
|
|
109
|
+
const freshSig = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
|
|
110
|
+
modified['x-hub-signature-256'] = freshSig;
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {}
|
|
113
|
+
|
|
114
|
+
return modified;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async acquireSlot(endpointId, limit) {
|
|
118
|
+
const current = this.inFlightCount.get(endpointId) || 0;
|
|
119
|
+
if (current < limit) {
|
|
120
|
+
this.inFlightCount.set(endpointId, current + 1);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return new Promise(resolve => {
|
|
125
|
+
if (!this.waitQueues.has(endpointId)) this.waitQueues.set(endpointId, []);
|
|
126
|
+
this.waitQueues.get(endpointId).push(resolve);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
releaseSlot(endpointId, limit) {
|
|
131
|
+
const queue = this.waitQueues.get(endpointId);
|
|
132
|
+
if (queue && queue.length > 0) {
|
|
133
|
+
const next = queue.shift();
|
|
134
|
+
next();
|
|
135
|
+
} else {
|
|
136
|
+
const current = this.inFlightCount.get(endpointId) || 1;
|
|
137
|
+
this.inFlightCount.set(endpointId, Math.max(0, current - 1));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Calculate exponential backoff with jitter: 30s, 2m, 10m, 30m, 2h
|
|
142
|
+
calculateNextRetry(attempt) {
|
|
143
|
+
const intervals = [30, 120, 600, 1800, 7200]; // seconds
|
|
144
|
+
const base = intervals[Math.min(attempt, intervals.length - 1)];
|
|
145
|
+
const jitter = Math.floor(Math.random() * (base * 0.2));
|
|
146
|
+
const delaySec = base + jitter;
|
|
147
|
+
return new Date(Date.now() + delaySec * 1000).toISOString();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async dispatch(event, endpoint) {
|
|
151
|
+
const limit = endpoint.concurrency_limit || this.defaultConcurrency;
|
|
152
|
+
await this.acquireSlot(endpoint.id, limit);
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
return await this._executeDispatch(event, endpoint);
|
|
156
|
+
} finally {
|
|
157
|
+
this.releaseSlot(endpoint.id, limit);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async _executeDispatch(event, endpoint) {
|
|
162
|
+
const startTime = Date.now();
|
|
163
|
+
const targetUrl = endpoint.target_url;
|
|
164
|
+
|
|
165
|
+
let headers = { ...event.headers };
|
|
166
|
+
|
|
167
|
+
// Re-sign outbound headers if endpoint secret is configured
|
|
168
|
+
if (endpoint.secret) {
|
|
169
|
+
headers = this.signHeaders(event.provider, event.raw_body, headers, endpoint.secret);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Remove hop-by-hop and encoding headers
|
|
173
|
+
delete headers['host'];
|
|
174
|
+
delete headers['connection'];
|
|
175
|
+
delete headers['content-length'];
|
|
176
|
+
delete headers['content-encoding'];
|
|
177
|
+
delete headers['transfer-encoding'];
|
|
178
|
+
|
|
179
|
+
const rawBuffer = Buffer.from(event.raw_body, 'utf8');
|
|
180
|
+
headers['content-length'] = rawBuffer.length;
|
|
181
|
+
headers['x-hookarmor-delivery-id'] = event.id;
|
|
182
|
+
headers['x-hookarmor-attempt'] = String(event.attempts + 1);
|
|
183
|
+
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
try {
|
|
186
|
+
const url = new URL(targetUrl);
|
|
187
|
+
const isHttps = url.protocol === 'https:';
|
|
188
|
+
const client = isHttps ? https : http;
|
|
189
|
+
|
|
190
|
+
const req = client.request(url, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers,
|
|
193
|
+
timeout: this.defaultTimeoutMs
|
|
194
|
+
}, (res) => {
|
|
195
|
+
let responseBody = '';
|
|
196
|
+
res.setEncoding('utf8');
|
|
197
|
+
res.on('data', chunk => {
|
|
198
|
+
if (responseBody.length < 4000) {
|
|
199
|
+
responseBody += chunk;
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
res.on('end', async () => {
|
|
204
|
+
const latencyMs = Date.now() - startTime;
|
|
205
|
+
const statusCode = res.statusCode || 0;
|
|
206
|
+
const isSuccess = statusCode >= 200 && statusCode < 300;
|
|
207
|
+
|
|
208
|
+
let nextRetryAt = null;
|
|
209
|
+
if (!isSuccess && endpoint.auto_retry && (event.attempts + 1) < endpoint.max_retries) {
|
|
210
|
+
nextRetryAt = this.calculateNextRetry(event.attempts);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const updatedEvent = this.storage.recordAttempt({
|
|
214
|
+
eventId: event.id,
|
|
215
|
+
statusCode,
|
|
216
|
+
responseBody,
|
|
217
|
+
errorMessage: isSuccess ? '' : `Destination returned HTTP ${statusCode}`,
|
|
218
|
+
latencyMs,
|
|
219
|
+
nextRetryAt
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const result = {
|
|
223
|
+
eventId: event.id,
|
|
224
|
+
endpointId: endpoint.id,
|
|
225
|
+
success: isSuccess,
|
|
226
|
+
statusCode,
|
|
227
|
+
latencyMs,
|
|
228
|
+
responseBody,
|
|
229
|
+
nextRetryAt
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
this.emit('delivery', result);
|
|
233
|
+
|
|
234
|
+
if (!isSuccess) {
|
|
235
|
+
await Alerter.sendAlert(endpoint.alert_webhook_url, {
|
|
236
|
+
event,
|
|
237
|
+
endpoint,
|
|
238
|
+
attempt: { statusCode, errorMessage: `HTTP ${statusCode}`, latencyMs }
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
resolve(result);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
req.on('timeout', async () => {
|
|
247
|
+
req.destroy(new Error(`Connection timed out after ${this.defaultTimeoutMs}ms`));
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
req.on('error', async (err) => {
|
|
251
|
+
const latencyMs = Date.now() - startTime;
|
|
252
|
+
let nextRetryAt = null;
|
|
253
|
+
if (endpoint.auto_retry && (event.attempts + 1) < endpoint.max_retries) {
|
|
254
|
+
nextRetryAt = this.calculateNextRetry(event.attempts);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const updatedEvent = this.storage.recordAttempt({
|
|
258
|
+
eventId: event.id,
|
|
259
|
+
statusCode: 0,
|
|
260
|
+
responseBody: '',
|
|
261
|
+
errorMessage: err.message,
|
|
262
|
+
latencyMs,
|
|
263
|
+
nextRetryAt
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const result = {
|
|
267
|
+
eventId: event.id,
|
|
268
|
+
endpointId: endpoint.id,
|
|
269
|
+
success: false,
|
|
270
|
+
statusCode: 0,
|
|
271
|
+
latencyMs,
|
|
272
|
+
errorMessage: err.message,
|
|
273
|
+
nextRetryAt
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
this.emit('delivery', result);
|
|
277
|
+
|
|
278
|
+
await Alerter.sendAlert(endpoint.alert_webhook_url, {
|
|
279
|
+
event,
|
|
280
|
+
endpoint,
|
|
281
|
+
attempt: { statusCode: 0, errorMessage: err.message, latencyMs }
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
resolve(result);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
req.write(rawBuffer);
|
|
288
|
+
req.end();
|
|
289
|
+
} catch (err) {
|
|
290
|
+
const latencyMs = Date.now() - startTime;
|
|
291
|
+
this.storage.recordAttempt({
|
|
292
|
+
eventId: event.id,
|
|
293
|
+
statusCode: 0,
|
|
294
|
+
responseBody: '',
|
|
295
|
+
errorMessage: err.message,
|
|
296
|
+
latencyMs
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const result = {
|
|
300
|
+
eventId: event.id,
|
|
301
|
+
endpointId: endpoint.id,
|
|
302
|
+
success: false,
|
|
303
|
+
statusCode: 0,
|
|
304
|
+
latencyMs,
|
|
305
|
+
errorMessage: err.message
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
this.emit('delivery', result);
|
|
309
|
+
resolve(result);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async replayEvent(eventId) {
|
|
315
|
+
const event = this.storage.getEvent(eventId);
|
|
316
|
+
if (!event) throw new Error(`Event not found: ${eventId}`);
|
|
317
|
+
const endpoint = this.storage.getEndpoint(event.endpoint_id);
|
|
318
|
+
if (!endpoint) throw new Error(`Endpoint not found: ${event.endpoint_id}`);
|
|
319
|
+
|
|
320
|
+
return this.dispatch(event, endpoint);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async replayAllFailed(endpointId = null) {
|
|
324
|
+
const failedEvents = this.storage.getFailedEventsToRetry(endpointId);
|
|
325
|
+
const results = [];
|
|
326
|
+
for (const event of failedEvents) {
|
|
327
|
+
const endpoint = this.storage.getEndpoint(event.endpoint_id);
|
|
328
|
+
if (endpoint) {
|
|
329
|
+
const res = await this.dispatch(event, endpoint);
|
|
330
|
+
results.push(res);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return results;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
module.exports = Dispatcher;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>HookArmor Engineering Blog - Webhook Reliability & Architecture</title>
|
|
7
|
+
<meta name="description" content="Deep technical teardowns on webhook reliability, Stripe signature verification edge cases, SQS deduplication pitfalls, and serverless crash recovery.">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
11
|
+
<style>
|
|
12
|
+
:root {
|
|
13
|
+
--bg: #070a12;
|
|
14
|
+
--bg-card: #0d1322;
|
|
15
|
+
--border: #1e293b;
|
|
16
|
+
--accent: #3b82f6;
|
|
17
|
+
--text: #f8fafc;
|
|
18
|
+
--text-muted: #94a3b8;
|
|
19
|
+
--font-sans: 'Plus Jakarta Sans', sans-serif;
|
|
20
|
+
--font-mono: 'JetBrains Mono', monospace;
|
|
21
|
+
}
|
|
22
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
23
|
+
body {
|
|
24
|
+
background: var(--bg);
|
|
25
|
+
color: var(--text);
|
|
26
|
+
font-family: var(--font-sans);
|
|
27
|
+
line-height: 1.6;
|
|
28
|
+
padding: 0 1.5rem;
|
|
29
|
+
}
|
|
30
|
+
nav {
|
|
31
|
+
max-width: 900px;
|
|
32
|
+
margin: 0 auto;
|
|
33
|
+
padding: 2rem 0;
|
|
34
|
+
display: flex;
|
|
35
|
+
justify-content: space-between;
|
|
36
|
+
align-items: center;
|
|
37
|
+
border-bottom: 1px solid var(--border);
|
|
38
|
+
}
|
|
39
|
+
.logo {
|
|
40
|
+
font-weight: 800;
|
|
41
|
+
font-size: 1.25rem;
|
|
42
|
+
color: white;
|
|
43
|
+
text-decoration: none;
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: 0.5rem;
|
|
47
|
+
}
|
|
48
|
+
.nav-links a {
|
|
49
|
+
color: var(--text-muted);
|
|
50
|
+
text-decoration: none;
|
|
51
|
+
font-size: 0.9rem;
|
|
52
|
+
margin-left: 1.5rem;
|
|
53
|
+
transition: color 0.15s;
|
|
54
|
+
}
|
|
55
|
+
.nav-links a:hover { color: var(--text); }
|
|
56
|
+
main {
|
|
57
|
+
max-width: 900px;
|
|
58
|
+
margin: 3rem auto;
|
|
59
|
+
}
|
|
60
|
+
.header-section {
|
|
61
|
+
margin-bottom: 3.5rem;
|
|
62
|
+
}
|
|
63
|
+
h1 {
|
|
64
|
+
font-size: 2.25rem;
|
|
65
|
+
font-weight: 800;
|
|
66
|
+
letter-spacing: -0.025em;
|
|
67
|
+
margin-bottom: 0.75rem;
|
|
68
|
+
}
|
|
69
|
+
.subtitle {
|
|
70
|
+
color: var(--text-muted);
|
|
71
|
+
font-size: 1.1rem;
|
|
72
|
+
}
|
|
73
|
+
.post-card {
|
|
74
|
+
background: var(--bg-card);
|
|
75
|
+
border: 1px solid var(--border);
|
|
76
|
+
border-radius: 12px;
|
|
77
|
+
padding: 2rem;
|
|
78
|
+
margin-bottom: 2rem;
|
|
79
|
+
transition: border-color 0.2s, transform 0.2s;
|
|
80
|
+
display: block;
|
|
81
|
+
text-decoration: none;
|
|
82
|
+
color: inherit;
|
|
83
|
+
}
|
|
84
|
+
.post-card:hover {
|
|
85
|
+
border-color: var(--accent);
|
|
86
|
+
transform: translateY(-2px);
|
|
87
|
+
}
|
|
88
|
+
.post-tag {
|
|
89
|
+
font-family: var(--font-mono);
|
|
90
|
+
font-size: 0.75rem;
|
|
91
|
+
color: var(--accent);
|
|
92
|
+
text-transform: uppercase;
|
|
93
|
+
letter-spacing: 0.05em;
|
|
94
|
+
font-weight: 700;
|
|
95
|
+
margin-bottom: 0.75rem;
|
|
96
|
+
display: inline-block;
|
|
97
|
+
}
|
|
98
|
+
.post-title {
|
|
99
|
+
font-size: 1.5rem;
|
|
100
|
+
font-weight: 700;
|
|
101
|
+
margin-bottom: 0.75rem;
|
|
102
|
+
line-height: 1.3;
|
|
103
|
+
}
|
|
104
|
+
.post-summary {
|
|
105
|
+
color: var(--text-muted);
|
|
106
|
+
font-size: 0.95rem;
|
|
107
|
+
margin-bottom: 1.25rem;
|
|
108
|
+
line-height: 1.6;
|
|
109
|
+
}
|
|
110
|
+
.post-meta {
|
|
111
|
+
font-size: 0.8rem;
|
|
112
|
+
color: #64748b;
|
|
113
|
+
display: flex;
|
|
114
|
+
gap: 1rem;
|
|
115
|
+
align-items: center;
|
|
116
|
+
}
|
|
117
|
+
</style>
|
|
118
|
+
<meta property="og:image" content="https://hookarmour.dev/og-preview.png">
|
|
119
|
+
<meta property="og:image:width" content="1200">
|
|
120
|
+
<meta property="og:image:height" content="630">
|
|
121
|
+
<meta name="twitter:image" content="https://hookarmour.dev/og-preview.png">
|
|
122
|
+
</head>
|
|
123
|
+
<body>
|
|
124
|
+
<nav>
|
|
125
|
+
<a href="/" class="logo">🛡️ HookArmor</a>
|
|
126
|
+
<div class="nav-links">
|
|
127
|
+
<a href="/">Product</a>
|
|
128
|
+
<a href="/dashboard">Dashboard</a>
|
|
129
|
+
<a href="https://github.com/pkdoddamani/hookarmor" target="_blank">GitHub</a>
|
|
130
|
+
</div>
|
|
131
|
+
</nav>
|
|
132
|
+
|
|
133
|
+
<main>
|
|
134
|
+
<div class="header-section">
|
|
135
|
+
<span class="post-tag">Engineering Notes</span>
|
|
136
|
+
<h1>Systems & Webhook Architecture</h1>
|
|
137
|
+
<p class="subtitle">Deep technical teardowns on Stripe signature mechanics, idempotency traps, and zero-loss distributed ingress.</p>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<a href="/blog/stripe-webhook-signature-verification-failed" class="post-card">
|
|
141
|
+
<span class="post-tag">Security & Webhooks</span>
|
|
142
|
+
<h2 class="post-title">Why Stripe Webhook Replays Fail Signature Verification (And The Re-Signing Fix)</h2>
|
|
143
|
+
<p class="post-summary">Why standard `stripe.webhooks.constructEvent()` throws "Timestamp outside the tolerance zone" on retried or replayed webhooks older than 300 seconds, and the cryptographic re-signing architecture required to solve it.</p>
|
|
144
|
+
<div class="post-meta">
|
|
145
|
+
<span>Sept 2026</span>
|
|
146
|
+
<span>•</span>
|
|
147
|
+
<span>6 min read</span>
|
|
148
|
+
<span>•</span>
|
|
149
|
+
<span>By HookArmor Systems Team</span>
|
|
150
|
+
</div>
|
|
151
|
+
</a>
|
|
152
|
+
|
|
153
|
+
<a href="/blog/sqs-webhook-deduplication-trap" class="post-card">
|
|
154
|
+
<span class="post-tag">Distributed Systems</span>
|
|
155
|
+
<h2 class="post-title">The SQS Deduplication Trap: Why Naive Webhook Queues Silently Drop Payment Retries</h2>
|
|
156
|
+
<p class="post-summary">Rolling your own SQS + Lambda webhook queue looks simple in 20 lines of code until database connection exhaustion or Lambda OOM crashes turn your unique constraint dedupe into a silent event incinerator.</p>
|
|
157
|
+
<div class="post-meta">
|
|
158
|
+
<span>Sept 2026</span>
|
|
159
|
+
<span>•</span>
|
|
160
|
+
<span>7 min read</span>
|
|
161
|
+
<span>•</span>
|
|
162
|
+
<span>By HookArmor Systems Team</span>
|
|
163
|
+
</div>
|
|
164
|
+
</a>
|
|
165
|
+
</main>
|
|
166
|
+
</body>
|
|
167
|
+
</html>
|