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-Signature header?
  • Why is my Plain webhook signature verification failing (or why are requests unsigned)?
  • How do I handle thread.thread_created, thread.email_received, or customer.customer_created events?

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)

  1. In Plain, go to Settings → Request signing (workspace admins only) and generate/copy the HMAC secret.
  2. Create a webhook target in Settings → Webhooks → Add webhook target.
  3. 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.eventTypeTriggered When
thread.thread_createdA thread is created, on any channel
thread.email_receivedAn email arrives on a thread
thread.thread_status_transitionedA thread changes status
customer.customer_createdA 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 9, 2026

View on GitHub →