Docs
Recipes

Pre-register visitors from your CRM

When a client meeting is booked in your CRM, create an Agoo visit and send the client a QR pass, keep it in step with changes, and tell the CRM when they arrive.

Planned· P9For developers

This is designed and scheduled but not built yet. We document it now so you can plan your integration.

Goal

Volta Bank's relationship managers book client meetings in the bank's CRM. Today, reception only learns about a client when they walk in. With this integration:

  1. When a meeting at one of the bank's offices is booked in the CRM, Agoo creates an expected visit and sends the client a QR pass by SMS, WhatsApp or email.
  2. When the meeting moves, the visit moves and the client gets an updated pass. When it's cancelled, the visit is cancelled.
  3. When the client checks in at the kiosk, the CRM records that they arrived. If they don't come, the CRM records a no-show.

What you need

NeedDetails
PlanPro or Enterprise for live use. Build and test on any plan in test mode.
API key scopesvisits:read, visits:write, people:read
Webhook endpointSubscribed to visit.checked_in and visit.no_show
Your CRMA way to be told when a meeting is created, changed or cancelled (most CRMs send webhooks), and an API to update a meeting
RuntimeNode.js 20 or later

The CRM side here is deliberately generic. Replace the two CRM functions with your CRM's own API.

How it works

WhenYour serviceAgoo
CRM meeting createdPOST /visits with an idempotency key from the meeting IDCreates the visit, sends the pass
CRM meeting movedPATCH /visits/{visit_id}, then POST /visits/{visit_id}/resend-passUpdates the visit, sends the new pass
CRM meeting cancelledPOST /visits/{visit_id}/cancelCancels the visit
Client checks in at the kioskReceives visit.checked_in, updates the CRMSends the webhook
Client never arrivesReceives visit.no_show, updates the CRMSends the webhook

Steps

Set up the project

npm i @ardent-africa/agoo express
npm i -D typescript tsx @types/express @types/node
.env
AGOO_API_KEY=agoo_sk_test_…
AGOO_WEBHOOK_SECRET=whsec_…
# Your CRM's office names, mapped to Agoo site IDs (run: agoo api GET /sites)
AGOO_SITES='{"Ridge HQ":"site_01kjpt3yw0fz0v414608h9x65s","Tema Branch":"site_01knvdere0f738bjjhnyhtyv2z"}'
CRM_API_URL=https://crm.example/api
CRM_API_TOKEN=…

Test mode is a separate copy of your organisation, so its site IDs are different from live ones. Keep a separate AGOO_SITES for each mode.

Create the Agoo client

src/agoo.ts
import { Agoo } from "@ardent-africa/agoo"

export const agoo = new Agoo({ apiKey: process.env.AGOO_API_KEY })

Describe your CRM

These are the only CRM details the integration needs. Adapt the type and the two functions to your CRM's API.

src/crm.ts
export type CrmMeeting = {
  id: string
  status: "scheduled" | "cancelled"
  start: string // ISO 8601 with an offset, such as 2026-10-14T10:30:00+00:00
  office: string // such as "Ridge HQ"
  owner_email: string // the relationship manager hosting the meeting
  contact: { name: string; email?: string; phone?: string; company?: string }
}

