---
title: Integration examples
description: Ready-to-adapt code for connecting Signal and GPS to Slack, Microsoft Teams, Google Sheets, Zapier, Salesforce, Jira, GitHub and HubSpot.
product: all
section: use-cases
order: 7
---

# Integration examples

Working patterns for the integrations customers build most. Each one is checked against the API reference. Copy one, swap in your own IDs and secrets, and adapt it.

The GPS examples assume these headers:

```js
const GPS = "https://plan.empiraa.app/api/v1";
const gpsHeaders = {
  "X-API-Key": process.env.GPS_API_KEY_ID,
  "X-API-Secret": process.env.GPS_API_SECRET,
  "Content-Type": "application/json",
};
```

The Signal examples assume:

```js
const SIGNAL = "https://crm.empiraa.app/api/v1";
const signalHeaders = {
  Authorization: `Bearer ${process.env.SIGNAL_API_KEY}`,
  "Content-Type": "application/json",
};
```

## GPS to other tools

These start from a GPS webhook. Add the webhook in **Settings → Developer → Keys & Webhooks**. See [GPS webhooks](../gps/api/webhooks.md).

### Slack: post when an Objective changes

GPS sends `objective.updated`. Your endpoint checks the signature and posts to a Slack incoming webhook.

```js
const express = require("express");
const crypto = require("crypto");
const app = express();

function isValid(rawBody, header) {
  const expected = "sha256=" + crypto.createHmac("sha256", process.env.GPS_WEBHOOK_SECRET).update(rawBody).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(header || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/webhooks/gps", express.raw({ type: "application/json" }), async (req, res) => {
  if (!isValid(req.body, req.get("X-Empiraa-Signature"))) return res.status(401).send("Invalid signature");
  res.sendStatus(200); // answer first, then do the work

  const event = JSON.parse(req.body);
  if (event.event !== "objective.updated") return;
  const { title, progress, status } = event.data;
  await fetch(process.env.SLACK_WEBHOOK_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text: `*${title}* is now at ${progress}% (${status})` }),
  });
});
```

### Microsoft Teams: post when a Goal is created or changes

The same receiver, with a different destination. Listen for `goal.created` and `goal.updated`.

```js
const event = JSON.parse(req.body);
if (!["goal.created", "goal.updated"].includes(event.event)) return;

const { title, status } = event.data;
await fetch(process.env.TEAMS_WEBHOOK_URL, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    "@type": "MessageCard",
    summary: `Goal ${title}`,
    sections: [{ activityTitle: title, facts: [{ name: "Status", value: status }] }],
  }),
});
```

### Zapier: use GPS as a trigger, no code

1. In Zapier, create a Zap with the trigger **Webhooks by Zapier**, then **Catch Hook**. Copy the URL Zapier gives you.
2. In GPS, open **Settings → Developer → Keys & Webhooks** and add a webhook that points at that URL. Tick the events you want, such as `objective.updated`.
3. Send a test from GPS so Zapier catches a sample.
4. Add any Zapier action and map the fields: send an email, add a row, post a message.

### Google Sheets: export every Objective

Page through the list, then write the rows with the Google Sheets API.

```js
async function listAll(path) {
  const all = [];
  for (let offset = 0; ; offset += 200) {
    const res = await fetch(`${GPS}${path}?limit=200&offset=${offset}`, { headers: gpsHeaders });
    const { data, meta } = await res.json();
    all.push(...data);
    if (offset + data.length >= meta.total) return all;
  }
}

const objectives = await listAll("/objectives");
const rows = objectives.map((o) => [o.title, o.status, `${o.progress}%`, o.createdAt]);
// then write `rows` to your sheet with the Google Sheets API
```

## Other tools to GPS

To move a number in GPS, record a check-in. A check-in sets the Objective's value and GPS works out the new progress from the target. Changing `currentValue` with `PATCH` doesn't recalculate progress.

### Salesforce: closed deal updates a revenue Objective

When an Opportunity is marked Closed Won, add its amount to the Objective's running total and record a check-in.

```js
async function onDealClosed(amount, dealName) {
  const id = process.env.GPS_REVENUE_OBJECTIVE_ID;
  const { data: objective } = await (await fetch(`${GPS}/objectives/${id}`, { headers: gpsHeaders })).json();
  const value = (objective.currentValue ?? 0) + amount;

  await fetch(`${GPS}/objectives/${id}/progress`, {
    method: "POST",
    headers: gpsHeaders,
    body: JSON.stringify({ value, notes: `Closed won: ${dealName}` }),
  });
}
```

### HubSpot: push a lead count into GPS

A HubSpot workflow calls your endpoint with the latest count, and you record it as a check-in.

```js
app.post("/webhooks/hubspot-leads", express.json(), async (req, res) => {
  const result = await fetch(`${GPS}/objectives/${process.env.GPS_LEADS_OBJECTIVE_ID}/progress`, {
    method: "POST",
    headers: gpsHeaders,
    body: JSON.stringify({ value: Number(req.body.leadCount), notes: "From HubSpot" }),
  });
  res.sendStatus(result.ok ? 200 : 502);
});
```

GPS can also keep an Objective in sync from HubSpot by itself, with no code. See the [Prompt library](../prompts/index.md).

### GitHub Actions: count releases against an Objective

