Guide to Plain Webhooks: Features and Best Practices
Plain is a customer support platform built for B2B teams, with threads that span email, Slack, Discord, Microsoft Teams and chat. Its webhooks tell your systems what is happening in the workspace as it happens: a thread is created or changes status, a message arrives, a customer is updated, a task moves, or an account changes. Teams use them for routing and assignment, auto-responders, labelling and incident triggers.
This guide covers how Plain webhooks are configured and signed, the events and their payloads, versioning and retries, and the practices that keep a Plain receiver reliable.
What are Plain webhooks?
In Plain, a webhook target tells Plain which events you want and where to send them. Each target has a name, an HTTPS URL, a set of event types, an enabled flag and a schema version. When a subscribed event occurs, Plain POSTs a JSON body to the target's URL.
Signing is a workspace setting rather than a per-target one. Once a workspace admin generates the HMAC secret under Settings > Request signing, every request Plain sends carries a Plain-Request-Signature header: a hex HMAC-SHA256 of the request body. The same secret signs Plain's customer card requests. Until the secret exists, requests are unsigned.
Plain guarantees at-least-once delivery and retries failures for about five days.
Plain webhook features
| Feature | Details |
|---|---|
| Configuration | Settings > Webhooks > Add webhook target |
| Limit | 25 webhook targets per workspace |
| Events | 48 types across threads, discussions, customers, timelines, tasks and accounts |
| Versioning | Each target is pinned to a schema version |
| Payload | { id, type, timestamp, workspaceId, payload, webhookMetadata } |
| Signature header | Plain-Request-Signature, once request signing is set up |
| Scheme | HMAC-SHA256, hex, no prefix |
| Signed content | The raw request body |
| Secret | One global HMAC secret per workspace, from Settings > Request signing |
| Extra authentication | Optional Basic auth in the target URL, or mTLS |
| Success | Any 2xx; redirects count as failures |
| Retries | 12 retries over about 5 days |
| Delivery | At least once |
Plain doesn't document a response timeout or source IPs for webhooks.
Common events
Plain's JSON Schema lists 48 event types. The name is in the body's type, in payload.eventType and in the Plain-Event-Type header.
| Area | Examples | Fires when |
|---|---|---|
| Thread lifecycle | thread.thread_created, thread.thread_, thread.thread_, thread.thread_, thread.thread_, thread.thread_locked | A thread is created, changes status, assignee, labels or priority, or is locked |
| Messages | thread.email_received, thread.email_sent, thread.slack_, thread.discord_, thread.ms_, thread.chat_received | A message arrives or is sent on any channel (with _sent and _updated variants) |
| Notes and fields | thread.note_created, thread.note_, thread.thread_, thread.thread_, thread.thread_ | A note or mention is added, or a thread field changes |
| SLAs and tenants | thread.service_, thread.thread_ | An SLA changes state, or a thread's tenant changes |
| Discussions | discussion.discussion_, discussion.message_, discussion.tool_, discussion.tool_, discussion.turn_ | Discussion activity, including tool-call approvals |
| Customers | customer.customer_, customer.customer_, customer.customer_, customer.customer_, customer.customer_, customer.customer_ | A customer or customer group changes |
| Tasks | task.task_created, task.task_updated, task.task_, task.task_deleted | A task changes |
| Accounts | account.account_created, account.account_updated, account.account_deleted, account.account_, account.account_, account.account_, account.account_ | An account (formerly a tenant) or its fields or members change |
| Timeline | timeline.timeline_ | A timeline entry changes |
Account events are only sent to targets on version 2026-10-06 or later.
Every request shares one envelope. Here it is for thread.thread_created, heavily trimmed:
{
"id": "pEv_01HD44FHDPG82VQ4QNHDR4N6QW",
"type": "thread.thread_created",
"timestamp": "2026-10-25T13:46:40.000Z",
"workspaceId": "w_01GST0W989ZNAW53X6XYHAY87P",
"payload": {
"eventType": "thread.thread_created",
"thread": {
"id": "th_01HD44FHMCDSSWE38N14FSYV6K",
"title": "Can't export my invoices",
"status": "TODO",
"priority": 2,
"customer": { "id": "c_01HD44FHFF8M3BE6Q0EYR6AGSC", "fullName": "Jane Doe" },
"labels": [],
"createdAt": "2026-10-25T13:46:39.512Z"
}
},
"webhookMetadata": {
"webhookTargetId": "whTarget_01HD4400VTDJQ646V6RY37SR7K",
"webhookTargetVersion": "2026-10-06",
"webhookDeliveryAttemptId": "whAttempt_01HD44FJ45TWK7W6F0BVNKAX7K",
"webhookDeliveryAttemptNumber": 1,
"webhookDeliveryAttemptTimestamp": "2026-10-25T13:46:40.217Z"
}
}
idis the event ID. It stays the same across delivery attempts, so it is your idempotency key.typeis the event. Route on it.payloadholds the event-specific data. Thread events carry the fullthread, and transition events add the previous state.webhookMetadatarepeats the delivery headers.webhookDeliveryAttemptIdandwebhookDeliveryAttemptTimestampchange on every attempt, so the body of a retry differs from the first attempt.- Events can contain personal data, such as customer names, emails and message content.
The IDs above are illustrative. Plain's own examples are in its per-event docs and the JSON Schema.
Setting up Plain webhooks
- As a workspace admin, go to Settings > Request signing and generate the HMAC secret. Store it server-side, for example as
PLAIN_WEBHOOK_SECRET. - Go to Settings > Webhooks and click + Add webhook target.
- Enter a name, the HTTPS URL of your endpoint and the events you want, and choose whether to enable it straight away.
- Pick the latest schema version unless you have a reason to stay on an older one.
Turn on request signing in Plain and paste its HMAC secret into Hookdeck. Without it, Plain sends no Plain-Request-Signature header, so requests are unsigned and fail verification in your handler and in a Hookdeck Plain source.
If you receive Plain webhooks through Hookdeck, use the Hookdeck source URL as the target URL and paste the workspace HMAC secret into the Plain source. Because the secret is workspace-wide, regenerating it affects every webhook target and every customer card integration at once.
Securing Plain webhooks
Compute an HMAC-SHA256 of the raw request body with the workspace secret as the key, hex-encode it, and compare it with Plain-Request-Signature in constant time.
const crypto = require('crypto');
const express = require('express');
function verifyPlainSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/plain', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.PLAIN_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyPlainSignature(req.body, req.get('plain-request-signature'), secret)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on event.id, route on event.type, then process asynchronously.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
def verify_plain_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature_header.strip().lower().encode("utf-8"), expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_plain_signature(raw_body, request.headers.get("plain-request-signature"),
# os.environ["PLAIN_WEBHOOK_SECRET"])
Don't copy the Node snippet on Plain's request signing page as it stands. It hashes JSON.stringify(request.body), which only matches if re-serialising the parsed body reproduces Plain's exact bytes. It reads request.headers['Plain-Request-Signature'], but Node lowercases header names, so that lookup returns undefined. And it compares with !==, which isn't constant time. Plain's own SDK hashes the raw body, as above.
To check your verifier, use the secret plain-guide-test-hmac-secret and this exact body:
{"id":"pEv_01HD44FHDPG82VQ4QNHDR4N6QW","type":"thread.thread_created","payload":{"eventType":"thread.thread_created"}}
The Plain-Request-Signature value should be:
deb0a4dd32902e1da04ed0efc7df841a789625b5ada08ee7afb5188e14ef4a25
Replay checks and the SDK
The signature covers only the body, but the body includes webhookMetadata.webhookDeliveryAttemptTimestamp, which Plain sets fresh on every attempt. Plain's TypeScript SDK, @team-plain/webhooks, uses it: verifyPlainWebhook verifies the signature, then rejects the request if that timestamp is more than 300 seconds from your clock. Its optional fourth argument sets a different tolerance.
Plain's own retries pass that check, because each attempt is re-signed with a new timestamp. A delivery that Hookdeck retries or replays to your endpoint keeps the body from Plain's original attempt, so the SDK rejects it once it is more than 5 minutes old, even though its signature is valid. If you use the SDK behind Hookdeck, pass a larger tolerance, or verify with code like the above. The Plain skill takes the second route: its replay check is opt-in through PLAIN_TOLERANCE_SECONDS and off by default, so it accepts retries and replays unless you turn the check on.
Make Plain webhooks production-ready. Hookdeck Event Gateway verifies the
Plain-Request-Signatureheader on every delivery, acknowledges Plain straight away, and holds each thread and customer event until your service has processed it.
Plain webhook limitations and pain points
Signing is off until an admin turns it on
The Problem: Requests arrive with no signature, so anyone who finds the URL can post to it.
Why It Happens: Plain only signs once the workspace HMAC secret has been generated, and only admins can do that.
Workarounds:
- Generate the secret before you create your first webhook target.
- Reject requests without
Plain-Request-Signaturerather than treating a missing header as a pass. - Add Basic auth credentials to the target URL, or use mTLS, as an extra check.
How Hookdeck Can Help: A Hookdeck Plain source with the secret configured marks unsigned or mis-signed requests as failed verification, so they never reach your service.
One secret for the whole workspace
The Problem: Rotating the secret breaks every receiver at once.
Why It Happens: The HMAC secret is global. It signs every webhook target and every customer card request in the workspace, with no per-target secrets and no documented overlap period when you regenerate it.
Workarounds:
- Keep the secret in one secrets manager entry that every receiver reads.
- Rotate during a quiet period and update all consumers together.
How Hookdeck Can Help: With Hookdeck in front, only the Plain source holds the secret for webhooks, so a rotation means updating one field.
Repeated and out-of-order deliveries
The Problem: The same thread event is processed twice, or an older status overwrites a newer one.
Why It Happens: Delivery is at least once, and retries continue for about five days on a schedule of 10 seconds, 30 seconds, 5 minutes, 30 minutes, 1 hour, 3 hours, 6 hours, 12 hours and then daily. A retried event can land long after later ones.
Workarounds:
- Deduplicate on
id, or thePlain-Event-Idheader, which stay the same across attempts. - Compare
timestampwith what you have stored before applying a state change, or re-read the thread from the API.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries on Plain-Event-Id, and retries to your service are under your control.
Versions change payloads
The Problem: A field your code relies on changes shape, or an event you expect never arrives.
Why It Happens: Each target is pinned to a schema version. New versions add events and change definitions, and older targets don't receive some of them. For example, only 2026-10-06 targets receive account events.
Workarounds:
- Read
Plain-Webhook-Target-Versionon each request and log it. - Test against a new version on a second target before switching your main one.
How Hookdeck Can Help: Hookdeck filters can route requests by version header, so you can send a new version to a staging service while production stays on the old one.
Best practices
Verify against the raw body
The signature covers the exact bytes. Use express.raw({ type: '*/*' }) in Express, await request.text() in a Next.js route handler and await request.body() in FastAPI, and parse only after verification.
Never redirect
Plain treats redirects as failures. Point the target at the final URL, including any trailing slash your framework expects.
Route on type, and expect new events
Switch on type and log anything you don't recognise. Plain adds events in new schema versions.
Acknowledge fast and process asynchronously
Verify, store, return 200, and do the work afterwards, particularly for auto-responders that call a model or the Plain API. See why to process webhooks asynchronously and our guide to webhook idempotency.
Conclusion
Plain signs webhooks with Plain-Request-Signature, a hex HMAC-SHA256 of the raw body, but only after an admin generates the workspace secret. Turn signing on first, verify the raw body rather than following the docs' re-serialising snippet, deduplicate on the event id, and pin each target to a version you have tested.
Hookdeck Event Gateway sits in front of your receiver, checks every Plain signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Plain webhooks reliably in minutes.