IntroductionWorking with recordsActivity reportsMCP server for AI AgentsWebhook notificationsWebhook managementExamplesBatching RequestsIncoming call screen pop

Show an incoming caller’s Solve360 record in Google Chat

This example uses Quo, a cloud business phone system, to demonstrate a common Screen Pop workflow that can be used as-is or adapted to another phone system.

When an incoming call rings, the workflow uses the caller’s phone number to find the matching record in Solve360. It then posts a message in Google Chat showing the caller’s name, phone number, and a direct link to open the CRM record.

The person answering the call can immediately see who is calling and open the correct record with one click.

How it works

There are a few connected parts because each service has one simple job:

  • Quo receives the call and sends a signed webhook when it rings.
  • AWS Lambda verifies the webhook and reads the caller’s phone number.
  • Solve360 finds the matching CRM record.
  • Google Chat displays the caller information and CRM link.

The parts are quick to configure, and once connected the workflow is fast and automatic. AWS supplies the public HTTPS address, so there is no server, API Gateway, database, custom domain, SSL certificate, or Google OAuth application to configure.

Example message

📞 Incoming Quo call
Caller: +14035551234
Name: Jordan Lee
CRM: https://secure.solve360.com/contact/123456

Workflow

  1. Quo sends a signed call.ringing event to the Lambda Function URL.
  2. Lambda verifies the openphone-signature header.
  3. Lambda searches Solve360 using the caller’s phone number.
  4. When a record is found, Lambda posts the caller and CRM link to Google Chat.
  5. Lambda returns 200 OK to Quo.

What you need

  • A Quo workspace where an Owner or Admin can create webhooks.
  • An AWS account.
  • A Solve360 user email address and API token.
  • A Google Workspace account that can add a webhook to a Google Chat space.

Setup

1

Create the Google Chat webhook

  1. Open Google Chat in a desktop browser.
  2. Create or open the space where incoming-call notifications should appear.
  3. Open the menu beside the space name.
  4. Choose Apps & integrations, then Add webhooks.
  5. Give the webhook a name and save it.
  6. Copy the webhook URL. Treat the complete URL like a password.
2

Create the AWS Lambda function

  1. In AWS, open Lambda and choose Create function.
  2. Select Author from scratch.
  3. Use quo-screen-pop as the function name.
  4. Select Node.js 24.x as the runtime.
  5. Expand Advanced settings and enable Function URL.
  6. Set the authorization type to NONE and leave CORS disabled.
  7. Choose Create function.

Open index.mjs, replace its contents with the code below, and choose Deploy.

index.mjs

import crypto from "node:crypto";

const {
  GOOGLE_CHAT_WEBHOOK_URL,
  SOLVE_EMAIL,
  SOLVE_TOKEN,
  QUO_SIGNING_SECRET
} = process.env;

const SOLVE360_CALLER_URL = "https://secure.solve360.com/caller";
const MAX_SIGNATURE_AGE_MS = 5 * 60 * 1000;
const FETCH_TIMEOUT_MS = 3000;

export const handler = async (request) => {
  try {
    if (request?.requestContext?.http?.method !== "POST") {
      return response(405, { ok: false, error: "Method not allowed" });
    }

    requireConfiguration();

    const bodyText = request.isBase64Encoded
      ? Buffer.from(request.body || "", "base64").toString("utf8")
      : String(request.body || "");

    const event = JSON.parse(bodyText);
    const normalizedPayload = JSON.stringify(event);
    const signatureHeader = getHeader(
      request.headers,
      "openphone-signature"
    );

    if (
      !verifyQuoSignature(
        normalizedPayload,
        signatureHeader,
        QUO_SIGNING_SECRET
      )
    ) {
      console.log("Rejected webhook: invalid Quo signature");
      return response(401, { ok: false, error: "Unauthorized" });
    }

    const call = event?.data?.object;

    console.log(JSON.stringify({
      eventId: event?.id || null,
      eventType: event?.type || null,
      direction: call?.direction || null
    }));

    if (
      event?.type !== "call.ringing" ||
      call?.direction !== "incoming" ||
      !call?.from
    ) {
      return response(200, { ok: true, ignored: true });
    }

    const phone = String(call.from);
    const solveUrl =
      SOLVE360_CALLER_URL + "/" + encodeURIComponent(phone);

    const authHeader =
      "Basic " +
      Buffer.from(
        `${SOLVE_EMAIL}:${SOLVE_TOKEN}`,
        "utf8"
      ).toString("base64");

    const solveResponse = await fetch(solveUrl, {
      method: "GET",
      headers: {
        Authorization: authHeader,
        Accept: "application/json"
      },
      signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
    });

    if (solveResponse.status !== 200) {
      console.log(`Solve360 lookup returned ${solveResponse.status}`);
      return response(200, {
        ok: true,
        matched: false,
        solveStatus: solveResponse.status
      });
    }

    const data = await solveResponse.json();

    if (!data?.id || !data?.url) {
      return response(200, { ok: true, matched: false });
    }

    const name = data.cn || "Unknown";
    const message = [
      "📞 Incoming Quo call",
      "",
      "Caller: " + phone,
      "Name: " + name,
      "CRM: " + data.url
    ].join("\n");

    const chatResponse = await fetch(GOOGLE_CHAT_WEBHOOK_URL, {
      method: "POST",
      headers: {
        "content-type": "application/json; charset=UTF-8"
      },
      body: JSON.stringify({ text: message }),
      signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
    });

    if (!chatResponse.ok) {
      const chatBody = await chatResponse.text();
      console.log(
        `Google Chat returned ${chatResponse.status}: ` +
        chatBody.slice(0, 500)
      );

      return response(502, {
        ok: false,
        error: "Google Chat request failed"
      });
    }

    return response(200, {
      ok: true,
      eventId: event?.id || null
    });
  } catch (error) {
    console.error(error);

    return response(500, {
      ok: false,
      error: error instanceof Error
        ? error.message
        : "Unexpected error"
    });
  }
};