```yaml
# .github/workflows/gps-release.yml
name: Record release in GPS
on:
  release:
    types: [published]
jobs:
  check-in:
    runs-on: ubuntu-latest
    steps:
      - name: Add one to the releases Objective
        env:
          KEY: ${{ secrets.GPS_API_KEY_ID }}
          SECRET: ${{ secrets.GPS_API_SECRET }}
          OBJECTIVE: ${{ secrets.GPS_RELEASES_OBJECTIVE_ID }}
        run: |
          BASE=https://plan.empiraa.app/api/v1
          CURRENT=$(curl -s -H "X-API-Key: $KEY" -H "X-API-Secret: $SECRET" "$BASE/objectives/$OBJECTIVE" | jq -r '.data.currentValue // 0')
          curl -s -X POST "$BASE/objectives/$OBJECTIVE/progress" \
            -H "X-API-Key: $KEY" -H "X-API-Secret: $SECRET" -H "Content-Type: application/json" \
            -d "{\"value\": $((CURRENT + 1)), \"notes\": \"Release ${{ github.event.release.tag_name }}\"}"
```

### Jira: finished sprint completes the matching Actions

When Jira reports a sprint is done, find the GPS Actions that name it and mark them completed.

```js
app.post("/webhooks/jira-sprint", express.json(), async (req, res) => {
  const sprint = req.body.sprint?.name;
  if (!sprint) return res.sendStatus(400);
  res.sendStatus(200);

  const { data: actions } = await (await fetch(`${GPS}/actions?limit=200`, { headers: gpsHeaders })).json();
  for (const action of actions.filter((a) => a.title.includes(sprint))) {
    await fetch(`${GPS}/actions/${action.id}`, {
      method: "PATCH",
      headers: gpsHeaders,
      body: JSON.stringify({ status: "completed" }),
    });
  }
});
```

GPS allows 100 requests a minute for each key, so pace large batches. See [Rate limits](../gps/api/conventions.md#rate-limits).

## Signal

### Website form creates a prospect contact

`POST /prospects` matches on email, so the same person sent twice is updated, not duplicated.

```bash
curl -X POST https://crm.empiraa.app/api/v1/prospects \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Jane",
    "last_name": "Doe",
    "email": "jane@fabrikam.example",
    "job_title": "Operations Manager",
    "notes": "Asked for a demo through the website form."
  }'
```

```js
await fetch(`${SIGNAL}/prospects`, {
  method: "POST",
  headers: signalHeaders,
  body: JSON.stringify({
    first_name: "Jane",
    last_name: "Doe",
    email: "jane@fabrikam.example",
    job_title: "Operations Manager",
    notes: "Asked for a demo through the website form.",
  }),
});
```

### Move a Deal to a new stage

Send only what changed. The stage must belong to the Deal's pipeline. See [Stage IDs](../signal/api/reference/deals.md#stage-ids).

```bash
curl -X PATCH https://crm.empiraa.app/api/v1/deals/DEAL_ID \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "stage_id": "rds_proposal", "value": 15000 }'
```

```js
await fetch(`${SIGNAL}/deals/${dealId}`, {
  method: "PATCH",
  headers: signalHeaders,
  body: JSON.stringify({ stage_id: "rds_proposal", value: 15000 }),
});
```

### Add a note to a prospect contact

```bash
curl -X POST https://crm.empiraa.app/api/v1/notes \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "prospect_id": "PROSPECT_ID", "content": "Spoke with Jane. Follow up next week." }'
```

```js
await fetch(`${SIGNAL}/notes`, {
  method: "POST",
  headers: signalHeaders,
  body: JSON.stringify({ prospect_id: prospectId, content: "Spoke with Jane. Follow up next week." }),
});
```

### Build a Deck for a prospect contact

Copy a template Deck and have ANI personalize it, or build one from the checkbox custom fields ticked on the prospect contact. See [Decks](../signal/api/reference/decks.md).

```bash
# Copy a template and personalize it
curl -X POST https://crm.empiraa.app/api/v1/decks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "template_deck_id": "TEMPLATE_DECK_ID", "prospect_id": "PROSPECT_ID", "personalise": true }'
```

```js
// Build one from the prospect contact's questionnaire
await fetch(`${SIGNAL}/decks`, {
  method: "POST",
  headers: signalHeaders,
  body: JSON.stringify({ generate_from_questionnaire: true, prospect_id: prospectId, title: "Jane Doe: connectivity report" }),
});
```

### New prospect contact posts to Slack

Signal sends `prospect.created`. Signal signs the timestamp and the raw body together, so its check differs from GPS's. See [Signal webhooks](../signal/api/webhooks.md).

```js
function isValidSignal(rawBody, timestamp, header) {
  const expected = "sha256=" + crypto.createHmac("sha256", process.env.SIGNAL_WEBHOOK_SECRET).update(`${timestamp}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(header || "");
  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
  return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/webhooks/signal", express.raw({ type: "application/json" }), async (req, res) => {
  if (!isValidSignal(req.body, req.get("X-Empiraa-Timestamp"), req.get("X-Empiraa-Signature"))) return res.status(401).send("Invalid signature");
  res.sendStatus(200);

  const event = JSON.parse(req.body);
  if (event.eventType !== "prospect.created") return;
  await fetch(process.env.SLACK_WEBHOOK_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text: `New prospect contact: ${event.data.first_name ?? ""} ${event.data.last_name ?? ""}` }),
  });
});
```

