协作头脑风暴
brainstorming
在实现前澄清需求、探索设计选择,并形成清晰的开发方向。
appfolio-webhooks-events【开发与工程】Handle AppFolio webhook events for property management notifications. Trigger: "appfolio webhook".
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
name: appfolio-webhooks-events description: 'Handle AppFolio webhook events for property management notifications.
Trigger: "appfolio webhook".
' allowed-tools: Read, Write, Edit, Bash(npm:), Bash(curl:), Grep version: 1.5.0 license: MIT author: Jeremy Longshore jeremy@intentsolutions.io tags:
AppFolio Stack delivers real-time webhook notifications for property management lifecycle events including tenant onboarding, lease execution, rent payments, and maintenance workflows. Use these webhooks to sync AppFolio data with your CRM, accounting system, or custom property management dashboards without polling the API.
// Use this only if the signed provider contract explicitly supports this API.
const response = await createVerifiedAppFolioClient().post("/webhooks", {
url: "https://yourapp.com/webhooks/appfolio",
events: ["tenant.created", "work_order.updated", "payment.received", "lease.signed"],
secret: process.env.APPFOLIO_WEBHOOK_SECRET,
});
import crypto from "crypto";
import { Request, Response, NextFunction } from "express";
function verifyAppFolioSignature(req: Request, res: Response, next: NextFunction) {
const signature = req.headers["x-appfolio-signature"] as string;
const expected = Buffer.from(crypto
.createHmac("sha256", process.env.APPFOLIO_WEBHOOK_SECRET!)
.update(req.body)
.digest("hex"));
const received = signature ? Buffer.from(signature) : Buffer.alloc(0);
if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
return res.status(401).json({ error: "Invalid signature" });
}
next();
}
import express from "express";
const app = express();
app.post("/webhooks/appfolio", express.raw({ type: "application/json" }), verifyAppFolioSignature, (req, res) => {
const event = JSON.parse(req.body.toString());
// persistIncomingEvent performs a durable idempotency insert and queue write
// in one transaction. A failed persist must receive a retryable response.
persistIncomingEvent(event).then(() => {
res.status(202).json({ received: true });
}).catch(() => {
res.status(503).json({ error: "Event persistence unavailable" });
});
});
| Event | Payload Fields | Use Case |
|---|---|---|
tenant.created | tenant_id, property_id, email | Sync new tenant to CRM |
work_order.updated | work_order_id, status, assigned_vendor | Dispatch or escalate maintenance |
payment.received | lease_id, amount_cents, payment_method | Update accounting ledger |
lease.signed | lease_id, move_in_date, term_months | Activate unit and send welcome |
lease.expired | lease_id, unit_id, vacate_date | Trigger renewal or re-listing |
async function handleIdempotent(event: { id: string; type: string; data: any }) {
if (await durableEventStore.hasSucceeded(event.id)) return;
await routeEvent(event);
await durableEventStore.markSucceeded(event.id);
}
| Issue | Cause | Fix |
|---|---|---|
| Signature mismatch | Wrong secret or parsed body | Use express.raw() for verification |
| Duplicate events | AppFolio retry on timeout | Track event IDs for idempotency |
Missing property_id | Event from archived property | Check property status before processing |
| Durable store/queue unavailable | Cannot guarantee acknowledged event survives | Return retryable 503; do not acknowledge before persistence |
For a synthetic work-order update, deliver a valid signed event, a malformed
signature, a duplicate ID, and a downstream timeout. Prove the valid event is
persisted before 202, the malformed request is rejected without parsing, the
duplicate does not produce a second side effect, and the timeout remains queued
for reconciliation. If provider support, raw-body capture, signature secret,
durable store, or queue is unavailable, keep the endpoint disabled and use the
provider-approved polling/reconciliation path instead.
See appfolio-security-basics.