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
Add an Endpoint
A global admin sets this up in the 3PM Accounts dashboard:
https://) and tick the events it needs.whsec_…). It is shown once.THREEPM_WEBHOOK_SECRET.webhook.test message is sent and the result shown right away.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:
| Field | Type | Meaning |
|---|---|---|
| 3PM-Signature | string | t=<unix seconds>,v1=<hex HMAC-SHA256> — see Verify the Signature. |
| 3PM-Event-Id | string | evt_… — the same event always has the same id. Use it to drop duplicates. |
| 3PM-Event-Type | string | e.g. subscription.plan_changed |
| 3PM-Webhook-Id | string | whk_… — which of your endpoints this is for. |
| 3PM-Delivery-Id | string | dlv_… — this event to this endpoint (same across retries). |
| 3PM-Attempt | number | 1 for the first try, 2 for the first retry, … |
| Content-Type | string | application/json |
The body has the same shape for every event:
{"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)" }}}
| Field | Type | Meaning |
|---|---|---|
| id | string | Event id (evt_…), same as the 3PM-Event-Id header. |
| type | string | Event type — see Events. |
| apiVersion | string | Payload version. Fields are only ever added within a version. |
| createdAt | ISO date | When the change happened (not when this try was sent). |
| clientId | string | Your app's client id. |
| tenantId | string | null | The organisation the change is about. |
| actor | object | null | Who caused it: type user, admin, system or api_key, plus their id. |
| data.object | object | The current state — see Payload Reference. |
| data.previous | object | Earlier 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:
3PM-Signature take t (timestamp) and v1 (signature).HMAC-SHA256(secret, `${t}.${rawBody}`) as hex and compare it with v1 in constant time.t is more than 5 minutes from now (stops replays).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
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 backgroundres.sendStatus(200); // answer within 10 seconds});export default router;
Next.js (App Router)
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() firstif (!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 });}
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.created | An organisation subscribed to the app (including auto-subscribe and trial start). | Sent |
| subscription.plan_changed | The organisation's plan for the app changed. | Sent |
| subscription.cancelled | The app was cancelled for the organisation. | Sent |
| subscription.resumed | A cancelled app was resumed or bought again. | Sent |
| subscription.trial_extended | An admin extended or moved the trial end date. | Sent |
| subscription.trial_ended | The trial end date passed. | Sent |
Seats
| seat.granted | A member got access to the app (app seat, 3PM One, Field Pass or external pass). | Sent |
| seat.revoked | A member lost access to the app. | Sent |
Credits
| credits.purchased | A credit pack was booked into the app's wallet. | Sent |
| credits.granted | Credits were granted to the app's wallet through the Data API. | Sent |
Members
| member.added | A person joined the organisation. | Coming soon |
| member.removed | A person was removed from the organisation. | Coming soon |
| member.role_changed | A member's organisation role changed. | Coming soon |
| invitation.accepted | A person accepted an invitation that included the app. | Coming soon |
Users
| user.updated | A user's name, email, mobile or photo changed. | Coming soon |
| user.disabled | A user account was disabled or deleted. | Coming soon |
| user.restored | A disabled or deleted user account was restored. | Coming soon |
Organisations
| tenant.updated | An organisation's details (e.g. name) changed. | Coming soon |
| tenant.owner_changed | The 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.
{"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"}}
| Field | Type | Meaning |
|---|---|---|
| status | active | cancelled | suspended | cancelled = the organisation unsubscribed (it can resume or buy again). |
| planId / planName | string | The plan bought. |
| effectivePlanId / Name | string | The plan in force now — the trial plan while a trial runs. Use this for limits. |
| onTrial, trialEndsAt | boolean, ISO | null | Trial running, and when it ends. |
| seats | number | Seats bought (3PM One rows show 0 — their seats are on the bundle). |
| bundle | "one" | null | "one" when the app comes with 3PM One. |
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.
{"object": {"tenantId": "6a2fd9d87ea2db29cba8cbdd","clientId": "3pm_099c0cb053a73317","userId": "6a2fd91c7ea2db29cba8cbd4","via": "app"}}
| Field | Type | Meaning |
|---|---|---|
| userId | string | The 3PM Accounts user id of the person. |
| via | app | one | field-pass | external | Kind 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.
{"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"}}
{"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
| Field | Type | Meaning |
|---|---|---|
| 2xx | delivered | Done. Answer within 10 seconds — do slow work after answering. |
| 410 Gone | stop | This URL is retired: the endpoint is switched off and admins are emailed. |
| other 4xx / 5xx | retry | Tried again later. 429 and 503 may send Retry-After (honoured, up to 6 hours). |
| timeout / network | retry | No answer in 10 s, DNS or connection errors. |
| 3xx redirect | retry | Redirects 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 12at: 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(or127.0.0.1). In production onlyhttps://is accepted. - Press Test on the endpoint to send a signed
webhook.testand see the answer straight away. - Open the endpoint's History to see every message, its body and every try, and to Send again.
{"object": {"message": "This is a test event from 3PM Auth. If you can read it, your endpoint works.","endpointId": "whk_6ad8aaf39f0974ed9a0d9780"}}
Security Checklist
Webhooks Documentation - October 2026