Gareth Wilson Gareth Wilson

Guide to Plain Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationSettings > Webhooks > Add webhook target
Limit25 webhook targets per workspace
Events48 types across threads, discussions, customers, timelines, tasks and accounts
VersioningEach target is pinned to a schema version
Payload{ id, type, timestamp, workspaceId, payload, webhookMetadata }
Signature headerPlain-Request-Signature, once request signing is set up
SchemeHMAC-SHA256, hex, no prefix
Signed contentThe raw request body
SecretOne global HMAC secret per workspace, from Settings > Request signing
Extra authenticationOptional Basic auth in the target URL, or mTLS
SuccessAny 2xx; redirects count as failures
Retries12 retries over about 5 days
DeliveryAt 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.

AreaExamplesFires when
Thread lifecyclethread.thread_created, thread.thread_status_transitioned, thread.thread_assignment_transitioned, thread.thread_labels_changed, thread.thread_priority_changed, thread.thread_lockedA thread is created, changes status, assignee, labels or priority, or is locked
Messagesthread.email_received, thread.email_sent, thread.slack_message_received, thread.discord_message_received, thread.ms_teams_message_received, thread.chat_receivedA message arrives or is sent on any channel (with _sent and _updated variants)
Notes and fieldsthread.note_created, thread.note_mention_created, thread.thread_field_created, thread.thread_field_updated, thread.thread_field_deletedA note or mention is added, or a thread field changes
SLAs and tenantsthread.service_level_agreement_status_transitioned, thread.thread_tenant_updatedAn SLA changes state, or a thread's tenant changes
Discussionsdiscussion.discussion_created, discussion.message_created, discussion.tool_call_approval_requested, discussion.tool_call_approval_resolved, discussion.turn_stop_requestedDiscussion activity, including tool-call approvals
Customerscustomer.customer_created, customer.customer_updated, customer.customer_deleted, customer.customer_changed, customer.customer_group_changed, customer.customer_group_memberships_changedA customer or customer group changes
Taskstask.task_created, task.task_updated, task.task_status_transitioned, task.task_deletedA task changes
Accountsaccount.account_created, account.account_updated, account.account_deleted, account.account_membership_changed, account.account_field_created, account.account_field_updated, account.account_field_deletedAn account (formerly a tenant) or its fields or members change
Timelinetimeline.timeline_entry_changedA 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"
  }
}
  • id is the event ID. It stays the same across delivery attempts, so it is your idempotency key.
  • type is the event. Route on it.
  • payload holds the event-specific data. Thread events carry the full thread, and transition events add the previous state.
  • webhookMetadata repeats the delivery headers. webhookDeliveryAttemptId and webhookDeliveryAttemptTimestamp change 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

  1. As a workspace admin, go to Settings > Request signing and generate the HMAC secret. Store it server-side, for example as PLAIN_WEBHOOK_SECRET.
  2. Go to Settings > Webhooks and click + Add webhook target.
  3. Enter a name, the HTTPS URL of your endpoint and the events you want, and choose whether to enable it straight away.
  4. 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-Signature header 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-Signature rather 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 the Plain-Event-Id header, which stay the same across attempts.
  • Compare timestamp with 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-Version on 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.