function verifyQuoSignature(payload, headerValue, base64SigningSecret) {
  if (!headerValue || !base64SigningSecret) {
    return false;
  }

  const signingKey = Buffer.from(base64SigningSecret, "base64");
  const candidates = headerValue
    .split(",")
    .map((value) => value.trim());

  for (const candidate of candidates) {
    const [scheme, version, timestamp, providedDigest] =
      candidate.split(";");

    if (
      scheme !== "hmac" ||
      version !== "1" ||
      !timestamp ||
      !providedDigest
    ) {
      continue;
    }

    const timestampNumber = Number(timestamp);

    if (
      !Number.isFinite(timestampNumber) ||
      Math.abs(Date.now() - timestampNumber) > MAX_SIGNATURE_AGE_MS
    ) {
      continue;
    }

    const computedDigest = crypto
      .createHmac("sha256", signingKey)
      .update(timestamp + "." + payload, "utf8")
      .digest("base64");

    const providedBuffer = Buffer.from(providedDigest, "utf8");
    const computedBuffer = Buffer.from(computedDigest, "utf8");

    if (
      providedBuffer.length === computedBuffer.length &&
      crypto.timingSafeEqual(providedBuffer, computedBuffer)
    ) {
      return true;
    }
  }

  return false;
}

function getHeader(headers = {}, name) {
  const expected = name.toLowerCase();

  for (const [key, value] of Object.entries(headers || {})) {
    if (key.toLowerCase() === expected) {
      return String(value);
    }
  }

  return "";
}

function requireConfiguration() {
  const missing = [
    "GOOGLE_CHAT_WEBHOOK_URL",
    "SOLVE_EMAIL",
    "SOLVE_TOKEN",
    "QUO_SIGNING_SECRET"
  ].filter((key) => !process.env[key]);

  if (missing.length) {
    throw new Error(
      "Missing environment variables: " + missing.join(", ")
    );
  }
}

function response(statusCode, data) {
  return {
    statusCode,
    headers: {
      "content-type": "application/json"
    },
    body: JSON.stringify(data)
  };
}

Copy the Function URL shown in the function overview. You will use it in Quo.

3

Create the Quo webhook

  1. In Quo, open Settings, then Webhooks.
  2. Choose Create webhook.
  3. Paste the AWS Lambda Function URL.
  4. Select the call.ringing event.
  5. Select the Quo phone number or numbers to monitor.
  6. Add an optional label such as Solve360 Screen Pop, then save.
  7. Open the webhook details, choose the ellipsis menu, and select Reveal signing secret.
  8. Copy the signing secret.
Do not send the test request yet. Add the four Lambda environment variables first.
4

Add the Lambda settings

In Lambda, open Configuration > Environment variables > Edit. Add all four values:

VariableValue
GOOGLE_CHAT_WEBHOOK_URLThe Google Chat webhook URL from step 1.
SOLVE_EMAILYour Solve360 user email address.
SOLVE_TOKENYour Solve360 API token.
QUO_SIGNING_SECRETThe signing secret revealed in step 3.

Save the environment variables. Then open General configuration > Edit and set:

  • Memory: 128 MB
  • Timeout: 8 seconds
5

Test the Screen Pop

  1. In the Quo webhook details, choose the ellipsis menu and select Send Test Request.
  2. Confirm that Quo reports a successful 200 OK response.
  3. For a complete test, call the Quo number from a phone number stored in Solve360.
  4. Confirm that Google Chat displays the caller’s name, phone number, and CRM link.

Security

The Lambda Function URL accepts public requests because Quo cannot sign in with AWS IAM credentials. The function does not trust the public URL alone. It verifies Quo’s cryptographic signature before using the Solve360 API or posting to Google Chat.

  • The Quo signing secret verifies that the request came from Quo and that the payload was not changed.
  • The signature timestamp is checked to reduce replay attacks.
  • The Solve360 token, Quo signing secret, and Google Chat webhook URL are stored as Lambda environment variables rather than in the code.
  • The Google Chat webhook URL only permits one-way messages into the Chat space where it was created.

Use another phone system

This example can be adapted to any phone system that can send a webhook when a call rings. Usually, only two parts need to change:

  • The function that verifies the phone system’s webhook authentication.
  • The lines that read the caller’s phone number from the webhook payload.

The Solve360 lookup and Google Chat message can remain the same.