Outbound Webhooks
LPM sends organization and personal events to your HTTPS endpoint. Each webhook has a unique 32-byte HMAC secret.
The dashboard displays the secret one time. Store the secret in your approved secret manager.
Request Headers
| Header | Content |
|---|---|
X-LPM-Event | The event name |
X-LPM-Timestamp | The send time as Unix seconds |
X-LPM-Delivery-Id | The stable ID for one logical delivery |
X-LPM-Attempt-Id | The unique ID for this send attempt |
X-LPM-Retry | 0 for the first attempt, then the retry number |
Idempotency-Key | The stable delivery ID |
X-LPM-Signature-Version | 2 |
X-LPM-Signature-V2 | v2= plus the lowercase HMAC-SHA256 value |
X-LPM-Signature contains the old body-only signature for compatibility. New endpoints must use X-LPM-Signature-V2.
Signature Message
Create the signature message from the exact header values and raw request body. Do not parse and reserialize the body.
lpm.webhook.v2
<timestamp>
<event>
<delivery-id>
<attempt-id>
<retry>
<raw-body>
Use a newline character after each field except the raw body. Calculate HMAC-SHA256 with the webhook secret.
Verification Procedure
- Read the raw request body.
- Require all version 2 headers.
- Reject a timestamp that differs from server time by more than five minutes.
- Create the signature message with the exact header values.
- Calculate the HMAC-SHA256 value.
- Compare the supplied and calculated values in constant time.
- Atomically reserve the attempt ID for at least five minutes.
- If the attempt ID already exists, reject the request.
- Use the delivery ID as the business idempotency key.
- Process the event only after all verification steps succeed.
Do not use the event name, retry number, or IDs before the version 2 signature is valid.
Node.js Example
import { createHmac, timingSafeEqual } from "node:crypto"
export function verifyLpmWebhook({ headers, rawBody, secret, now = Date.now() }) {
const version = headers.get("x-lpm-signature-version")
const signature = headers.get("x-lpm-signature-v2")
const timestamp = headers.get("x-lpm-timestamp")
const event = headers.get("x-lpm-event")
const deliveryId = headers.get("x-lpm-delivery-id")
const attemptId = headers.get("x-lpm-attempt-id")
const retry = headers.get("x-lpm-retry")
if (
version !== "2" ||
!signature?.startsWith("v2=") ||
!timestamp ||
!event ||
!deliveryId ||
!attemptId ||
!/^\d+$/.test(retry || "")
) {
return { valid: false, reason: "missing_headers" }
}
const timestampSeconds = Number(timestamp)
if (
!Number.isSafeInteger(timestampSeconds) ||
Math.abs(Math.floor(now / 1000) - timestampSeconds) > 300
) {
return { valid: false, reason: "timestamp_outside_window" }
}
const message = [
"lpm.webhook.v2",
timestamp,
event,
deliveryId,
attemptId,
retry,
rawBody,
].join("\n")
const supplied = Buffer.from(signature.slice(3), "hex")
const expected = createHmac("sha256", secret).update(message).digest()
if (supplied.length !== expected.length) {
return { valid: false, reason: "invalid_signature" }
}
if (!timingSafeEqual(supplied, expected)) {
return { valid: false, reason: "invalid_signature" }
}
return { valid: true, event, deliveryId, attemptId, retry: Number(retry) }
}
The example verifies the cryptographic envelope. Your service must also reserve attempt IDs in shared durable storage.
If multiple workers receive requests, an in-memory set does not prevent replays. Use a database or shared cache with an atomic insert.
Retry and Idempotency Rules
LPM keeps the delivery ID constant across retries. LPM creates a new attempt ID and timestamp for each retry.
Store the final result by delivery ID. If a later attempt uses the same delivery ID, return the stored result without repeating the side effect.
Keep attempt IDs for at least five minutes. Keep delivery IDs for the full period in which duplicate business actions cause harm.
See Also
- Integrations — Integration boundaries and entry points.
- Managing Members — Organization roles and access changes.
- Webhook Inspector — Inspect third-party webhooks during local development.