Phone system integration
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.
There are a few connected parts because each service has one simple job:
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.
call.ringing event to the Lambda Function URL.openphone-signature header.200 OK to Quo.quo-screen-pop as the function name.Open index.mjs, replace its contents with the code below, and choose Deploy.
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.
call.ringing event.Solve360 Screen Pop, then save.In Lambda, open Configuration > Environment variables > Edit. Add all four values:
| Variable | Value |
|---|---|
GOOGLE_CHAT_WEBHOOK_URL | The Google Chat webhook URL from step 1. |
SOLVE_EMAIL | Your Solve360 user email address. |
SOLVE_TOKEN | Your Solve360 API token. |
QUO_SIGNING_SECRET | The signing secret revealed in step 3. |
Save the environment variables. Then open General configuration > Edit and set:
200 OK response.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.
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 Solve360 lookup and Google Chat message can remain the same.