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.

  1. In GPS, open Settings → Developer → Keys & Webhooks.
  2. Under Webhooks, select Add Webhook.
  3. 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.
  4. Select Register Webhook.
  5. 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 ping event 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 PATCH that 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 fire objective.updated.
  • Cascading deletes send one event. Deleting a Goal sends goal.deleted only, 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.
JSON
{
  "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:

JSON
{
  "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.

JavaScript
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 200 straight 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 updatedAt or fetch the record from the API.
  • Re-read when it matters. For deleted events, 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.