# Test Your Agility Integration in CI

> Source: https://agilitycms.com/docs/developers/test-agility-integration-in-ci

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](#1-contract-tests-against-the-fetch-api) | Missing fields, empty lists, wrong keys | Preview API key |
| [2. Model snapshot](#2-catch-model-drift-with-a-snapshot) | Model changes made in Agility since your last release | Personal Access Token |
| [3. Webhook handler tests](#3-test-your-webhook-handler-with-signed-payloads) | Signature handling bugs, broken handlers | Nothing: the tests sign their own payloads |
| [4. Build check](#4-build-the-site-against-real-content) | Data-fetching errors that only show with real content | Fetch and preview keys |
| [5. Link check](#5-check-links-on-the-built-site) | 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.

## Secrets you'll need

| 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](/docs/developers/personal-access-tokens), 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.

## 1. Contract tests against the Fetch API

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](/docs/developers/content-fetch-api) reference:

- Lists are at `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.
- A list returns `{ items, totalCount }`, and each item has `contentID`, `properties` and `fields`.
- `take` defaults to 10 and can be at most 250.
- A missing or wrong key gets `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](/docs/developers/fetch-api-status-codes-and-caching) for status codes the test may see.

`test/agility-contract.test.js`:

```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:

```bash
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.

> [!TIP]
> 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.

## 2. Catch model drift with a snapshot

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:

### With the Management SDK

`scripts/snapshot-models.mjs`, using [`@agility/management-sdk`](https://www.npmjs.com/package/@agility/management-sdk) (checked with 0.1.40):

```js
// 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.

```bash
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"
```

### With the Agility CLI

If you already use the [Agility CLI](/docs/developers/cli-reference), a models-only pull downloads one JSON file per model to `agility-files/{guid}/models/`. Reduce them to the same stable shape with `jq`:

```bash
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](/docs/developers/cli-pull).

### In CI

```bash
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.

## 3. Test your webhook handler with signed payloads

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](https://www.standardwebhooks.com) specification, so the [`standardwebhooks`](https://www.npmjs.com/package/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](/docs/developers/webhook-events-and-payloads). For verification in other languages, see [Verifying Signed Webhooks](/docs/developers/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`:

```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:

```js
// 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:

```js
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);
});
```

```bash
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`.

## 4. Build the site against real content

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.

```bash
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.

## 5. Check links on the built site

Start the built site and crawl it with [`linkinator`](https://www.npmjs.com/package/linkinator):

```bash
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.

## Putting it together

`.github/workflows/agility-checks.yml`:

```yaml
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
```

- The webhook tests run everywhere, because they need no secrets.
- GitHub doesn't pass secrets to workflows triggered by pull requests from forks, so the jobs that need them skip those pull requests instead of failing.
- The model snapshot runs only on `main` and on the schedule, in an [environment](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment) (`agility-read` here) that holds the Personal Access Token, so other jobs can't read it.

## Keep secrets out of logs

- **Store keys and tokens as CI secrets**, never in the repository or in workflow files. GitHub Actions masks registered secret values in logs, but only exact matches: a value you transform (base64-encode, split, URL-encode) is not masked.
- **Don't print them.** No `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.
- **Use throwaway secrets in tests.** The webhook tests generate a new `whsec_` secret each run. Never paste a real signing secret into a test file.
- **Scope the Personal Access Token.** Give `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.
- **Mind your artifacts.** A CLI pull writes your content, including unpublished content, to `agility-files/`. Don't upload that folder as a build artifact unless the artifact is as private as the content.
- **Keep TLS on.** Behind a proxy that inspects TLS, give Node.js the proxy's CA certificate with `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.

## Related

- [CLI command reference](/docs/developers/cli-reference) and [CLI CI/CD Integration Guide](/docs/developers/cli-ci-cd-integration-guide)
- [Webhook Events and Payload Reference](/docs/developers/webhook-events-and-payloads) and [Verifying Signed Webhooks](/docs/developers/verifying-signed-webhooks)
- [Personal Access Tokens](/docs/developers/personal-access-tokens)
- [Handle Rate Limits and Outages Gracefully](/docs/developers/handle-rate-limits-and-outages)
