What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
APIs
Five CI checks for an Agility site: Fetch API contract tests, a model snapshot to catch drift, signed webhook handler tests, a build check and a link check, with secrets kept out of logs.
Your site depends on things that change outside your repository: a field gets renamed in a model, a container is emptied, a webhook secret is rolled, a page link starts pointing nowhere. Unit tests with mocked data don't notice any of that. This guide adds five checks to your pipeline that do, and shows how to keep the credentials they need out of your logs.
| Check | Catches | Needs |
|---|---|---|
| 1. Contract tests against the Fetch API | Missing fields, empty lists, wrong keys | Preview API key |
| 2. Model snapshot | Model changes made in Agility since your last release | Personal Access Token |
| 3. Webhook handler tests | Signature handling bugs, broken handlers | Nothing: the tests sign their own payloads |
| 4. Build check | Data-fetching errors that only show with real content | Fetch and preview keys |
| 5. Link check | Broken internal links and images | Nothing extra |
The examples use GitHub Actions and Node.js's built-in test runner (node --test, Node.js 22), so there's nothing extra to install for the tests themselves. The same steps work in any CI system.
| Name | What it is | Where to get it | Store as |
|---|---|---|---|
AGILITY_GUID | Your instance GUID | Settings > API Keys | A variable (it isn't secret) |
AGILITY_API_PREVIEW_KEY | Read-only key that returns unpublished content as well as published | Settings > API Keys | A secret |
AGILITY_API_FETCH_KEY | Read-only key for published content | Settings > API Keys | A secret |
AGILITY_TOKEN | A Personal Access Token, only for the model snapshot | Created through the Management API | A secret, available only to the job that needs it |
A Personal Access Token acts as the user who created it, with that user's permissions on every instance they can reach, including write access. Give it to one job, not the whole workflow, and create it for a user whose access is limited to what the check needs. The preview key is read-only, but it exposes content nobody has published yet, so treat it as a secret too.
A contract test asks the real API for the containers your site reads and checks that the fields your code uses are there. Run it with the preview key so you hear about a change while it is still in staging, before it's published and breaks production.
What the test relies on, from the Content Fetch API reference:
GET https://api.aglty.io/{guid}/preview/{locale}/list/{referenceName} (use fetch instead of preview for published content only), with the key in an APIKey header. Reference names in the path are lowercase.{ items, totalCount }, and each item has contentID, properties and fields.take defaults to 10 and can be at most 250.401.api.aglty.io serves instances in the USA region. Instances in other regions use a regional host (for example api-eu.aglty.io for a GUID ending in -e); set AGILITY_FETCH_BASE_URL to match. See Fetch API Status Codes and Caching for status codes the test may see.test/agility-contract.test.js:
import { test } from "node:test";
import assert from "node:assert/strict";
const guid = process.env.AGILITY_GUID;
const apiKey = process.env.AGILITY_API_PREVIEW_KEY;
const locale = process.env.AGILITY_LOCALE ?? "en-us";
// api.aglty.io serves US instances; see the Fetch API docs for other regions.
const base = process.env.AGILITY_FETCH_BASE_URL ?? "https://api.aglty.io";
if (!guid || !apiKey) {
throw new Error("AGILITY_GUID and AGILITY_API_PREVIEW_KEY must be set");
}
async function getJson(path) {
const res = await fetch(`${base}/${guid}/preview${path}`, {
headers: { APIKey: apiKey },
});
// Report the status and path only. Never print the key or request headers.
assert.equal(res.status, 200, `GET ${path} returned ${res.status}`);
return res.json();
}
// The fields your code reads, per container. Keep this list next to your types.
const contract = {
posts: ["title", "slug", "date"],
};
for (const [referenceName, fields] of Object.entries(contract)) {
test(`list "${referenceName}" returns items with the fields the site reads`, async () => {
const list = await getJson(`/${locale}/list/${referenceName}?take=5`);
assert.ok(Array.isArray(list.items), "items is an array");
assert.ok(list.items.length > 0, `${referenceName} has at least one item in preview`);
for (const item of list.items) {
assert.equal(typeof item.contentID, "number");
for (const field of fields) {
assert.ok(field in item.fields, `${referenceName} item ${item.contentID} has field "${field}"`);
}
}
});
}
Edit contract to list your own containers and the field names your code reads. Run it with:
node --test test/agility-contract.test.js
When a test fails, the message names the container, the item and the missing field, and the request's status and path. It never prints the key.
If you've generated TypeScript types for your content, build contract from the same source so the test and your types can't drift apart. See Typed GraphQL with TypeScript and Next.js.
A contract test checks the containers you list. A model snapshot catches everything else: a field added, removed, renamed or retyped on any model. Commit a snapshot of your models, regenerate it in CI, and fail when it differs. A failure doesn't always mean something broke; it means someone changed a model and your code should be checked against the change.
Two ways to build the snapshot:
scripts/snapshot-models.mjs, using @agility/management-sdk (checked with 0.1.40):
// Writes a stable snapshot of every content and component model to agility-models.json.
// Commit the file; CI regenerates it and fails if it differs.
import { writeFileSync } from "node:fs";
import * as mgmt from "@agility/management-sdk";
const guid = process.env.AGILITY_GUID;
const token = process.env.AGILITY_TOKEN; // a Personal Access Token, from CI secrets
if (!guid || !token) throw new Error("AGILITY_GUID and AGILITY_TOKEN must be set");
const options = new mgmt.Options();
options.token = token;
const client = new mgmt.ApiClient(options);
// Content models and component (module) models. Page models are not included.
const models = await client.modelMethods.getContentModules(true, guid, true);
// Keep only what your code depends on; drop audit fields that change on every save.
const snapshot = models
.map((m) => ({
referenceName: m.referenceName,
fields: (m.fields ?? [])
.map((f) => ({ name: f.name, type: f.type }))
.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")),
}))
.sort((a, b) => (a.referenceName ?? "").localeCompare(b.referenceName ?? ""));
writeFileSync("agility-models.json", JSON.stringify(snapshot, null, 2) + "\n");
console.log(`Wrote ${snapshot.length} models`);
The script keeps only each model's reference name and its fields' names and types, sorted, so the file changes only when the model does. Add other properties (for example settings) if your code depends on them.
npm install --save-dev @agility/management-sdk
AGILITY_GUID=... AGILITY_TOKEN=... node scripts/snapshot-models.mjs # once, locally
git add agility-models.json && git commit -m "Snapshot Agility models"
If you already use the Agility CLI, a models-only pull downloads one JSON file per model to agility-files/{guid}/models/. Reduce them to the same stable shape with jq:
npx @agility/cli@1.1.0 pull --sourceGuid="$AGILITY_GUID" --elements="Models" --headless
jq -S -s 'map({referenceName, fields: ((.fields // []) | map({name, type}) | sort_by(.name))})
| sort_by(.referenceName)' \
agility-files/"$AGILITY_GUID"/models/*.json > agility-models.json
The CLI reads the token from AGILITY_TOKEN. Pass the GUID with --sourceGuid: the CLI doesn't read AGILITY_GUID from the environment, only from a .env file. In CLI 1.1.0 a failed sign-in doesn't fail pull's exit code, so the jq step (which fails when no model files exist) is what stops the job. See agility pull.
node scripts/snapshot-models.mjs # or the CLI and jq steps above
git diff --exit-code -- agility-models.json
git diff --exit-code exits 1 and prints the difference when the models changed. Run this check on your main branch and on a schedule rather than on every pull request: it reads the live instance, so the result doesn't depend on the code in the pull request.
If your site handles Agility webhooks (to revalidate pages or update a search index, for example), test the handler with deliveries signed exactly the way Agility signs them. Agility follows the Standard Webhooks specification, so the standardwebhooks package can both sign test payloads and verify real ones. The tests generate a throwaway secret, so they need no secrets from CI and can run on every pull request.
For the payload fields and the headers, see Webhook Events and Payload Reference. For verification in other languages, see Verifying Signed Webhooks.
Keep the verification in a function that takes a standard Request, so it can be tested without starting a server. app/api/agility-webhook/handler.js:
import { Webhook } from "standardwebhooks";
// Verifies an Agility webhook and returns a Response.
// `onEvent` is your own work (revalidate a path, update an index...).
export async function handleAgilityWebhook(req, secret, onEvent) {
const rawBody = await req.text(); // verify the raw body, before any JSON parsing
const headers = {
"webhook-id": req.headers.get("webhook-id") ?? "",
"webhook-timestamp": req.headers.get("webhook-timestamp") ?? "",
"webhook-signature": req.headers.get("webhook-signature") ?? "",
};
let event;
try {
event = new Webhook(secret).verify(rawBody, headers);
} catch {
return new Response("Invalid signature", { status: 401 });
}
await onEvent(event);
return new Response("OK", { status: 200 });
}
In a Next.js route handler, call it from POST with your secret:
// app/api/agility-webhook/route.js
import { handleAgilityWebhook } from "./handler.js";
export async function POST(req) {
return handleAgilityWebhook(req, process.env.AGILITY_WEBHOOK_SECRET, async (event) => {
// your work: revalidate, re-index, enqueue...
});
}
test/webhook.test.js covers a valid delivery, the wrong secret, a tampered body, a replayed old delivery, and the two signatures sent during the 24 hours after a secret roll:
import { test } from "node:test";
import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import { Webhook } from "standardwebhooks";
import { handleAgilityWebhook } from "../app/api/agility-webhook/handler.js";
// A throwaway secret in the same whsec_ format Agility uses. Never a real one.
const secret = "whsec_" + randomBytes(32).toString("base64");
const payload = JSON.stringify({
state: "Published",
instanceGuid: "test-guid",
languageCode: "en-us",
referenceName: "posts",
contentID: 39,
contentVersionID: 300,
changeDateUTC: "2026-10-03T10:00:00Z",
});
function signedRequest(body, { signWith = secret, timestamp = new Date() } = {}) {
const id = "msg_test_1";
const signature = new Webhook(signWith).sign(id, timestamp, body);
return new Request("http://localhost/api/agility-webhook", {
method: "POST",
headers: {
"content-type": "application/json",
"webhook-id": id,
"webhook-timestamp": Math.floor(timestamp.getTime() / 1000).toString(),
"webhook-signature": signature,
},
body,
});
}
test("accepts a correctly signed delivery", async () => {
const seen = [];
const res = await handleAgilityWebhook(signedRequest(payload), secret, (e) => seen.push(e));
assert.equal(res.status, 200);
assert.equal(seen[0].contentID, 39);
});
test("rejects a delivery signed with another secret", async () => {
const other = "whsec_" + randomBytes(32).toString("base64");
const res = await handleAgilityWebhook(signedRequest(payload, { signWith: other }), secret, () => {});
assert.equal(res.status, 401);
});
test("rejects a tampered body", async () => {
const req = signedRequest(payload);
const tampered = new Request(req.url, {
method: "POST",
headers: req.headers,
body: payload.replace('"contentID":39', '"contentID":40'),
});
const res = await handleAgilityWebhook(tampered, secret, () => {});
assert.equal(res.status, 401);
});
test("rejects a replayed delivery from an hour ago", async () => {
const old = new Date(Date.now() - 60 * 60 * 1000);
const res = await handleAgilityWebhook(signedRequest(payload, { timestamp: old }), secret, () => {});
assert.equal(res.status, 401);
});
test("accepts either signature during a secret roll", async () => {
const previous = "whsec_" + randomBytes(32).toString("base64");
const id = "msg_test_2";
const now = new Date();
const sigNew = new Webhook(secret).sign(id, now, payload);
const sigOld = new Webhook(previous).sign(id, now, payload);
const req = new Request("http://localhost/api/agility-webhook", {
method: "POST",
headers: {
"webhook-id": id,
"webhook-timestamp": Math.floor(now.getTime() / 1000).toString(),
"webhook-signature": `${sigOld} ${sigNew}`,
},
body: payload,
});
const res = await handleAgilityWebhook(req, secret, () => {});
assert.equal(res.status, 200);
});
npm install standardwebhooks
node --test test/webhook.test.js
These examples were run with Node.js 22 and standardwebhooks 1.1.1: all five tests pass. The library rejects a timestamp more than about five minutes from the current time, which is why the hour-old delivery fails.
Add a test for your own onEvent logic too, with a payload for each state you handle. Remember that unpublishing arrives as Deleted.
If your pages fetch Agility content at build time, a production build is also an end-to-end test: it fails when a page can't render the content it gets. Give the build job the same variables your site reads at runtime. In an Agility Next.js project those are usually AGILITY_GUID, AGILITY_API_FETCH_KEY, AGILITY_API_PREVIEW_KEY and AGILITY_SECURITY_KEY; use whatever names your project reads.
npm ci
npm run build
Run it on pull requests from your own repository, on your main branch, and on the schedule, so a content change that breaks a page is found even when no code changed.
Start the built site and crawl it with linkinator:
npm run start -- --port 3000 &
npx wait-on@9.5.1 --timeout 60000 http://localhost:3000
npx linkinator@8.1.0 http://localhost:3000 --recurse --timeout 15000
--recurse follows links within the site; links to other sites are checked but not crawled. wait-on waits until the server responds (the timeout is in milliseconds). Add --skip "<regex>" for URLs you don't want checked, such as a third-party site that blocks crawlers. Link checking catches links that editors put in content as well as the ones in your code, so a failure may need a content fix rather than a code fix.
.github/workflows/agility-checks.yml:
name: Agility integration checks
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "17 6 * * *" # daily, to catch model changes made in Agility
permissions:
contents: read
jobs:
webhook-tests:
# Needs no secrets: the tests sign their own payloads with a throwaway secret
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: node --test test/webhook.test.js
contract-tests:
# Secrets are not passed to pull requests from forks, so skip those
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
env:
AGILITY_GUID: ${{ vars.AGILITY_GUID }}
AGILITY_API_PREVIEW_KEY: ${{ secrets.AGILITY_API_PREVIEW_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: node --test test/agility-contract.test.js
model-drift:
# Uses a Personal Access Token, so only on main and on the schedule
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
environment: agility-read
env:
AGILITY_GUID: ${{ vars.AGILITY_GUID }}
AGILITY_TOKEN: ${{ secrets.AGILITY_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: node scripts/snapshot-models.mjs
- name: Fail if the models changed
run: git diff --exit-code -- agility-models.json
build-and-links:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
env:
AGILITY_GUID: ${{ vars.AGILITY_GUID }}
AGILITY_API_FETCH_KEY: ${{ secrets.AGILITY_API_FETCH_KEY }}
AGILITY_API_PREVIEW_KEY: ${{ secrets.AGILITY_API_PREVIEW_KEY }}
AGILITY_SECURITY_KEY: ${{ secrets.AGILITY_SECURITY_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm run build
- name: Check links on the built site
run: |
npm run start -- --port 3000 &
npx wait-on@9.5.1 --timeout 60000 http://localhost:3000
npx linkinator@8.1.0 http://localhost:3000 --recurse --timeout 15000
main and on the schedule, in an environment (agility-read here) that holds the Personal Access Token, so other jobs can't read it.echo $AGILITY_TOKEN, no env or printenv steps, and no set -x in steps that use secrets. In your own tests, report the status and the path of a failed request, never its headers, as the contract test above does.whsec_ secret each run. Never paste a real signing secret into a test file.AGILITY_TOKEN to the one job that needs it, and rotate it on a schedule. The Agility CLI prints Using Personal Access Token for authentication., not the token.agility-files/. Don't upload that folder as a build artifact unless the artifact is as private as the content.NODE_EXTRA_CA_CERTS=/path/to/ca.pem. Never disable certificate checks to make a request work: that exposes the keys in the request to anyone on the network path.