---
title: Webhooks
description: Have GPS call your HTTPS endpoint when Goals, Objectives or Actions change, and verify each delivery's signature.
product: gps
section: webhooks
order: 20
---

# Webhooks

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](#events).
4. Select **Register Webhook**.
5. Copy the **signing secret** shown. It's displayed only once, and you need it to [verify signatures](#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](reference/goals.md#the-goal-object), [the Objective object](reference/objectives.md#the-objective-object) and [the Action object](reference/actions.md#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](#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.

```js
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](index.md).

## 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.
