loom-webhooks
Webhook implementation and consumption patterns. Use when building webhook endpoints, receivers, or senders — covering HMAC signature verification, retry with exponential backoff, idempotency keys, delivery guarantees, replay protection, dead letter queues, payload design, and monitoring.
Works with
---
name: loom-webhooks
description: Webhook implementation and consumption patterns. Use when building webhook endpoints, receivers, or senders — covering HMAC signature verification, retry with exponential backoff, idempotency keys, delivery guarantees, replay protection, dead letter queues, payload design, and monitoring.
license: MIT
---
# Webhooks
## Overview
HTTP callbacks that push events to external systems instead of polling. The hard parts are all correctness/security: signing over the right bytes, timing-safe verification, replay protection, at-least-once delivery + idempotent consumers, and SSRF on the sender. This skill is organized sender-side vs receiver-side, with the traps called out.
## The non-negotiables (read first)
- **Sign/verify over the RAW request body**, not re-serialized JSON. `JSON.parse` → `JSON.stringify` reorders keys and changes whitespace, breaking the HMAC. Receiver must read raw bytes *before* any JSON body parser runs.
- **Timing-safe compare** for signatures — never `===`. And guard length first: `crypto.timingSafeEqual` **throws** on unequal-length buffers.
- **Timestamped signatures + a tolerance window** to blunt replay; dedup by event id for the rest.
- **At-least-once delivery is the only realistic guarantee** ⇒ duplicates *will* arrive ⇒ **consumers must be idempotent.**
- **Verify before you trust or parse.** For security-critical actions, treat the payload as a *hint* and refetch canonical state from your API.
- **Sender makes outbound requests to user-supplied URLs ⇒ SSRF surface.** Validate destinations; block internal ranges.
## Event & subscription model
```typescript
interface WebhookEvent {
id: string; // stable, unique → idempotency + replay key
type: string; // "order.created" — resource.action
created: number; // Unix seconds
apiVersion: string; // payload schema version
data: {
object: Record<string, unknown>;
previousAttributes?: Record<string, unknown>; // deltas on updates
};
}
interface WebhookEndpoint {
id: string;
url: string;
secret: string; // per-endpoint, rotatable
events: string[]; // ["order.*", "payment.completed", "*"]
status: "active" | "disabled";
}
```
Naming: `resource.action` (`order.created`, `payment.failed`). Support wildcards (`order.*`, `*`).
### Thin vs fat events (design decision)
| | Fat (embed full object) | Thin (id + type only) |
| --- | --- | --- |
| Extra fetch | none | receiver GETs canonical state |
| Freshness | can be **stale/out-of-order** at delivery | always current (fetch at process time) |
| Ordering | receiver must handle reordering via `created`/version | naturally tolerant — fetch reflects latest |
| PII/size | more data in transit/logs; size caps (<256KB) | minimal exposure |
| Reliability | works if your API is down | needs your API up to process |
⚠ **Don't trust a fat payload for security/authorization decisions** — an attacker who forges or replays a delivery controls its contents. Verify signature, then for money/permissions **refetch** by id. Thin events sidestep this and out-of-order delivery, at the cost of a round-trip.
## Sender side
### HMAC signing (over raw payload + timestamp)
```typescript
import crypto from "crypto";
function sign(secret: string, payload: string, ts: number): string {
return crypto.createHmac("sha256", secret).update(`${ts}.${payload}`).digest("hex");
}
function signedHeaders(secret: string, payload: string): Record<string, string> {
const ts = Math.floor(Date.now() / 1000);
return {
"Content-Type": "application/json",
"X-Webhook-Timestamp": String(ts),
"X-Webhook-Signature": `v1=${sign(secret, payload, ts)}`, // scheme-versioned for rotation
};
}
```
Signing the timestamp *inside* the HMAC (Stripe-style `t=...,v1=...`) prevents an attacker from replaying a captured body with a fresh timestamp. Version the scheme (`v1=`) so you can add `v2=` and support both during rotation.
### Retry with exponential backoff + jitter
```typescript
const retry = { maxAttempts: 5, initialDelay: 1000, maxDelay: 3_600_000, factor: 2,
retryable: [408, 429, 500, 502, 503, 504] };
function nextDelay(attempt: number): number { // attempt starts at 0
const base = Math.min(retry.initialDelay * retry.factor ** attempt, retry.maxDelay);
return base + base * Math.random() * 0.25; // 0–25% jitter → no thundering herd
}
```
- Retry only network errors + `retryable` statuses. **Don't retry 4xx** (400/401/422) — the request is broken; retrying just hammers the receiver.
- Honor the receiver's `Retry-After` on 429/503 over your computed backoff.
- 30s delivery timeout. Deliver **asynchronously via a queue** — never block the event-producing transaction on HTTP to a third party.
### At-least-once delivery
```typescript
async function dispatch(event: WebhookEvent, endpoints: WebhookEndpoint[]) {
await db.events.create(event); // persist FIRST (durability)
for (const ep of endpoints) {
if (ep.status !== "active" || !matches(event.type, ep.events)) continue;
await queue.add("deliver", { eventId: event.id, endpointId: ep.id },
{ attempts: 5, backoff: { type: "exponential", delay: 1000 }, removeOnFail: false });
}
}
function matches(type: string, filters: string[]): boolean {
return filters.some(f => f === "*" || (f.endsWith(".*") ? type.startsWith(f.slice(0, -2)) : f === type));
}
```
Persist the event before enqueuing so a crash can't lose it. Keep failed jobs (`removeOnFail: false`) for inspection/replay. Include the immutable `event.id` in every delivery so receivers can dedup.
### SSRF: webhook URLs are attacker-controlled
Users register the destination URL, so your sender becomes an SSRF vector into your own network.
- Reject non-HTTPS and non-standard ports at registration.
- Resolve the hostname and **block private/loopback/link-local/metadata ranges**: `127.0.0.0/8`, `10/8`, `172.16/12`, `192.168/16`, `169.254.0.0/16` (incl. `169.254.169.254` cloud metadata), `::1`, `fc00::/7`, `fe80::/10`.
- ⚠ **DNS rebinding**: validate the IP you actually connect to, not just the one resolved at registration — resolve again at request time and pin, or use an egress proxy / allowlist.
- Disable HTTP redirects (or re-validate each hop) — a redirect to `http://169.254.169.254/` bypasses the initial check.
- Cap response body size and timeout; you don't need the receiver's response body.
### DLQ + auto-disable
```typescript
async function onExhausted(delivery: WebhookDelivery) {
await db.deadLetter.create({ ...delivery, movedAt: new Date() });
const failures1h = await db.deadLetter.count({ endpointId: delivery.endpointId, since: hoursAgo(1) });
if (failures1h >= 10) await alerts.warn("Webhook endpoint failing", delivery.endpointId);
const failures24h = await db.deadLetter.count({ endpointId: delivery.endpointId, since: hoursAgo(24) });
if (failures24h >= 100) await db.endpoints.disable(delivery.endpointId, "too many failures");
}
```
After max attempts, move to a DLQ (never silently drop). Alert on rising failure rates; auto-disable chronically dead endpoints to stop wasting deliveries, and expose a manual replay path.
## Receiver side
### Verification middleware (raw body first!)
```typescript
import express from "express";
function verify(secret: string, rawBody: Buffer, sigHeader: string, tsHeader: string,
toleranceSec = 300): void {
const ts = parseInt(tsHeader, 10);
if (!ts || Math.abs(Math.floor(Date.now() / 1000) - ts) > toleranceSec)
throw new WebhookError("timestamp outside tolerance", "TIMESTAMP_EXPIRED"); // replay guard
const provided = sigHeader.split(",").find(p => p.startsWith("v1="))?.slice(3);
if (!provided) throw new WebhookError("no v1 signature", "INVALID_SIGNATURE");
const expected = sign(secret, rawBody.toString("utf8"), ts);
const a = Buffer.from(provided), b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) // length guard: timingSafeEqual throws otherwise
throw new WebhookError("signature mismatch", "INVALID_SIGNATURE");
}
// express.raw keeps the body as bytes — a global express.json() would corrupt the HMAC
app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
try {
verify(process.env.WEBHOOK_SECRET!, req.body,
req.header("x-webhook-signature")!, req.header("x-webhook-timestamp")!);
} catch { return res.status(401).json({ error: "invalid signature" }); }
const event = JSON.parse(req.body.toString()) as WebhookEvent;
res.status(200).json({ received: true }); // ACK fast (<1s)…
enqueueForProcessing(event).catch(logger.error); // …then process out of band
});
```
Traps: (1) any body parser that runs before this handler replaces `req.body` and the raw bytes are gone — mount `express.raw` on this route only, or capture raw via a `verify` hook. (2) `timingSafeEqual` throws `RangeError` on length mismatch — compare lengths first (also prevents a length-based side channel). (3) verify **before** `JSON.parse`.
### Idempotent processing (dedup by event id)
```typescript
async function process(event: WebhookEvent) {
// NX set → true only for the first arrival of this id
const first = await redis.set(`wh:${event.id}`, "1", "EX", 86400, "NX");
if (!first) return; // duplicate delivery → no-op
try {
await handlers[event.type]?.(event.data.object);
} catch (e) {
await redis.del(`wh:${event.id}`); // allow retry to reprocess
throw e; // 5xx → sender retries
}
}
```
Redis `SET key val EX ttl NX` is the atomic dedup primitive (no read-then-write race). ⚠ Redis dedup is best-effort — for money-movement use a DB unique constraint on `event_id` (or an inbox table) so dedup is transactional with the side effect. Delete the key on failure so a legitimate retry can reprocess.
### Ordering & staleness
Delivery is **not ordered**. `order.updated` can arrive before `order.created`, or a stale update after a newer one. Defend with the event `created`/a monotonically increasing version: ignore an update whose version ≤ the version you already applied. Thin events dodge this by always fetching current state.
### Receiver status-code contract
| Status | Meaning to sender |
| ------ | ----------------- |
| 2xx | Received (and durably queued) — do not retry |
| 400 | Malformed payload — permanent, do not retry |
| 401 | Bad/missing signature — permanent, do not retry |
| 409 | Duplicate (optional) — do not retry |
| 5xx / timeout | Transient — sender retries |
Return 2xx **only after** you've durably stored/queued the event — a 2xx before persistence means a crash loses it and the sender won't retry. Store the raw payload before processing for replay/debugging.
## Monitoring
Track per endpoint: total deliveries, success rate, avg/p95/p99 latency, error breakdown by status, DLQ depth, retry backlog. Alert on success-rate drop and DLQ growth; auto-disable after a threshold. Give customers a delivery log + self-serve replay for failed events.
```typescript
function p95(values: number[]): number {
if (!values.length) return 0;
const s = [...values].sort((a, b) => a - b);
return s[Math.ceil(s.length * 0.95) - 1];
}
```
## Reference values
- Retries: 5 attempts, 1s initial, 1h max, factor 2, 0–25% jitter; retryable `408, 429, 500, 502, 503, 504`.
- Delivery timeout 30s; receiver should ACK <1s and process async.
- Signature tolerance ~300s; idempotency/dedup TTL ~24h; payload cap <256KB.
- Rotate secrets periodically; support two active secrets during rotation (accept either).
## Checklists
**Sender — verify before shipping:**
- [ ] Event persisted before enqueue; delivery async via durable queue
- [ ] HMAC over raw payload with timestamp inside the signed string; scheme versioned (`v1=`)
- [ ] Backoff + jitter; retries limited to network errors + retryable 5xx/429/408; `Retry-After` honored; 4xx not retried
- [ ] SSRF guard: HTTPS-only, private/link-local/metadata IPs blocked, redirects disabled/re-validated, DNS-rebinding handled at connect time
- [ ] DLQ on exhaustion (nothing silently dropped); failure alerting + auto-disable + manual replay
- [ ] Per-endpoint rotatable secrets; monitoring (success rate, p95, DLQ depth)
**Receiver — verify before shipping:**
- [ ] Raw body captured before any JSON parser; signature verified before `JSON.parse`
- [ ] Timing-safe compare **with length guard**; timestamp tolerance enforced (replay window)
- [ ] Consumer idempotent — dedup by `event.id` (DB unique constraint for state-changing/financial ops)
- [ ] ACK 2xx only after durable persist/enqueue; then process out of band
- [ ] Out-of-order tolerated via `created`/version; security-critical actions refetch canonical state, don't trust payload
- [ ] Correct status codes: 2xx received, 400/401 permanent, 5xx retryableMore API Design skills
lark-event
larksuite/cli
Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, Approval status changes, Task updates, VC meeting started/joined/ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses.
lark-contact
larksuite/cli
飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名,以及按关键词搜索当前用户可见的机器人 / 智能体(agent)。当用户提到一个名字要下一步发消息 / 排日程,或拿到 open_id 想查具体信息时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。
lark-openapi-explorer
larksuite/cli
飞书/Lark 原生 OpenAPI 探索:从官方文档库中挖掘未经 CLI 封装的原生 OpenAPI 接口。当用户的需求无法被现有 lark-* skill 或 lark-cli 已注册命令满足,需要查找并调用原生飞书 OpenAPI 时使用。

