Add website technologies to a CRM account
Use a company website to show detected technologies, versions, and their recorded check time on an account card.
Working example
ParseAPISet up ParseAPI
Install the SDK in your application and set PARSEAPI_KEY in your server environment.
npm install @parseapi/sdk@1.14.0
export PARSEAPI_KEY='YOUR_SECRET_API_KEY'Create a client and reuse it for the calls below:
import { parseAPI } from '@parseapi/sdk';
const parse = parseAPI();Give an account useful website context
Your CRM already knows an account's website. Add a compact technology card so a teammate can see detected platforms before a conversation, without leaving the account view.
This example keeps a fictional Acme Studio account beside an editable website field. It checks the public website through ParseAPI, then groups the result into useful categories. The card is a recorded observation, rather than a complete software inventory or a monitoring service.
1. Use the account's website as the input
Pass the existing account website into a named form field:
const form = `<section class="account"><h2>Acme Studio</h2><p class="note">Account website</p></section>
<form method="post" action="/">
<label for="website">Website domain</label>
<input id="website" name="website" type="text" maxlength="253" required
autocomplete="off" autocapitalize="none" spellcheck="false"
value="${escape(fields.website ?? 'wordpress.org')}"
aria-describedby="website-help">
<p id="website-help" class="note">Use a public hostname, without https:// or a path.</p>
<button type="submit">Check technologies</button>
</form>`;The server accepts one public hostname, without a scheme or path, and normalizes internationalized names. In your CRM, use the website already attached to the account; let the person correct it before checking.
The Node app keeps its secret PARSEAPI_KEY on the server. The browser sends only the website field to the app, and the SDK makes the lookup.
2. Request the detected technologies
Use the SDK's Stack method:
const stack = await parse.stack(website, { signal });The response identifies domain, the checked homepage url, and checked_at. Technology collections include cms, servers, frameworks, ecommerce, analytics, chat, payments, and hosting.
The complete source verifies the requested domain and collection shapes before rendering them. It uses the SDK's normal Stack timeout and disables automatic retries, so pressing the button starts one attempt.
3. Turn the returned groups into account context
Each technology has a stable technology code, display name, and a nullable version. Render the useful labels and versions under their categories:
const groups = Object.entries(CATEGORIES)
.filter(([key]) => stack[key].length > 0)
.map(([key, label]) => `<div><h3>${label}</h3><ul>${
stack[key].map(technology => `<li>
<span class="technology">${escape(technology.name)}</span>
<small>${technology.version === null
? 'Version not detected' : escape(technology.version)}</small>
</li>`).join('')
}</ul></div>`).join('');
const unmatched = Object.entries(CATEGORIES)
.filter(([key]) => stack[key].length === 0)
.map(([, label]) => label);A null version means no explicit version was detected. An empty category means no matching technology was detected, rather than proof that the site does not use one. Keep those meanings in the account card.
When the page could not be checked, checked_at and all collections are null. Show that no inventory is available instead of presenting an empty successful check.
4. Keep the observation with the account view
Use the returned check time and page alongside the rendered groups:
return `<section class="result"><h2>Acme Studio website technologies</h2><p>${escape(stack.domain)}</p>
<p class="note">Checked <time datetime="${escape(stack.checked_at)}">
${escape(new Date(stack.checked_at).toISOString())}</time>.
Cached results keep the original check time.</p>
<p class="note">Page: ${escape(stack.url)}</p><div class="grid">${groups}</div>
${unmatched.length ? `<p class="note">No matches detected for: ${unmatched.join(', ')}.</p>` : ''}
<p class="note">Detection uses the homepage and response headers.
A missing match does not establish that a technology is absent.</p></section>`;Cached results keep their original checked_at. Display that time rather than replacing it with the time someone opened the CRM. Detection uses the static homepage and response headers; JavaScript-loaded and unexposed technologies can be missed.
In your application, attach the observation to the existing account record using your own CRM storage. The standalone app renders one account card and stores nothing. It schedules no checks. Editing the website clears the previous result and cancels the pending request, while account or network failures leave the form available to retry.
See the Stack reference for response semantics and detection scope.
Complete example
Use the complete implementation as a reference when adding this feature to your app. It includes the validation and failure handling around the calls above.
View and copy the complete source
import { createServer } from 'node:http';
import { pathToFileURL, domainToASCII, domainToUnicode } from 'node:url';
// example:imports
import { parseAPI } from '@parseapi/sdk';
// /example:imports
class InputError extends Error {}
const escape = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
const text = (value, max = 160) => typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/u.test(value);
function exactDomain(value) {
if (typeof value !== 'string' || value.trim().length > 253 || /[\s\u0000-\u0020\u007f/:@?#\\%\[\]]/u.test(value.trim())) throw new InputError('Enter one domain without a URL, path, or email address.');
const domain = domainToASCII(value.trim().toLowerCase()).replace(/\.$/, '');
const pattern = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9](?:[a-z0-9-]{0,57}[a-z0-9])?)$/;
if (!domain || domain.length > 253 || !pattern.test(domain) || /(?:^|\.)xn--/i.test(domainToUnicode(domain))) throw new InputError('Enter a valid domain name.');
return domain;
}
const TITLE = 'Add website context to an account';
const INTRO = 'Keep a company website beside its detected technologies, so your team has context before a conversation.';
const FAILURE = 'Website technologies could not be checked. The account card has no new inventory. Try again.';
const FIELDS = ['website'];
const CATEGORIES = { cms: 'CMS', servers: 'Web servers', frameworks: 'Frameworks', ecommerce: 'Ecommerce', analytics: 'Analytics', chat: 'Chat', payments: 'Payments', hosting: 'Hosting' };
export async function accountTechnologies(input, parse, signal) {
const website = exactDomain(input);
// example:request
const stack = await parse.stack(website, { signal });
// /example:request
if (!stack || stack.domain !== website || !text(stack.url, 2048)) throw new Error('Unexpected website response.');
const page = new URL(stack.url);
if (!['https:', 'http:'].includes(page.protocol) || page.username || page.password || page.search || page.hash) throw new Error('Unexpected page URL.');
if (!(stack.checked_at === null || typeof stack.checked_at === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(stack.checked_at) && Number.isFinite(Date.parse(stack.checked_at)) && new Date(stack.checked_at).toISOString().slice(0, 19) === stack.checked_at.slice(0, 19))) throw new Error('Unexpected check time.');
let total = 0;
for (const category of Object.keys(CATEGORIES)) {
const entries = stack[category];
if (stack.checked_at === null ? entries !== null : !Array.isArray(entries)) throw new Error('Unexpected technology collection.');
if (entries === null) continue;
total += entries.length;
if (total > 100) throw new Error('Unexpected technology count.');
const ids = new Set();
for (const entry of entries) {
if (!entry || !text(entry.technology, 80) || ids.has(entry.technology) || !text(entry.name, 120) || !(entry.version === null || text(entry.version, 80))) throw new Error('Unexpected technology.');
ids.add(entry.technology);
}
}
return stack;
}
function renderForm(fields = {}) {
// example:form
const form = `<section class="account"><h2>Acme Studio</h2><p class="note">Account website</p></section>
<form method="post" action="/">
<label for="website">Website domain</label>
<input id="website" name="website" type="text" maxlength="253" required
autocomplete="off" autocapitalize="none" spellcheck="false"
value="${escape(fields.website ?? 'wordpress.org')}"
aria-describedby="website-help">
<p id="website-help" class="note">Use a public hostname, without https:// or a path.</p>
<button type="submit">Check technologies</button>
</form>`;
// /example:form
return form;
}
async function submit(fields, parse, signal) {
const stack = await accountTechnologies(fields.website, parse, signal);
if (stack.checked_at === null) return '<section class="result"><h2>Website could not be checked</h2><p>No technology inventory is available for this account website. Try again later.</p></section>';
// example:result
const groups = Object.entries(CATEGORIES)
.filter(([key]) => stack[key].length > 0)
.map(([key, label]) => `<div><h3>${label}</h3><ul>${
stack[key].map(technology => `<li>
<span class="technology">${escape(technology.name)}</span>
<small>${technology.version === null
? 'Version not detected' : escape(technology.version)}</small>
</li>`).join('')
}</ul></div>`).join('');
const unmatched = Object.entries(CATEGORIES)
.filter(([key]) => stack[key].length === 0)
.map(([, label]) => label);
// /example:result
// example:integration
return `<section class="result"><h2>Acme Studio website technologies</h2><p>${escape(stack.domain)}</p>
<p class="note">Checked <time datetime="${escape(stack.checked_at)}">
${escape(new Date(stack.checked_at).toISOString())}</time>.
Cached results keep the original check time.</p>
<p class="note">Page: ${escape(stack.url)}</p><div class="grid">${groups}</div>
${unmatched.length ? `<p class="note">No matches detected for: ${unmatched.join(', ')}.</p>` : ''}
<p class="note">Detection uses the homepage and response headers.
A missing match does not establish that a technology is absent.</p></section>`;
// /example:integration
}
const CSS = `
:root{color-scheme:light dark;font:16px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#202124;background:#f5f5f7}
*{box-sizing:border-box}body{margin:0}main{max-width:720px;margin:0 auto;padding:56px 20px 72px}h1{font-size:clamp(2rem,6vw,2.8rem);line-height:1.12;letter-spacing:-.045em;margin:0 0 16px}h2{font-size:1.35rem;line-height:1.3;letter-spacing:-.025em;margin:0 0 12px}h3{font-size:1rem;margin:0 0 8px}p{margin:0 0 16px}.intro,.note,small{color:#62646a}.intro{max-width:56ch}form,.result,.account{background:#fff;border:1px solid #e2e3e4;border-radius:18px;padding:24px;margin-top:24px}label{display:block;font-weight:600;margin-bottom:8px}input,select,button{font:inherit;min-height:44px;border-radius:8px}input,select{width:100%;border:1px solid #acafb5;background:#fff;color:inherit;padding:10px 12px}select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%2371717a' stroke-width='1.5' d='m4 6 4 4 4-4'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:36px}button{border:0;background:#087f9c;color:#fff;font-weight:600;padding:10px 18px;margin-top:20px;cursor:pointer}button:disabled{opacity:.65;cursor:wait}:focus-visible{outline:3px solid #0891b2;outline-offset:3px}.note{font-size:.875rem;margin-top:12px}.status{min-height:1.5em;margin-top:14px}ul{padding-left:20px}li{overflow-wrap:anywhere}.result{overflow-wrap:anywhere}.result p:last-child,.account p:last-child{margin-bottom:0}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px}.technology{display:block}time{font-variant-numeric:tabular-nums}
@media(max-width:480px){main{padding-top:32px}form,.result,.account{padding:20px}.grid{grid-template-columns:1fr}button{width:100%}}
@media(prefers-color-scheme:dark){:root{background:#000;color:#f5f5f7}form,.result,.account{background:#1d1d1f;border-color:#424245}input,select{background-color:#1d1d1f;border-color:#69696e}.intro,.note,small{color:#a1a1a6}button{background:#087f9c}}
`;
const CLIENT = `
const form = document.querySelector('form');
const result = document.querySelector('#result');
const status = document.querySelector('#status');
const button = form.querySelector('button');
let controller, revision = 0;
form.addEventListener('input', () => {
revision++; controller?.abort(); result.replaceChildren(); status.textContent = ''; button.disabled = false;
});
form.addEventListener('submit', async event => {
event.preventDefault(); controller?.abort(); controller = new AbortController();
const current = ++revision, signal = controller.signal;
result.replaceChildren(); status.textContent = 'Checking...'; button.disabled = true;
try {
const response = await fetch('/', { method: 'POST', signal, headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'X-Example-Fragment': '1'
}, body: new URLSearchParams(new FormData(form)) });
const html = await response.text();
if (current !== revision || signal.aborted) return;
if (!response.headers.get('content-type')?.startsWith('text/html')) throw new Error();
result.innerHTML = html; status.textContent = '';
} catch {
if (current !== revision || signal.aborted) return;
status.textContent = 'The check did not finish. Try again.';
} finally { if (current === revision) button.disabled = false; }
});
`;
function page(form, result = '') {
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${TITLE}</title><style>${CSS}</style></head><body><main><h1>${TITLE}</h1><p class="intro">${INTRO}</p>${form}<p id="status" class="status" role="status"></p><div id="result" aria-live="polite">${result}</div></main><script>${CLIENT}</script></body></html>`;
}
function readForm(req, allowed) {
if (req.headers['content-type']?.split(';')[0] !== 'application/x-www-form-urlencoded') return Promise.reject(new InputError('Submit this form again.'));
return new Promise((resolve, reject) => {
const chunks = []; let bytes = 0;
const fail = error => { cleanup(); req.resume(); reject(error); };
const chunk = value => {
bytes += value.length;
if (bytes > 4096) return fail(new InputError('The form is too large.'));
chunks.push(value);
};
const finish = () => {
cleanup(); const fields = new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
if ([...fields.keys()].some(key => !allowed.includes(key)) || allowed.some(key => fields.getAll(key).length !== 1)) return reject(new InputError('Submit one value for each field.'));
resolve(Object.fromEntries(fields));
};
const aborted = () => fail(new Error('Request ended.'));
const cleanup = () => { req.off('data', chunk); req.off('end', finish); req.off('aborted', aborted); req.off('error', fail); };
req.on('data', chunk); req.once('end', finish); req.once('aborted', aborted); req.once('error', fail);
});
}
export function createApp(parse, { now = () => new Date() } = {}) {
const server = createServer(async (req, res) => {
const controller = new AbortController();
const disconnect = () => { if (!res.writableEnded) controller.abort(); };
res.once('close', disconnect);
const send = (status, html) => {
if (controller.signal.aborted) return;
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'" });
res.end(html);
};
let fields = {};
const fragment = req.headers['x-example-fragment'] === '1';
try {
const host = req.headers.host ?? '';
if (!/^(?:127\.0\.0\.1|localhost)(?::[0-9]{1,5})?$/.test(host) ||
req.headers.origin && req.headers.origin !== `http://${host}` || req.headers['sec-fetch-site'] === 'cross-site') return send(403, '<p>Open the app on 127.0.0.1 to use this form.</p>');
if (req.url !== '/') return send(404, '<p>Page not found.</p>');
if (req.method === 'GET') return send(200, page(renderForm({}, now())));
if (req.method !== 'POST') return send(405, '<p>Use the form to submit a check.</p>');
fields = await readForm(req, FIELDS);
const result = await submit(fields, parse, controller.signal, now());
send(200, fragment ? result : page(renderForm(fields, now()), result));
} catch (error) {
if (controller.signal.aborted) return;
const message = error instanceof InputError ? error.message : FAILURE;
const result = `<section class="result" role="alert"><h2>Check unavailable</h2><p>${escape(message)}</p></section>`;
send(error instanceof InputError ? 400 : 502, fragment ? result : page(renderForm(fields, now()), result));
}
});
server.requestTimeout = 10000; server.headersTimeout = 10000;
return server;
}
function main() {
const key = process.env.PARSEAPI_KEY?.trim();
if (!key || key.startsWith('parse_public_') || key.startsWith('parse_app_') || /[\r\n]/.test(key)) throw new Error('Set PARSEAPI_KEY to a server secret key.');
const port = process.env.PORT === undefined ? 8000 : Number(process.env.PORT);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('PORT must be from 1 to 65535.');
// API 2.1.0 is selected by @parseapi/sdk 1.14.0. No automatic retry of a submitted form.
// example:client
const parse = parseAPI(key, { retries: 0 });
// /example:client
const server = createApp(parse);
server.on('error', () => { console.error('The local app could not start. Check PORT.'); process.exitCode = 1; });
server.listen(port, '127.0.0.1', () => console.log(`Open http://127.0.0.1:${port}`));
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try { main(); } catch (error) { console.error(error.message); process.exitCode = 1; }
}
// The website embeds this same app; the standalone server remains unchanged.
export { page, renderForm, submit };
Try the complete example on your computer
After completing the setup above, save the source in your application folder and start the app:
node crm-website-technologies.node.mjsOpen 127.0.0.1:8000 to use the form. The server keeps your secret key out of the page.
For every field and parameter, see Stack API reference.