I saw this feature request on the community asking how to compare two Postman collections and find new or changed endpoints.
I’ve been there. API collections get out of sync real fast, especially when working across teams or versions.
So… I built a thing!
What it does:
-
Compares two collections (old vs new)
-
Detects:
Unchanged endpoints
Modified endpoints (URL, headers, body, auth, etc.)
New endpoints
Removed endpoints
-
Even suggests which folder new endpoints might belong to
How it works:
Just drop your old collection into a global variable, hit the new one, and my test script runs the comparison.
It uses:
object-hashfor unique endpoint signaturesfast-deep-equalto compare bodies/authlodash: Deep object comparison and array operations- Some basic HTML to show the results nicely in Postman
Code snippet
const hash = pm.require('npm:[email protected]');
const equal = pm.require('npm:[email protected]');
const _ = pm.require('npm:[email protected]');
pm.test("Collection Comparison and Analysis", function () {
// Get collections (in real scenario, old collection would be stored in globals/environment)
const oldCollection = JSON.parse(pm.globals.get("old_collection") || '{"item": []}');
const newCollection = pm.response.json(); // New collection from API response
// Perform comprehensive analysis
const analysis = performCollectionAnalysis(oldCollection, newCollection);
// Store results for visualization
pm.globals.set("collection_analysis", JSON.stringify(analysis));
// Log summary
console.log("=== COLLECTION COMPARISON RESULTS ===");
console.log(`📊 New endpoints: ${analysis.newEndpoints.length}`);
console.log(`🔄 Modified endpoints: ${analysis.modifiedEndpoints.length}`);
console.log(`✅ Unchanged endpoints: ${analysis.unchangedEndpoints}`);
console.log(`❌ Removed endpoints: ${analysis.removedEndpoints.length}`);
// Generate visualization
generateVisualization(analysis);
});
function performCollectionAnalysis(oldCol, newCol) {
console.log("🔍 Starting collection analysis...");
// Extract and index old requests
const oldRequests = new Map();
const oldRequestsList = extractRequestsWithDetails(oldCol);
oldRequestsList.forEach(req => {
const signature = generateRequestSignature(req);
oldRequests.set(signature, req);
});
// Analyze new requests
const newRequestsList = extractRequestsWithDetails(newCol);
const newEndpoints = [];
const modifiedEndpoints = [];
const unchangedEndpoints = [];
newRequestsList.forEach(req => {
const signature = generateRequestSignature(req);
const oldReq = oldRequests.get(signature);
if (!oldReq) {
// Completely new endpoint
newEndpoints.push({
...req,
suggestedFolder: suggestFolderPlacement(req, newCol)
});
} else {
// Check if request details changed
const changes = findDetailedChanges(oldReq, req);
if (changes.length > 0) {
modifiedEndpoints.push({
...req,
changes: changes,
oldVersion: oldReq
});
} else {
unchangedEndpoints.push(req);
}
// Remove from old requests map
oldRequests.delete(signature);
}
});
// Remaining requests in oldRequests map are removed endpoints
const removedEndpoints = Array.from(oldRequests.values());
return {
newEndpoints,
modifiedEndpoints,
unchangedEndpoints: unchangedEndpoints.length,
removedEndpoints,
summary: {
totalOld: oldRequestsList.length,
totalNew: newRequestsList.length,
analysisTimestamp: new Date().toISOString()
}
};
}
function extractRequestsWithDetails(collection) {
const requests = [];
function traverseItems(items, folderPath = []) {
if (!items) return;
items.forEach(item => {
if (item.request) {
requests.push({
id: item.id || generateUniqueId(),
name: item.name,
method: item.request.method,
url: normalizeUrl(item.request.url),
headers: item.request.header || [],
body: item.request.body || {},
auth: item.request.auth || {},
folder: folderPath.join('/') || 'Root',
description: item.request.description || '',
tests: item.event?.filter(e => e.listen === 'test') || [],
prerequest: item.event?.filter(e => e.listen === 'prerequest') || []
});
}
if (item.item) {
traverseItems(item.item, [...folderPath, item.name]);
}
});
}
traverseItems(collection.item);
return requests;
}
function generateRequestSignature(request) {
// Create unique signature for request identification
return hash({
method: request.method,
url: request.url,
// Include headers that affect functionality, not just metadata
// functionalHeaders: request.headers?.filter(h =>
// !['user-agent', 'postman-token'].includes(h.key?.toLowerCase())
// ) || []
});
}
function findDetailedChanges(oldReq, newReq) {
const changes = [];
// Check URL changes
if (oldReq.url !== newReq.url) {
changes.push(`URL changed from '${oldReq.url}' to '${newReq.url}'`);
}
// Check header changes
const oldHeaders = _.keyBy(oldReq.headers || [], 'key');
const newHeaders = _.keyBy(newReq.headers || [], 'key');
Object.keys(newHeaders).forEach(key => {
if (!oldHeaders[key]) {
changes.push(`Added header: ${key}`);
} else if (oldHeaders[key].value !== newHeaders[key].value) {
changes.push(`Modified header '${key}': ${oldHeaders[key].value} → ${newHeaders[key].value}`);
}
});
Object.keys(oldHeaders).forEach(key => {
if (!newHeaders[key]) {
changes.push(`Removed header: ${key}`);
}
});
// Check body changes
if (!equal(oldReq.body, newReq.body)) {
if (oldReq.body?.raw !== newReq.body?.raw) {
changes.push("Request body modified");
}
if (oldReq.body?.mode !== newReq.body?.mode) {
changes.push(`Body mode changed: ${oldReq.body?.mode} → ${newReq.body?.mode}`);
}
}
// Check auth changes
if (!equal(oldReq.auth, newReq.auth)) {
changes.push("Authentication configuration changed");
}
// Check test changes
if (oldReq.tests?.length !== newReq.tests?.length) {
changes.push(`Test count changed: ${oldReq.tests?.length || 0} → ${newReq.tests?.length || 0}`);
}
return changes;
}
function suggestFolderPlacement(request, collection) {
// Intelligent folder suggestion based on URL patterns
const url = request.url.toLowerCase();
if (url.includes('/auth') || url.includes('/login') || url.includes('/token')) {
return 'Authentication';
} else if (url.includes('/user') || url.includes('/profile')) {
return 'User Management';
} else if (url.includes('/product') || url.includes('/catalog')) {
return 'Products';
} else if (url.includes('/order') || url.includes('/purchase')) {
return 'Orders';
} else if (url.includes('/admin') || url.includes('/management')) {
return 'Administration';
} else if (url.includes('/report') || url.includes('/analytics')) {
return 'Reporting';
} else {
// Try to match existing folder structure
const existingFolders = extractFolderNames(collection);
const urlParts = request.url.split('/').filter(part => part && !part.match(/^v\d+$/));
for (const folder of existingFolders) {
for (const part of urlParts) {
if (folder.toLowerCase().includes(part.toLowerCase()) ||
part.toLowerCase().includes(folder.toLowerCase())) {
return folder;
}
}
}
return 'General';
}
}
function extractFolderNames(collection) {
const folders = new Set();
function traverse(items) {
if (!items) return;
items.forEach(item => {
if (item.item) {
folders.add(item.name);
traverse(item.item);
}
});
}
traverse(collection.item);
return Array.from(folders);
}
function normalizeUrl(url) {
if (typeof url === 'string') return url.trim();
if (url?.raw) return url.raw.trim();
if (url?.host && url?.path) {
const host = Array.isArray(url.host) ? url.host.join('.') : url.host;
const path = Array.isArray(url.path) ? '/' + url.path.join('/') : url.path;
return `${host}${path}`.trim();
}
return '';
}
function generateUniqueId() {
return 'req_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
function generateVisualization(analysis) {
const template = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Collection Comparison Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.dashboard {
max-width: 1400px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
backdrop-filter: blur(10px);
}
.header {
background: linear-gradient(135deg, #ff6b6b, #feca57);
padding: 30px;
text-align: center;
color: white;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
padding: 30px;
background: #f8f9fa;
}
.stat-card {
background: white;
padding: 25px;
border-radius: 15px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.08);
border-left: 5px solid;
transition: transform 0.3s ease;
}
.stat-card:hover {
transform: translateY(-5px);
}
.stat-card.new { border-left-color: #27ae60; }
.stat-card.modified { border-left-color: #f39c12; }
.stat-card.unchanged { border-left-color: #3498db; }
.stat-card.removed { border-left-color: #e74c3c; }
.stat-number {
font-size: 3rem;
font-weight: bold;
margin-bottom: 10px;
}
.stat-card.new .stat-number { color: #27ae60; }
.stat-card.modified .stat-number { color: #f39c12; }
.stat-card.unchanged .stat-number { color: #3498db; }
.stat-card.removed .stat-number { color: #e74c3c; }
.content-section {
padding: 30px;
}
.endpoint-list {
margin-top: 20px;
}
.endpoint-item {
display: flex;
align-items: center;
padding: 15px;
margin-bottom: 10px;
border-radius: 10px;
transition: all 0.3s ease;
border-left: 4px solid;
}
.endpoint-item.new {
background: #d5f4e6;
border-left-color: #27ae60;
}
.endpoint-item.modified {
background: #fef9e7;
border-left-color: #f39c12;
}
.endpoint-item.removed {
background: #fadbd8;
border-left-color: #e74c3c;
}
.method-badge {
padding: 5px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: bold;
margin-right: 15px;
min-width: 60px;
text-align: center;
color: white;
}
.method-get { background: #27ae60; }
.method-post { background: #f39c12; }
.method-put { background: #3498db; }
.method-delete { background: #e74c3c; }
.method-patch { background: #9b59b6; }
.endpoint-details {
flex: 1;
}
.endpoint-name {
font-weight: 600;
margin-bottom: 5px;
color: #2c3e50;
}
.endpoint-url {
font-size: 0.9rem;
color: #666;
font-family: 'Monaco', 'Courier New', monospace;
margin-bottom: 5px;
}
.changes-list {
margin-top: 8px;
}
.change-item {
font-size: 0.8rem;
color: #666;
margin-bottom: 3px;
padding-left: 15px;
position: relative;
}
.change-item:before {
content: "•";
position: absolute;
left: 0;
color: #f39c12;
}
.section-title {
font-size: 1.3rem;
font-weight: 600;
margin-bottom: 15px;
color: #2c3e50;
display: flex;
align-items: center;
gap: 10px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #666;
font-style: italic;
}
.merge-summary {
background: #e8f5e8;
border: 1px solid #27ae60;
border-radius: 10px;
padding: 20px;
margin: 20px 0;
}
.timestamp {
text-align: center;
padding: 20px;
color: #666;
font-size: 0.9rem;
background: #f8f9fa;
border-top: 1px solid #dee2e6;
}
</style>
</head>
<body>
<div class="dashboard">
<div class="header">
<h1>🔄 Collection Comparison Dashboard</h1>
</div>
<div class="stats-grid">
<div class="stat-card new">
<div class="stat-number">${analysis.newEndpoints.length}</div>
<div class="stat-label">New Endpoints</div>
<div class="stat-description">Ready to be added</div>
</div>
<div class="stat-card modified">
<div class="stat-number">${analysis.modifiedEndpoints.length}</div>
<div class="stat-label">Modified Endpoints</div>
<div class="stat-description">Require updates</div>
</div>
<div class="stat-card unchanged">
<div class="stat-number">${analysis.unchangedEndpoints}</div>
<div class="stat-label">Unchanged Endpoints</div>
<div class="stat-description">No action needed</div>
</div>
<div class="stat-card removed">
<div class="stat-number">${analysis.removedEndpoints.length}</div>
<div class="stat-label">Removed Endpoints</div>
<div class="stat-description">Missing in new collection</div>
</div>
</div>
<div class="content-section">
${generateNewEndpointsSection(analysis.newEndpoints)}
${generateModifiedEndpointsSection(analysis.modifiedEndpoints)}
${generateRemovedEndpointsSection(analysis.removedEndpoints)}
${generateMergeSummary(analysis)}
</div>
<div class="timestamp">
Analysis completed at: ${new Date(analysis.summary.analysisTimestamp).toLocaleString()}
<br>
Old Collection: ${analysis.summary.totalOld} endpoints |
New Collection: ${analysis.summary.totalNew} endpoints
</div>
</div>
</body>
</html>
`;
pm.visualizer.set(template);
}
function generateNewEndpointsSection(newEndpoints) {
if (newEndpoints.length === 0) {
return `
<div class="section-title">✨ New Endpoints</div>
<div class="empty-state">No new endpoints detected</div>
`;
}
const endpointsHtml = newEndpoints.map(endpoint => `
<div class="endpoint-item new">
<div class="method-badge method-${endpoint.method.toLowerCase()}">${endpoint.method}</div>
<div class="endpoint-details">
<div class="endpoint-name">${endpoint.name}</div>
<div class="endpoint-url">${endpoint.url}</div>
<div style="font-size: 0.8rem; color: #666; margin-top: 5px;">
📁 Suggested folder: ${endpoint.suggestedFolder}
</div>
</div>
</div>
`).join('');
return `
<div class="section-title">✨ New Endpoints (${newEndpoints.length})</div>
<div class="endpoint-list">${endpointsHtml}</div>
`;
}
function generateModifiedEndpointsSection(modifiedEndpoints) {
if (modifiedEndpoints.length === 0) {
return `
<div class="section-title">⚡ Modified Endpoints</div>
<div class="empty-state">No modifications detected</div>
`;
}
const endpointsHtml = modifiedEndpoints.map(endpoint => {
const changesHtml = endpoint.changes.map(change =>
`<div class="change-item">${change}</div>`
).join('');
return `
<div class="endpoint-item modified">
<div class="method-badge method-${endpoint.method.toLowerCase()}">${endpoint.method}</div>
<div class="endpoint-details">
<div class="endpoint-name">${endpoint.name}</div>
<div class="endpoint-url">${endpoint.url}</div>
<div class="changes-list">
<strong>Changes detected:</strong>
${changesHtml}
</div>
</div>
</div>
`;
}).join('');
return `
<div class="section-title">⚡ Modified Endpoints (${modifiedEndpoints.length})</div>
<div class="endpoint-list">${endpointsHtml}</div>
`;
}
function generateRemovedEndpointsSection(removedEndpoints) {
if (removedEndpoints.length === 0) {
return `
<div class="section-title">❌ Removed Endpoints</div>
<div class="empty-state">No endpoints were removed</div>
`;
}
const endpointsHtml = removedEndpoints.map(endpoint => `
<div class="endpoint-item removed">
<div class="method-badge method-${endpoint.method.toLowerCase()}">${endpoint.method}</div>
<div class="endpoint-details">
<div class="endpoint-name">${endpoint.name}</div>
<div class="endpoint-url">${endpoint.url}</div>
<div style="font-size: 0.8rem; color: #666; margin-top: 5px;">
⚠️ Consider archiving or removing from collection
</div>
</div>
</div>
`).join('');
return `
<div class="section-title">❌ Removed Endpoints (${removedEndpoints.length})</div>
<div class="endpoint-list">${endpointsHtml}</div>
`;
}
function generateMergeSummary(analysis) {
const total = analysis.newEndpoints.length + analysis.modifiedEndpoints.length +
analysis.unchangedEndpoints + analysis.removedEndpoints.length;
const actionItems = [];
if (analysis.newEndpoints.length > 0) {
actionItems.push(`Add ${analysis.newEndpoints.length} new endpoint${analysis.newEndpoints.length > 1 ? 's' : ''}`);
}
if (analysis.modifiedEndpoints.length > 0) {
actionItems.push(`Update ${analysis.modifiedEndpoints.length} modified endpoint${analysis.modifiedEndpoints.length > 1 ? 's' : ''}`);
}
if (analysis.removedEndpoints.length > 0) {
actionItems.push(`Review ${analysis.removedEndpoints.length} removed endpoint${analysis.removedEndpoints.length > 1 ? 's' : ''}`);
}
const actionText = actionItems.length > 0 ? actionItems.join(', ') : 'No actions required';
return `
<div class="merge-summary">
<div class="section-title">📋 Merge Summary</div>
<p><strong>Total endpoints analyzed:</strong> ${total}</p>
<p><strong>Actions required:</strong> ${actionText}</p>
<p><strong>Collection sync status:</strong> ${
analysis.newEndpoints.length === 0 && analysis.modifiedEndpoints.length === 0 && analysis.removedEndpoints.length === 0
? '✅ Collections are in sync'
: '🔄 Manual merge required'
}</p>
</div>
`;
}
Output looks like this:
📊 New endpoints: 3
🔄 Modified endpoints: 2
✅ Unchanged endpoints: 5
❌ Removed endpoints: 1
With a breakdown of exactly what changed — header diffs, body changes, all of it.
Wild how a few packages inside a script can turn a feature request into a working tool. Didn’t expect it to be this smooth!

