Plane docs
SDK and CLI

@plane/sdk/verify

Proof that an incoming request really came from Plane.

plane.handlers verifies for you. This subpath is for a route you wrote yourself — a webhook, a proxy, a middleware.

import { createPlaneVerifier, PlaneSignatureError } from "@plane/sdk/verify"

const verify = createPlaneVerifier({
  jwksUrl: `${process.env.PLANE_BASE_URL}/.well-known/plane/jwks.json`,
  audience: "https://app.example.com/api/plane/hooks",
  purpose: "hook",                       // "mcp" | "hook"
  expect: { org: "acme", env: "prod" },  // or env: ["prod", "staging"]
  header: "authorization"                // "x-plane-signature" on a signed oauth connection
})

export async function POST(request: Request) {
  try {
    const { claims, connection, session, body } = await verify(request)
    // `body` is the raw text, already read; `request` is still readable.
    return Response.json({ ok: true })
  } catch (error) {
    if (error instanceof PlaneSignatureError) {
      return Response.json({ reason: error.reason }, { status: 401 })
    }
    throw error
  }
}

Use createPlaneVerifier rather than the one-shot verifyPlaneRequest in a server: it holds the JWKS cache across requests, and a per-call verifier puts a JWKS fetch in front of every tool call.

error.reason
missing_tokenNo credential in the header being read.
invalid_signatureIt did not verify.
unknown_keyThe kid is not in the JWKS.
expiredPast exp. Tokens live five minutes.
audience_mismatchMinted for a different endpoint.
body_hash_mismatchThe body is not the one that was signed.
purpose_mismatchAn MCP signature at a hook endpoint, or the reverse.
tenant_mismatchexpect was set and the claim did not match — including when the claim is absent.

The claims, what each one is for, and why expect is stronger than a key set per environment are in MCP and request signing.