Integration examples

Ready-to-adapt code for connecting Signal and GPS to Slack, Microsoft Teams, Google Sheets, Zapier, Salesforce, Jira, GitHub and HubSpot.

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:

JavaScript
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:

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

Slack: post when an Objective changes#

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

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

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

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

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

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

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.

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

Signal#

Website form creates a prospect contact#

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

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."
  }'

Move a Deal to a new stage#

Send only what changed. The stage must belong to the Deal's pipeline. See Stage IDs.

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 }'

Add a note to a prospect contact#

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." }'

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.

# 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 }'

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.

JavaScript
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 ?? ""}` }),
  });
});