Webhooks
Have GPS call your HTTPS endpoint when Goals, Objectives or Actions change, and verify each delivery's signature.
A webhook tells your system, within seconds, that a Goal, Objective or Action was created, updated or deleted. GPS sends a signed POST request with a JSON body to an HTTPS URL you register.
Register an endpoint#
You need the Owner, Super Admin or Admin role. Webhooks are managed in GPS only; there's no API for them.
- In GPS, open Settings → Developer → Keys & Webhooks.
- Under Webhooks, select Add Webhook.
- Fill in:
- Name (required, up to 100 characters)
- Endpoint URL (required). It must use
https://and resolve to a public address; private, loopback, link-local and cloud metadata addresses are refused. - Description (optional, up to 500 characters)
- Events to subscribe (at least one). See Events.
- Select Register Webhook.
- Copy the signing secret shown. It's displayed only once, and you need it to verify signatures.
From the webhook's row you can then:
- Send test ping: deliver a
pingevent now and see the request and your endpoint's response. - Edit: change the name, URL, description or events. The signing secret stays the same.
- Switch it active or inactive. Inactive webhooks receive no events.
- Delivery log: see recent delivery attempts, with the HTTP status your endpoint returned.
- Delete it, along with its delivery history.
To rotate a signing secret, register a new webhook for the same URL, update your endpoint to accept the new secret, then delete the old webhook.
Events#
| Event | Sent when | data holds |
|---|---|---|
goal.created |
A Goal is created. | The Goal |
goal.updated |
A Goal is updated. | The Goal |
goal.deleted |
A Goal is deleted. | { "id": "<goal id>" } |
objective.created |
An Objective is created. | The Objective |
objective.updated |
An Objective is updated. | The Objective |
objective.deleted |
An Objective is deleted. | { "id": "<objective id>" } |
action.created |
An Action is created. | The Action |
action.updated |
An Action is updated. | The Action |
action.deleted |
An Action is deleted. | { "id": "<action id>" } |
ping |
You select Send test ping. | { "message": "Ping from Empiraa GPS Webhooks — connection verified" } |
The Goal, Objective and Action in data have the same fields the API returns: see the Goal object, the Objective object and the Action object.
What fires an event#
- REST API changes always fire: creating, updating or deleting a Goal, Objective or Action through the API. A
PATCHthat changes nothing fires nothing. - Some changes made in the GPS app fire too, for example deleting a Goal, Objective or Action, recording a check-in on an Objective (
objective.updated), and bulk changes to Actions. Not every in-app edit sends an event yet, so don't treat webhooks as a complete record of in-app changes. - Not covered: Meetings, Ideas, comments, members and the organization profile have no events. A check-in recorded through the API (
POST /objectives/{objectiveId}/progress) doesn't fireobjective.updated. - Cascading deletes send one event. Deleting a Goal sends
goal.deletedonly, even though its Objectives and Actions are deleted with it; the same goes for an Objective's sub-Objectives and Actions.
The delivery#
GPS sends one POST per event to each active webhook subscribed to that event.
Headers
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Empiraa-Event |
The event name, for example goal.updated |
X-Empiraa-Signature |
sha256= followed by the hex HMAC-SHA256 of the raw body |
User-Agent |
Empiraa-Webhooks/1.0 |
Body
| Field | Type | Notes |
|---|---|---|
id |
string | Unique ID for this event. Retries of the same event reuse it. |
event |
string | The event name. |
organizationId |
string | Your organization's ID. |
createdAt |
string | When the event was raised, ISO 8601 UTC. |
data |
object | See Events. |
{
"id": "0d6f1c7e-3b2a-4e59-9f8d-6a1b2c3d4e5f",
"event": "action.updated",
"organizationId": "9b2f4e1a-6c3d-4e8f-a1b2-c3d4e5f6a7b8",
"createdAt": "2026-11-12T03:25:00.214Z",
"data": {
"id": "e5a7c9e1-6f8b-4c2d-8e9f-3a4b5c6d7e8f",
"title": "Send renewal offers to the top 20 clients",
"description": null,
"notes": null,
"objectiveId": "c7e9a1b3-4d5f-4a6b-9c8d-1e2f3a4b5c6d",
"goalId": null,
"assignees": ["5d7f9b1d-3e5a-4c7e-9f1a-2b3c4d5e6f70"],
"priority": "High",
"status": "completed",
"startDate": null,
"dueDate": "2026-11-28T00:00:00.000Z",
"completedDate": "2026-11-12T00:00:00.000Z",
"reminderDate": null,
"reminderFrequency": null,
"reminderSent": false,
"emailReminder": false,
"appReminder": true,
"isRepeatable": false,
"repeatFrequency": null,
"repeatDay": null,
"isPrivate": false,
"createdBy": "5d7f9b1d-3e5a-4c7e-9f1a-2b3c4d5e6f70",
"sourceMeetingId": null,
"portalOriginMasterUserId": null,
"positionInSet": null,
"createdAt": "2026-11-12T03:17:30.004Z",
"updatedAt": "2026-11-12T03:17:30.004Z"
}
}
A deleted event:
{
"id": "5a1e9c3b-7d2f-4b8a-a6c4-1e0f9d8c7b6a",
"event": "goal.deleted",
"organizationId": "9b2f4e1a-6c3d-4e8f-a1b2-c3d4e5f6a7b8",
"createdAt": "2026-11-12T03:30:12.877Z",
"data": { "id": "a1f3c5e7-2b4d-4f6a-8c9e-0b1d3f5a7c9e" }
}
Verify signatures#
Every delivery is signed with your webhook's signing secret. Compute an HMAC-SHA256 of the raw request body (the exact bytes received, before any JSON parsing) using the signing secret as the key, hex-encode it, prefix sha256=, and compare it with X-Empiraa-Signature in constant time.
const crypto = require('crypto');
const express = require('express');
const app = express();
const SIGNING_SECRET = process.env.GPS_WEBHOOK_SECRET;
function isValidSignature(rawBody, header) {
if (typeof header !== 'string') return false;
const expected = 'sha256=' + crypto.createHmac('sha256', SIGNING_SECRET).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps the body as a Buffer so the signature is checked on the exact bytes sent.
app.post('/webhooks/gps', express.raw({ type: 'application/json' }), (req, res) => {
if (!isValidSignature(req.body, req.get('X-Empiraa-Signature'))) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // acknowledge first, then process
queueForProcessing(event); // your own job queue or worker
});
app.listen(3000);
The signature covers the body only; there's no signed timestamp. To guard against replays, record each event id you've processed and ignore repeats, and consider rejecting events whose createdAt is far in the past.
Responses, timeouts and retries#
- Return any 2xx status within 10 seconds to acknowledge a delivery.
- Anything else counts as a failure: another status code (including 4xx), a timeout, or a connection error.
- GPS makes up to 3 attempts per event, waiting 1 second before the second and 2 seconds before the third. After the third failure the event is dropped and isn't sent again.
- Every attempt appears in the webhook's Delivery log, with the status your endpoint returned and the start of its response body.
- Deliveries can arrive out of order, and the same event can arrive more than once.
Because a failed event isn't redelivered later, treat webhooks as a prompt to sync rather than the only copy of a change: after downtime, read the current state with the REST API.
Best practices#
- Verify every signature before trusting a payload.
- Acknowledge fast. Return
200straight away and do slow work in the background, well inside the 10-second limit. - Deduplicate on
id. A retry after a slow or lost response sends the same event again. - Don't rely on order. If two updates to one record arrive out of order, compare
updatedAtor fetch the record from the API. - Re-read when it matters. For
deletedevents, and whenever you need the latest state, fetch from the API rather than trusting the payload alone. - Reconcile periodically. Because failed events aren't redelivered, run an occasional sync against the API (for example nightly).
- Use one webhook per destination, subscribed only to the events it needs.
- Keep the signing secret server-side, and rotate it by registering a new webhook if it leaks.

