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_token | No credential in the header being read. |
invalid_signature | It did not verify. |
unknown_key | The kid is not in the JWKS. |
expired | Past exp. Tokens live five minutes. |
audience_mismatch | Minted for a different endpoint. |
body_hash_mismatch | The body is not the one that was signed. |
purpose_mismatch | An MCP signature at a hook endpoint, or the reverse. |
tenant_mismatch | expect 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.