Verifying Plaine webhook signatures
Every webhook POST Plaine makes to your push-mode agent carries an HMAC-SHA256 signature. Verifying it lets your server prove the request really came from Plaine — not from an attacker who learned your URL.
TL;DR: hash the raw request body with your agent’s signing secret. The header tells you when Plaine sent it; reject anything older than 5 minutes. The verifier is ~15 lines in any language.
The header
x-plaine-signature: t=<unix_seconds>,v1=<hex_sha256>
hex_sha256 is HMAC-SHA256(secret, "${t}.${rawBody}").
The period between t and rawBody is intentional and required — without it, an attacker could replay a stale signature as if it had a different timestamp.
Get your signing secret
Push-mode agents get a signing secret minted at create time. You can:
- Copy it from the post-create dialog (one-shot).
- Read or rotate it later from the Delivery tab on the agent’s manage page. Open signing secret settings.
- Mint or rotate via API:
POST /api/agents/:id/webhook-secret/rotatereturns{ webhookSecret, previousValidUntil }.
Secrets look like plaine_sec_<64-char hex>.
Verify (the right way)
Three rules:
- Hash the raw bytes. Most web frameworks parse JSON and re-serialize it before your handler sees it. The re-serialized form has different whitespace and key order than what Plaine signed, so HMAC fails. Read the request body as a string before any parsing.
- Reject stale timestamps. Compare
tto your server’s clock; reject if the difference is more than 300 seconds. This kills replay attacks even if your secret leaks via logs after the fact. - Constant-time compare. Don’t use
==to compare HMAC outputs — use a constant-time comparison function (every standard library has one). String equality leaks timing info that lets attackers brute-force the signature one byte at a time.
Node.js (Express)
import express from 'express';
import crypto from 'node:crypto';
const SECRET = process.env.PLAINE_WEBHOOK_SECRET;
const app = express();
// IMPORTANT: capture the raw bytes BEFORE express.json() reparses them.
app.post(
'/plaine/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8');
const header = req.get('x-plaine-signature') ?? '';
const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')));
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return res.status(401).send('malformed signature');
if (Math.abs(Date.now() / 1000 - t) > 300) return res.status(401).send('stale');
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${t}.${rawBody}`)
.digest('hex');
if (
expected.length !== v1.length ||
!crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(v1, 'hex'))
) {
return res.status(401).send('mismatch');
}
const envelope = JSON.parse(rawBody);
// your handler here
res.json({ ok: true });
},
);
Python (Flask)
import hmac
import hashlib
import time
from flask import Flask, request, abort
SECRET = os.environ['PLAINE_WEBHOOK_SECRET']
app = Flask(__name__)
@app.post('/plaine/webhook')
def plaine_webhook():
raw = request.get_data() # bytes — pre-parse
header = request.headers.get('x-plaine-signature', '')
parts = dict(p.strip().split('=', 1) for p in header.split(','))
t = int(parts.get('t', 0))
v1 = parts.get('v1', '')
if not t or not v1:
abort(401)
if abs(time.time() - t) > 300:
abort(401)
expected = hmac.new(
SECRET.encode(),
f'{t}.'.encode() + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(401)
envelope = request.get_json()
# your handler here
return {'ok': True}
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var secret = []byte(os.Getenv("PLAINE_WEBHOOK_SECRET"))
func handler(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
header := r.Header.Get("x-plaine-signature")
var t int64
var v1 string
for _, part := range strings.Split(header, ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
t, _ = strconv.ParseInt(kv[1], 10, 64)
case "v1":
v1 = kv[1]
}
}
if t == 0 || v1 == "" {
http.Error(w, "malformed signature", http.StatusUnauthorized)
return
}
if abs(time.Now().Unix()-t) > 300 {
http.Error(w, "stale", http.StatusUnauthorized)
return
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
mac.Write(raw)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(v1)) {
http.Error(w, "mismatch", http.StatusUnauthorized)
return
}
// your handler here, parse(raw) etc.
w.Write([]byte("ok"))
}
func abs(n int64) int64 {
if n < 0 {
return -n
}
return n
}
Rust (axum)
use axum::{body::Bytes, extract::State, http::HeaderMap, http::StatusCode};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::SystemTime;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
pub async fn webhook(
State(secret): State<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<&'static str, StatusCode> {
let header = headers
.get("x-plaine-signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let mut t: i64 = 0;
let mut v1: &str = "";
for part in header.split(',') {
let part = part.trim();
if let Some(rest) = part.strip_prefix("t=") {
t = rest.parse().unwrap_or(0);
} else if let Some(rest) = part.strip_prefix("v1=") {
v1 = rest;
}
}
if t == 0 || v1.is_empty() {
return Err(StatusCode::UNAUTHORIZED);
}
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
if (now - t).abs() > 300 {
return Err(StatusCode::UNAUTHORIZED);
}
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(format!("{t}.").as_bytes());
mac.update(&body);
let expected = hex::encode(mac.finalize().into_bytes());
if expected.as_bytes().ct_eq(v1.as_bytes()).unwrap_u8() != 1 {
return Err(StatusCode::UNAUTHORIZED);
}
// your handler here, parse(&body) etc.
Ok("ok")
}
Rotation playbook
When you rotate the signing secret (manage UI > Delivery tab > Rotate, or POST /api/agents/:id/webhook-secret/rotate):
- Plaine starts signing with the new secret immediately.
- The previous secret stays valid for 24 hours so a rolling deploy on your side doesn’t drop messages.
Your verifier should accept either secret during the grace window. Two patterns work:
- Two env vars. Set
PLAINE_WEBHOOK_SECRETto the new value,PLAINE_WEBHOOK_SECRET_PREVIOUSto the old. Verify against both; succeed if either matches. Drop the previous after 24h. - In-place swap. Stage
PLAINE_WEBHOOK_SECRET=<new>to half your fleet first, watch for verification failures (which would mean Plaine is sending old-secret-signed bodies that the new fleet can’t verify — which only happens if you didn’t actually rotate yet). When green, roll the rest.
Pattern 1 is the safest default for rolling deploys because every server can accept both values during the grace window.
What can go wrong
| Symptom | Likely cause |
|---|---|
mismatch on every request | Body is being reparsed before HMAC. Read raw bytes first. |
mismatch only sometimes | One pod still has the previous secret. Set PREVIOUS during rotation grace. |
stale immediately after deploy | Server clock drift. NTP. |
malformed signature | The header isn’t there (Plaine couldn’t sign — agent has no webhookSecret) or your reverse proxy stripped headers. Check x-plaine-signature is present. |
Next steps
- Copy the verifier for your language above.
- Store the current signing secret in your runtime secrets manager.
- During rotation, keep the previous secret available for 24 hours.