Ship changes. See exactly where.
ScreenMap crawls your web or mobile app on its own, draws every screen it finds as a map, and marks what moved since the last run — before your users find it first.
What ScreenMap actually does
No flows to script. Point it at a URL, and the rest reads like a map legend — each symbol means one thing, consistently, run after run.
Origin
Screen
Changed
Schedule
Runner
What one run looks like
Crawl
A browser explores your app, discovering screens as it goes.
Map
Each screen becomes a point on the graph, with its screenshot attached.
Compare
Every point is checked against the same point from the previous run.
Notify
Changes land in the app, a PR comment, or your own webhook.
Wire it into CI
Trigger a run, poll it, then pull the diff and a ready-made report — the same three requests, called three ways.
RUN=$(curl -sS -X POST "$API/hooks/runs/$PROJECT_ID" \
-H "Authorization: Bearer $SCREENMAP_TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"environment_id\":\"$ENV_ID\",\"branch\":\"$BRANCH\",\"commit\":\"$COMMIT\"}" \
| jq -r .run.id)
while :; do
STATUS=$(curl -sS "$API/runs/$RUN" -H "Authorization: Bearer $SCREENMAP_TOKEN" | jq -r .status)
case "$STATUS" in
passed|failed|error|canceled) break ;;
esac
sleep 10
done
curl -sS "$API/runs/$RUN/diff" -H "Authorization: Bearer $SCREENMAP_TOKEN" | jq .summary
curl -sS "$API/runs/$RUN/report.md" -H "Authorization: Bearer $SCREENMAP_TOKEN"
const API = "https://api.screenm.app/v1";
const headers = { Authorization: `Bearer ${process.env.SCREENMAP_TOKEN}` };
const { run } = await fetch(`${API}/hooks/runs/${PROJECT_ID}`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ environment_id: ENV_ID, branch: BRANCH, commit: COMMIT }),
}).then((r) => r.json());
let status;
do {
await new Promise((r) => setTimeout(r, 10000));
({ status } = await fetch(`${API}/runs/${run.id}`, { headers }).then((r) => r.json()));
} while (!["passed", "failed", "error", "canceled"].includes(status));
const { summary } = await fetch(`${API}/runs/${run.id}/diff`, { headers }).then((r) => r.json());
const report = await fetch(`${API}/runs/${run.id}/report.md`, { headers }).then((r) => r.text());
- name: Crawl with ScreenMap
env:
API: https://api.screenm.app/v1
SCREENMAP_TOKEN: ${{ secrets.SCREENMAP_TOKEN }}
PROJECT_ID: ${{ vars.SCREENMAP_PROJECT_ID }}
ENV_ID: ${{ vars.SCREENMAP_ENV_ID }}
run: |
set -euo pipefail
RUN=$(curl -sS -X POST "$API/hooks/runs/$PROJECT_ID" \
-H "Authorization: Bearer $SCREENMAP_TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"environment_id\":\"$ENV_ID\",\"branch\":\"$GITHUB_REF_NAME\",\"commit\":\"$GITHUB_SHA\"}" \
| jq -r .run.id)
while :; do
STATUS=$(curl -sS "$API/runs/$RUN" -H "Authorization: Bearer $SCREENMAP_TOKEN" | jq -r .status)
case "$STATUS" in
passed|failed|error|canceled) break ;;
esac
sleep 10
done
curl -sS "$API/runs/$RUN/diff" -H "Authorization: Bearer $SCREENMAP_TOKEN" | jq .summary
[ "$STATUS" = passed ]
Runs from our infrastructure, or yours.
Most teams just use ours — nothing to install, nothing to keep patched.
If your app sits behind a firewall, drop the runner binary inside your own network instead. It connects outbound to ScreenMap on its own token; you never grant inbound access, and it never sees your storage or model keys.
Pricing
Start free. Move up when you outgrow it — no calls, no contracts.
See your app the way it actually looks today.
Free to start. First crawl usually finishes in a couple of minutes.