Webhooks
Get notified when things happen in Protocol. Register an endpoint, choose events, verify the signature, and read the delivery log. Owner only.
Advanced Webhooks let Protocol push event notifications to your own server the moment something happens, instead of you polling the API for changes. Register an HTTPS endpoint, pick the events you care about, and Protocol will POST a signed JSON payload to it every time one of those events occurs.
Owner only. Only a team owner can see this screen and manage webhook endpoints. Invited team coaches don’t have access.
Where to find it
Open your profile menu (your avatar, top-right) → Integrations → the Webhooks tab.
Create an endpoint
Click Create endpoint, give it your URL, an optional description, and choose which events it should receive.
The URL must be https and publicly reachable. Protocol rejects URLs that resolve to private,
loopback, or cloud-metadata addresses (for example anything in 10.0.0.0/8, 192.168.0.0/16,
127.0.0.1, or 169.254.169.254), both when you create the endpoint and again before every
delivery. This is a safety check, not something you need to configure: it just means your endpoint
has to live on the public internet, not on a private network or a local machine.
Your signing secret
When you create an endpoint, Protocol shows you its signing secret exactly once.
Store it right away, next to wherever your endpoint’s other secrets live. You’ll use it to verify that a delivery really came from Protocol (see below).
Change an endpoint later
Click Edit on an endpoint to change its URL, its description, or which events it receives. This is how you subscribe an existing endpoint to an event type that was added after you set it up. Editing never changes the signing secret, so your existing verification code keeps working, and the delivery history stays intact.
Repointing an endpoint at a new URL also clears its failure count, so an endpoint that was close to being auto-disabled gets a clean slate once you have fixed it.
If the secret leaks, or you just want to rotate it, click Roll secret on the endpoint. Rolling generates a brand new secret and shows it once, the same way. It takes effect immediately: the old secret stops working right away, so update your verification code with the new one before (or right after) you roll it.
Which events you can subscribe to
Clients
| Event | What it means |
|---|---|
client.created |
A new client was added to your account. |
client.updated |
Something about an existing client changed. changed says what: client for their details, or health_profile, fitness_profile, behavioral_profile, nutrition_profile, genomic_profile when one of their profiles was edited. |
client.status.changed |
A client was paused or reactivated. Carries isActive and previousIsActive, so you can tell which way it moved. |
client.lifecycle_stage.changed |
A client moved to a different lifecycle stage (for example Lead to Active). Carries lifecycleStageId and previousLifecycleStageId; either can be null. |
Plans
| Event | What it means |
|---|---|
plan.assigned |
A training program or a meal plan was assigned to a client. planType is program or nutrition_template. |
plan.changed |
An assigned training program’s status, active flag, dates or sharing changed: made live, paused, expired, rescheduled. changed lists which fields moved, and previousStatus gives you the transition. Editing the plan’s contents does not fire this. Programs only, see the note below. |
Money
| Event | What it means |
|---|---|
purchase.succeeded |
A client completed a purchase, such as a package or a subscription payment. |
purchase.updated |
A purchase changed status: paused, resumed, cancelled, past due, or expired. Carries status and previousStatus. Fires alongside purchase.succeeded on the transition into active. |
Progress and reports
| Event | What it means |
|---|---|
progress_entry.created |
A client logged a new progress entry (weight, measurements, photos, and similar check-ins). |
progress_report.created |
A progress report was drafted. source is automation (an automation wrote it) or manual (you did). |
progress_report.updated |
A report was edited, approved or discarded. Approval is the moment it reaches the client: look for status: "APPROVED" with dispatchedToClient: true. |
Forms and check-ins
| Event | What it means |
|---|---|
form.submitted |
A form was submitted: an onboarding questionnaire, a survey, a weekly check-in, or a public lead form. specialPurpose tells you which (INITIAL_QUESTIONNAIRE, SURVEY, CHECK_IN, OTHER, or null), so you can route without fetching the form back. |
checkin.reminder_sent |
A client was reminded about a check-in. step is due (it just came due) or pre_due (the optional heads-up beforehand). |
checkin.missed |
A check-in was missed and a nudge went out. missCount is which one (1st, 2nd, …) and maxMisses is your configured limit. |
checkin.fully_missed |
The check-in ladder ran out. This is the one that means “this client has gone quiet.” |
Messaging
| Event | What it means |
|---|---|
message.received |
A client sent you a message. Coach-sent messages don’t fire this, so an integration that replies won’t trigger itself. |
Everything
| Event | What it means |
|---|---|
* |
Wildcard. Subscribe to every event type, including ones added after you set up the endpoint. |
Some events carry identifiers rather than full content. message.received tells you a client
wrote to you, in which conversation and when, but never the message text: chat threads routinely
carry health information, and we don’t widen where that travels. The checkin.* events work the
same way, telling you a check-in was missed without carrying what was answered. Read the message
or the submission back through the API with your key when you need the content.
One client.updated per surface means you can receive several in quick succession: onboarding
can fill a client’s health, fitness, nutrition and behavioural profiles in a single submission,
and that is four events, each naming a different changed. If you only care that something
about the client moved, treat them as one and refetch the client once.
Two more deliberate quiet spots. Biometric readings are not on this list: wearable sync writes
thousands of points a day per client, and a webhook feed is the wrong shape for that; pull them
from the API instead. And plan.changed covers training programs only, because meal plans have
no status or date window to change: once a meal plan is assigned to a client they can see it,
so plan.assigned is the whole story for those.
What a delivery looks like
Every delivery is an HTTP POST with a JSON body wrapping the event in an envelope:
{
"id": "evt_1a2b3c4d5e6f",
"type": "client.created",
"api_version": "2026-07-20",
"created": 1752998400,
"data": {
"object": {
"id": "cli_9f8e7d6c",
"name": "Jamie Rivera",
"email": "jamie@example.com"
}
}
}
ididentifies this event.typeis one of the event strings above.api_versionidentifies the shape of the payload. It’s fixed per event and won’t change under you; if Protocol ever changes the envelope shape, it ships under a new version.createdis a Unix timestamp (seconds) of when the event happened.data.objectis the affected resource.
Each request also carries four headers:
| Header | Contains |
|---|---|
Webhook-Signature |
t=<unix-timestamp>,v1=<hex-hmac>, used to verify the delivery. See below. |
Webhook-Event-Id |
The ID of the event, identical to the envelope’s id. This is the one to deduplicate on. |
Webhook-Id |
The ID of this particular delivery attempt chain. Useful for matching a request against a row in the delivery log when you contact support. |
Webhook-Event-Type |
The event type string, e.g. client.created, same as the envelope’s type. |
Verify the signature
Anyone can POST to your endpoint claiming to be Protocol, so verify the Webhook-Signature
header before trusting a delivery.
The header looks like t=1752998400,v1=5257a869e.... t is the Unix timestamp (seconds) the
request was sent, and v1 is an HMAC-SHA256 hex digest computed over the string "{t}.{raw body}"
using your endpoint’s signing secret.
You must use the raw, unparsed request body. Most frameworks parse JSON automatically before your handler runs, which changes whitespace and key order and breaks the signature. Read the body as a raw string (or raw bytes) for this route specifically, compute the signature from that, and only then parse it as JSON.
Recompute the digest yourself, compare it to v1 with a constant-time comparison, and check that
t isn’t too old (to reject replayed requests):
import { createHmac, timingSafeEqual } from 'node:crypto';
function isValidWebhookSignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
const timestamp = Number(parts.t);
if (!timestamp || !parts.v1) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const received = Buffer.from(parts.v1, 'hex');
const computed = Buffer.from(expected, 'hex');
if (received.length !== computed.length) return false;
if (!timingSafeEqual(received, computed)) return false;
const age = Math.abs(Date.now() / 1000 - timestamp);
return age <= toleranceSeconds;
}
A plain === string comparison leaks timing information about how many characters matched, which
is why timingSafeEqual matters here, not just correctness.
Retries and failures
Protocol treats any 2xx response as success. Anything else, a non-2xx status, a timeout, or a connection error, is treated as a failure and retried with a growing gap between attempts: the first retry comes about 5 seconds later, then 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, and 16 hours, for 8 attempts total spread over roughly 24 hours. Once an attempt succeeds, delivery stops.
Two things are worth knowing about the edges. Protocol does not follow redirects: a 301 or 302 counts as a failure, so register the final URL rather than one that redirects to it. And a URL that resolves to a private, loopback, or cloud-metadata address fails immediately with no retries, because no amount of waiting makes that address acceptable.
If an endpoint keeps failing across many deliveries, Protocol automatically disables it so a dead or misconfigured endpoint doesn’t keep accumulating failed attempts. A disabled endpoint stops receiving new deliveries. Fix whatever was wrong and click Enable on the endpoint (see Where to find it) to resume.
Handle duplicates
Deliveries are at-least-once, not exactly-once. A retry after a timeout, a network hiccup, or Protocol’s own crash-recovery can occasionally result in the same event reaching your endpoint more than once, even after it succeeded the first time.
Use the Webhook-Event-Id header (the same value as the envelope’s id) as an idempotency
key: before acting on a delivery, check whether you’ve already processed that ID, and if so,
return a 2xx and skip it. This is the single most common integration bug, and the fix is a lookup
in whatever store you already use (a database table, a cache, a set), keyed on Webhook-Event-Id.
Key on the event ID, not Webhook-Id. Webhook-Id identifies one delivery, and pressing
Resend in the delivery log creates a new delivery for the same event, so it arrives with the
same Webhook-Event-Id but a different Webhook-Id. An integration keyed on the wrong one would
process a resent event twice.
Don’t rely on the order they arrive in
Protocol sends queued deliveries in parallel, and a delivery that failed comes back later on its retry schedule. Two events raised a moment apart can therefore reach you in either order, and an event from an hour ago can land after one from a minute ago.
If order matters to you, sort on the envelope’s created timestamp rather than on arrival,
and treat an event older than the state you already hold as one to ignore. A coach who pauses a
plan and then reactivates it produces two plan.changed events; deciding from arrival order can
leave you with the plan marked paused when it is live.
Delivery log
Click the deliveries action on an endpoint to see its recent deliveries, filter by status, and inspect what was tried. Delivery history is kept for 30 days; a delivery still waiting to be retried is never removed, however old it is.
Related
- Creating & using API keys: create a key, authenticate a request, revoke a key. Webhooks and API keys are separate: a webhook pushes events to you, a key lets you pull data from the API.
- API rate limits & AI usage budget: the per-key request limits and the
429/402contract for calls you make into the API. - API reference: the full, always-current endpoint list for managing webhook endpoints and reading delivery logs.