async function updateMeeting(meetingId: string, fields: Record<string, unknown>): Promise<void> {
  const res = await fetch(`${process.env.CRM_API_URL}/meetings/${encodeURIComponent(meetingId)}`, {
    method: "PATCH",
    headers: {
      Authorization: `Bearer ${process.env.CRM_API_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(fields),
  })
  if (!res.ok) throw new Error(`CRM update failed: ${res.status}`)
}

export const markArrived = (meetingId: string, at: string) => updateMeeting(meetingId, { arrival_status: "arrived", arrived_at: at })

export const markNoShow = (meetingId: string) => updateMeeting(meetingId, { arrival_status: "no_show" })

Remember which visit belongs to which meeting

The integration needs to find a meeting's visit, and a visit's meeting. It also records which webhooks it has processed, because a webhook can arrive more than once. This version keeps them in memory so the recipe runs as is; use a table in your own database in production.

src/store.ts
const visitByMeeting = new Map<string, string>()
const meetingByVisit = new Map<string, string>()
const processedWebhooks = new Set<string>()

export const store = {
  async visitFor(meetingId: string) {
    return visitByMeeting.get(meetingId)
  },
  async meetingFor(visitId: string) {
    return meetingByVisit.get(visitId)
  },
  async link(meetingId: string, visitId: string) {
    visitByMeeting.set(meetingId, visitId)
    meetingByVisit.set(visitId, meetingId)
  },
  async isProcessed(webhookId: string) {
    return processedWebhooks.has(webhookId)
  },
  async markProcessed(webhookId: string) {
    processedWebhooks.add(webhookId)
  },
}

Keep the visit in step with the meeting

syncMeeting is safe to call for every change to a meeting, as often as your CRM sends them.

src/sync.ts
import { AgooError } from "@ardent-africa/agoo"
import { agoo } from "./agoo"
import type { CrmMeeting } from "./crm"
import { store } from "./store"

const SITE_BY_OFFICE: Record<string, string> = JSON.parse(process.env.AGOO_SITES ?? "{}")
const hostIdByEmail = new Map<string, string>()

/** RFC 3339 in UTC, without milliseconds. */
const utc = (iso: string) => new Date(iso).toISOString().replace(/\.\d{3}Z$/, "Z")

/** Ghana numbers to E.164. Unknown formats are left out rather than guessed. */
function toE164(phone?: string): string | undefined {
  const digits = phone?.replace(/[^\d+]/g, "")
  if (!digits) return undefined
  if (/^\+\d{8,15}$/.test(digits)) return digits
  if (/^0\d{9}$/.test(digits)) return `+233${digits.slice(1)}`
  return undefined
}

async function hostIdFor(email: string): Promise<string> {
  const cached = hostIdByEmail.get(email)
  if (cached) return cached

  const { data } = await agoo.people.list({ email, limit: 1 })
  if (!data[0]) throw new Error(`No Agoo host with the email ${email}`)

  hostIdByEmail.set(email, data[0].id)
  return data[0].id
}

/** Runs an action that may legitimately fail because the visit has moved on (checked in, already cancelled). */
async function unlessMovedOn(action: Promise<unknown>): Promise<void> {
  try {
    await action
  } catch (err) {
    if (err instanceof AgooError && err.code === "invalid_state") return
    throw err
  }
}

export async function syncMeeting(meeting: CrmMeeting): Promise<void> {
  const visitId = await store.visitFor(meeting.id)

  // Cancelled in the CRM: cancel the visit, if there is one.
  if (meeting.status === "cancelled") {
    if (visitId) await unlessMovedOn(agoo.visits.cancel(visitId, { reason: "Meeting cancelled" }))
    return
  }

  // Not at one of our offices (for example, a video call): nothing to pre-register.
  const siteId = SITE_BY_OFFICE[meeting.office]
  if (!siteId) return

  const phone = toE164(meeting.contact.phone)
  const email = meeting.contact.email
  if (!phone && !email) {
    console.warn(`Meeting ${meeting.id}: the contact has no phone or email, so no pass can be sent`)
    return
  }

  // New meeting: create the visit. The idempotency key makes a retried call safe.
  if (!visitId) {
    const visit = await agoo.visits.create(
      {
        site_id: siteId,
        host_id: await hostIdFor(meeting.owner_email),
        type: "meeting",
        expected_at: utc(meeting.start),
        visitor: { name: meeting.contact.name, phone, email, company: meeting.contact.company },
      },
      { idempotencyKey: `crm-meeting-${meeting.id}` },
    )
    await store.link(meeting.id, visit.id)
    return
  }

  // Existing meeting: move the visit if the time or office changed, and send the new pass.
  const visit = await agoo.visits.retrieve(visitId)
  if (visit.status !== "expected") return

  const moved = visit.expected_at === null || Date.parse(visit.expected_at) !== Date.parse(meeting.start) || visit.site_id !== siteId
  if (!moved) return

  await agoo.visits.update(visitId, { expected_at: utc(meeting.start), site_id: siteId })
  await agoo.visits.resendPass(visitId)
}

Receive CRM changes and Agoo webhooks

One small Express 5 server takes meeting changes from your CRM and events from Agoo.

src/server.ts
import express from "express"
import { agoo } from "./agoo"
import { type CrmMeeting, markArrived, markNoShow } from "./crm"
import { store } from "./store"
import { syncMeeting } from "./sync"

const app = express()

// From Agoo: visit.checked_in and visit.no_show. Needs the raw body for the signature check.
app.post("/webhooks/agoo", express.raw({ type: "application/json" }), async (req, res) => {
  let event
  try {
    event = await agoo.webhooks.verify(req.body, req.headers, process.env.AGOO_WEBHOOK_SECRET!)
  } catch {
    res.status(400).send("Invalid signature")
    return
  }

  const webhookId = String(req.headers["webhook-id"])
  if (await store.isProcessed(webhookId)) {
    res.sendStatus(204)
    return
  }

  if (event.type === "visit.checked_in" || event.type === "visit.no_show") {
    const visit = event.data.object
    const meetingId = await store.meetingFor(visit.id)

    if (meetingId && event.type === "visit.checked_in") await markArrived(meetingId, visit.checked_in_at!)
    if (meetingId && event.type === "visit.no_show") await markNoShow(meetingId)
  }

  await store.markProcessed(webhookId) // only after success, so a failure is retried
  res.sendStatus(204)
})

// From your CRM: meeting created, changed or cancelled.
app.post("/crm/meetings", express.json(), async (req, res) => {
  // Verify that the request came from your CRM, as its documentation describes, before trusting it.
  await syncMeeting(req.body as CrmMeeting)
  res.sendStatus(204)
})

app.listen(3000, () => console.log("Listening on http://localhost:3000"))

If the Agoo API or your CRM is briefly unavailable, the handler fails with a 500 and the sender retries: Agoo retries webhooks for about 27 hours, and most CRMs retry their own webhooks too. For high volumes, put the work on a queue and respond straight away.

Try it in test mode

Forward Agoo's test events to your machine

Terminal 1
agoo listen --forward-to http://localhost:3000/webhooks/agoo --events visit.checked_in,visit.no_show

Copy the whsec_… secret it prints into AGOO_WEBHOOK_SECRET.

Start the service

Terminal 2
npx tsx --env-file=.env src/server.ts

Send a meeting as your CRM would

Use the email of a host who exists in your test-mode organisation.

Terminal 3
curl -X POST http://localhost:3000/crm/meetings \
  -H "Content-Type: application/json" \
  -d '{
    "id": "mtg-1001",
    "status": "scheduled",
    "start": "2026-10-14T10:30:00+00:00",
    "office": "Ridge HQ",
    "owner_email": "kwame.mensah@voltabank.example",
    "contact": { "name": "Ama Owusu", "phone": "024 123 4567", "company": "Coastline Consult" }
  }'

In the console (in test mode), the visit appears as expected, and the pass appears in the test outbox: nothing is sent to the number.

Check the client in

Find the visit's ID in the console, or with agoo visits list --status expected, then:

Terminal 3
agoo api POST /visits/visit_01jndy0yp57rcybvn5sxs5aa81/check-in --data '{}'

agoo listen shows the visit.checked_in event forwarded with a 204, and your CRM function is called with the arrival time. If your test site requires host approval, the check-in fires visit.approval_requested instead: approve it with agoo api POST /visits/{visit_id}/approve --data '{}'.

Move and cancel

Send the same meeting again with a different start: the visit moves and a new pass appears in the test outbox. Send it with "status": "cancelled": the visit is cancelled.

Production checklist

  • Swap to a live secret key with only visits:read, visits:write and people:read, and the live AGOO_SITES map.
  • Create a live webhook endpoint for https://your-service.example/webhooks/agoo, subscribed to visit.checked_in and visit.no_show, and set its secret.
  • Replace the in-memory store with your database, so links and processed webhook IDs survive restarts.
  • Verify your CRM's webhook signatures on /crm/meetings.
  • Check every relationship manager's CRM email matches their Agoo email, or hostIdFor will fail for them.
  • Make sure your privacy notice tells clients that their name and number are used to send them a visitor pass.
  • Log the requestId of any AgooError, so support can trace a failed call.
  • Alert on repeated failures, for example when the CRM rejects updates.

On this page