Webhooks

Webhooks Guide

3PM Accounts tells your app the moment something changes for it — a plan, a person's access, AI credits — with a signed HTTPS message. No more asking “did anything change?” on a timer.

Overview

Each app registers one or more endpoints (URLs) and picks the events it wants. When one of those events happens, 3PM Accounts saves it, then POSTs a JSON message to every endpoint that asked for it. If your app is down, the message is tried again for up to 3 days.

Signed

Every message carries an HMAC-SHA256 signature made with the endpoint's own secret.

Retried

12 tries over 3 days if your app doesn't answer 2xx. Nothing is lost.

Logged

Admins see every message and every try, and can send any one again.

Scoped

An app only ever receives events about itself.

How it works

1Something changes in 3PM Accounts (e.g. an organisation moves to the Pro plan).
23PM Accounts saves the event, so it can never be lost.
3It POSTs the event to each of your endpoints that chose it, signed with that endpoint's secret.
4Your app checks the signature, answers 2xx, then handles the change.
5No 2xx? It is tried again later — see Responses & Retries.

Add an Endpoint

A global admin sets this up in the 3PM Accounts dashboard:

1Open Applications → your app → the Webhooks tab → Add endpoint.
2Enter the URL your app listens on (must be https://) and tick the events it needs.
3Copy the signing secret (whsec_…). It is shown once.
4Put it in your app's server environment, e.g. THREEPM_WEBHOOK_SECRET.
5Press Test — a webhook.test message is sent and the result shown right away.
Lost the secret? Press Regenerate: a new secret is shown once and the old one stops working immediately. Update your app quickly — messages that fail in between are retried, so nothing is lost.

Each endpoint has its own secret, so production and staging can use different ones. An app can have up to 10 endpoints.

Message Format

Every message is an HTTPS POST with a JSON body and these headers:

FieldTypeMeaning
3PM-Signaturestringt=<unix seconds>,v1=<hex HMAC-SHA256> — see Verify the Signature.
3PM-Event-Idstringevt_… — the same event always has the same id. Use it to drop duplicates.
3PM-Event-Typestringe.g. subscription.plan_changed
3PM-Webhook-Idstringwhk_… — which of your endpoints this is for.
3PM-Delivery-Idstringdlv_… — this event to this endpoint (same across retries).
3PM-Attemptnumber1 for the first try, 2 for the first retry, …
Content-Typestringapplication/json

The body has the same shape for every event:

bodyjson
{
"id": "evt_6ad99e8d88a7b7fc68abd161",
"type": "subscription.plan_changed",
"apiVersion": "2026-11-01",
"createdAt": "2026-10-22T05:26:37.250Z",
"clientId": "3pm_099c0cb053a73317",
"tenantId": "6a2fd9d87ea2db29cba8cbdd",
"actor": { "type": "admin", "id": "6a16909b9e3f70adecf42b30" },
"data": {
"object": { "...": "the thing as it is now" },
"previous": { "...": "what it was before (only on some events)" }
}
}
FieldTypeMeaning
idstringEvent id (evt_…), same as the 3PM-Event-Id header.
typestringEvent type — see Events.
apiVersionstringPayload version. Fields are only ever added within a version.
createdAtISO dateWhen the change happened (not when this try was sent).
clientIdstringYour app's client id.
tenantIdstring | nullThe organisation the change is about.
actorobject | nullWho caused it: type user, admin, system or api_key, plus their id.
data.objectobjectThe current state — see Payload Reference.
data.previousobjectEarlier values, on subscription changes.

Verify the Signature

Anyone on the internet can POST to your URL. Only accept a message whose signature you can reproduce with your secret:

1Read the raw request body, before any JSON parsing.
2From 3PM-Signature take t (timestamp) and v1 (signature).
3Compute HMAC-SHA256(secret, `${t}.${rawBody}`) as hex and compare it with v1 in constant time.
4Reject it if t is more than 5 minutes from now (stops replays).
verify-3pm-webhook.tstypescript
import crypto from "crypto";
const TOLERANCE_SECONDS = 300;
export function verify3pmWebhook(rawBody: string, header: string | null, secret: string): boolean {
if (!header) return false;
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=", 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`, "utf8").digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(parts.v1, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
return Math.abs(Date.now() / 1000 - t) <= TOLERANCE_SECONDS;
}

Express

routes/3pm-webhooks.tstypescript
import express from "express";
import { verify3pmWebhook } from "./verify-3pm-webhook";
const router = express.Router();
// express.raw — the signature is over the exact bytes we were sent.
router.post("/webhooks/3pm", express.raw({ type: "application/json" }), async (req, res) => {
const raw = req.body.toString("utf8");
if (!verify3pmWebhook(raw, req.header("3PM-Signature") ?? null, process.env.THREEPM_WEBHOOK_SECRET!)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(raw);
// Same event can arrive twice: remember the ids you have handled.
if (await alreadyHandled(event.id)) return res.sendStatus(200);
await saveForProcessing(event); // store it (quick), handle it in the background
res.sendStatus(200); // answer within 10 seconds
});
export default router;

Next.js (App Router)

app/api/webhooks/3pm/route.tstypescript
import { verify3pmWebhook } from "@/lib/verify-3pm-webhook";
export async function POST(request: Request) {
const raw = await request.text(); // raw body — don't use request.json() first
if (!verify3pmWebhook(raw, request.headers.get("3PM-Signature"), process.env.THREEPM_WEBHOOK_SECRET!)) {
return new Response("Invalid signature", { status: 400 });
}
const event = JSON.parse(raw);
// …de-duplicate by event.id, save, then:
return new Response(null, { status: 200 });
}
The secret is never sent in a message — only the signature. Keep it on your server, never in a browser or mobile app.

Events

Pick these per endpoint (or All events, which also includes ones added later). An event is only sent to the app it is about.

Subscriptions

subscription.createdAn organisation subscribed to the app (including auto-subscribe and trial start). Sent
subscription.plan_changedThe organisation's plan for the app changed. Sent
subscription.cancelledThe app was cancelled for the organisation. Sent
subscription.resumedA cancelled app was resumed or bought again. Sent
subscription.trial_extendedAn admin extended or moved the trial end date. Sent
subscription.trial_endedThe trial end date passed. Sent

Seats

seat.grantedA member got access to the app (app seat, 3PM One, Field Pass or external pass). Sent
seat.revokedA member lost access to the app. Sent

Credits

credits.purchasedA credit pack was booked into the app's wallet. Sent
credits.grantedCredits were granted to the app's wallet through the Data API. Sent

Members

member.addedA person joined the organisation. Coming soon
member.removedA person was removed from the organisation. Coming soon
member.role_changedA member's organisation role changed. Coming soon
invitation.acceptedA person accepted an invitation that included the app. Coming soon

Users

user.updatedA user's name, email, mobile or photo changed. Coming soon
user.disabledA user account was disabled or deleted. Coming soon
user.restoredA disabled or deleted user account was restored. Coming soon

Organisations

tenant.updatedAn organisation's details (e.g. name) changed. Coming soon
tenant.owner_changedThe organisation's owner changed: the new owner and the former owner (now an admin). Apps must move their own owner role to the new owner. Coming soon

subscription.trial_ended comes from a once-a-day check (after 15:00 UTC by default), so it can arrive up to a day after the trial ended — access itself ends on time. webhook.test is sent only when an admin presses Test. “Coming soon” events can already be ticked; they start arriving when they ship.

Payload Reference

Subscriptions

subscription.created, .plan_changed, .cancelled, .resumed and .trial_extended all carry the subscription as it is now, plus previous (except created). One change can send two events, e.g. buying again after cancelling sends resumed and plan_changed.

subscription.plan_changed → datajson
{
"object": {
"tenantId": "6a2fd9d87ea2db29cba8cbdd",
"clientId": "3pm_099c0cb053a73317",
"status": "active",
"planId": "pro",
"planName": "Pro",
"effectivePlanId": "pro",
"effectivePlanName": "Pro",
"onTrial": false,
"trialPlanId": null,
"trialEndsAt": null,
"seats": 2,
"billing": "monthly",
"bundle": null,
"subscribedAt": "2026-10-22T05:26:09.071Z",
"cancelledAt": null
},
"previous": {
"status": "active",
"planId": "free",
"effectivePlanId": "pro",
"effectivePlanName": "Pro",
"trialEndsAt": "2026-11-01T05:26:34.692Z"
}
}
FieldTypeMeaning
statusactive | cancelled | suspendedcancelled = the organisation unsubscribed (it can resume or buy again).
planId / planNamestringThe plan bought.
effectivePlanId / NamestringThe plan in force now — the trial plan while a trial runs. Use this for limits.
onTrial, trialEndsAtboolean, ISO | nullTrial running, and when it ends.
seatsnumberSeats bought (3PM One rows show 0 — their seats are on the bundle).
bundle"one" | null"one" when the app comes with 3PM One.
plan_changed is sent when the plan in force changes, when the plan bought changes, and when a running trial is bought (same plan, now paid — onTrial goes from true to false).

Seats

One event per person per app. Removing a person, cancelling the app for an organisation or deleting an account sends one seat.revoked for each seat it took away.

seat.granted / seat.revoked → datajson
{
"object": {
"tenantId": "6a2fd9d87ea2db29cba8cbdd",
"clientId": "3pm_099c0cb053a73317",
"userId": "6a2fd91c7ea2db29cba8cbd4",
"via": "app"
}
}
FieldTypeMeaning
userIdstringThe 3PM Accounts user id of the person.
viaapp | one | field-pass | externalKind of seat: an app seat, 3PM One, a Field Pass, or a subcontractor's pass.

Credits

Sent when credits are added to your app's wallet for an organisation. Spends, refunds and the monthly allowance send nothing — read the balance from the Data API when you need it.

credits.purchased → datajson
{
"object": {
"tenantId": "6a2fd9d87ea2db29cba8cbdd",
"clientId": "3pm_099c0cb053a73317",
"amount": 1500,
"bucket": "purchased",
"balance": 101500,
"description": "Top up pack × 3",
"packId": "topup",
"packName": "Top up",
"quantity": 3,
"orderId": "876a1e8e-b326-4787-b32e-ec41b1b3ba57"
}
}
credits.granted → datajson
{
"object": {
"tenantId": "6a2fd9d87ea2db29cba8cbdd",
"clientId": "3pm_099c0cb053a73317",
"amount": 100,
"bucket": "bonus",
"balance": 101600,
"description": "Welcome bonus",
"idempotencyKey": "my-app-grant-0001"
}
}

credits.granted is sent once per real grant — repeating a Data API grant with the same idempotencyKey adds nothing and sends nothing.

Responses & Retries

FieldTypeMeaning
2xxdeliveredDone. Answer within 10 seconds — do slow work after answering.
410 GonestopThis URL is retired: the endpoint is switched off and admins are emailed.
other 4xx / 5xxretryTried again later. 429 and 503 may send Retry-After (honoured, up to 6 hours).
timeout / networkretryNo answer in 10 s, DNS or connection errors.
3xx redirectretryRedirects are not followed — register the final URL.

Retries happen at fixed points after the event:

try: 1 2 3 4 5 6 7 8 9 10 11 12
at: now 1m 5m 15m 1h 3h 6h 12h 24h 36h 48h 72h

After the last try the message is marked failed — it stays in the delivery history and an admin can send it again. If every message to an endpoint fails for 3 days, the endpoint is switched off automatically and the global admins get an email. Once your app is fixed, switch it back on and press Retry all failed in its delivery history.

Duplicates & Order

  • At least once: a message can arrive more than once (for example when your app answered after our 10-second limit). Keep the 3PM-Event-Ids you have handled and skip repeats.
  • No guaranteed order: a retry can arrive after a newer event. Use createdAt, and when order matters re-read the current state (e.g. the entitlements or Data API endpoints) instead of trusting the message alone.
  • Answer first, work after: store the event, answer 2xx, then process it in the background.

Testing Locally

  • On a developer machine, endpoints may use http://localhost (or 127.0.0.1). In production only https:// is accepted.
  • Press Test on the endpoint to send a signed webhook.test and see the answer straight away.
  • Open the endpoint's History to see every message, its body and every try, and to Send again.
webhook.test → datajson
{
"object": {
"message": "This is a test event from 3PM Auth. If you can read it, your endpoint works.",
"endpointId": "whk_6ad8aaf39f0974ed9a0d9780"
}
}

Security Checklist

Verify the signature on every message, using the raw body.
Reject messages more than 5 minutes old.
Keep the signing secret in server-side environment variables only.
Use https:// (required in production); private network addresses are never called.
De-duplicate by 3PM-Event-Id.
Answer 2xx within 10 seconds; process in the background.
If a secret may have leaked, press Regenerate — the old one stops at once.

Webhooks Documentation - October 2026