Agent skill
Plain Webhooks Skill
Receive and verify Plain (customer support) webhooks. Use when setting up Plain webhook handlers, debugging Plain-Request-Signature verification, turning on request signing, or handling events like thread.thread_created, thread.email_received, thread.thread_status_transitioned, and customer.customer_created.
Install this skill
npx skills add hookdeck/webhook-skills --skill plain-webhooks
When to Use This Skill
- How do I receive Plain webhooks?
- How do I verify the
Plain-Request-Signatureheader? - Why is my Plain webhook signature verification failing (or why are requests unsigned)?
- How do I handle
thread.thread_created,thread.email_received, orcustomer.customer_events?created
Verification (core)
Plain signs the raw request body with HMAC-SHA256, keyed with your workspace HMAC secret, and sends the lowercase hex digest in the Plain-Request-Signature header. Requests are only signed once a workspace HMAC secret exists; without one the header is absent. Plain's docs snippet hashes JSON.stringify(request.body), but the official @team-plain/webhooks SDK hashes the raw payload, so use the raw body (re-serialized JSON can differ byte for byte).
const crypto = require('crypto');
function verifyPlainSignature(rawBody, signature, secret) {
// Exactly 64 hex chars, checked before any hex decoding (invalid hex silently truncates)
if (!signature || !/^[0-9a-f]{64}$/i.test(signature)) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature.toLowerCase(), 'utf8'),
Buffer.from(expected, 'utf8')
);
}
In Python, use hmac.compare_digest on bytes (expected.encode() and signature.lower().encode()) so a non-ASCII header gives a 401, not a 500.
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Setup (with or without Hookdeck)
- In Plain, go to Settings → Request signing (workspace admins only) and generate/copy the HMAC secret.
- Create a webhook target in Settings → Webhooks → Add webhook target.
- Store the secret as
PLAIN_WEBHOOK_SECRET. If you use Hookdeck, turn on request signing in Plain and paste its HMAC secret into the source's verification settings. Without request signing, requests are unsigned and fail verification.
Express handlers must read the body with express.raw({ type: '*/*' }) so any content type verifies. See references/setup.md.
Common Event Types
The body is {id, type, timestamp, workspaceId, payload: {eventType, ...}, webhookMetadata: {...}}. Dispatch on payload.eventType (also sent as the Plain-Event-Type header).
payload.eventType | Triggered When |
|---|---|
thread.thread_created | A thread is created, on any channel |
thread.email_received | An email arrives on a thread |
thread.thread_ | A thread changes status |
customer.customer_ | A customer is created |
For the full event reference, see Plain webhook events.
Environment Variables
PLAIN_WEBHOOK_SECRET=your_plain_hmac_secret # Settings > Request signing
PLAIN_TOLERANCE_SECONDS=300 # Optional replay check; off when unset
Local Development
npx hookdeck-cli listen 3000 plain --path /webhooks/plain
No account required; the CLI creates a guest account on first run.
Reference Materials
- references/overview.md - Plain webhook concepts, payload shape, events
- references/setup.md - Request signing, webhook targets, Hookdeck
- references/verification.md - Signature details and gotchas