Packs

A pack is the mock of one service. It holds the routes the service answers and the response for each.

Shipped and available packs

integration-mock packs list
integration-mock packs install notion
integration-mock packs enable notion

Pack metadata

pack.json may name who owns a pack and which version it is, for a team that publishes and pins its own packs:

{
  "id": "erp",
  "domains": ["erp.example.internal"],
  "prefix": "/erp",
  "source": "authored",
  "version": "1.4.0",
  "owner": "integration-platform@example.com",
  "description": "The ERP order and invoice endpoints the fulfilment workflows call",
  "license": "internal"
}

packs validate accepts all four; packs list shows the version and owner when a pack has them. A recorded pack also carries provenance (when, by which release, redacted).

Which pack answers a call

When several packs have the same id, the most specific one wins.

OrderLayerWhere it lives
1SnapshotMocks built from one real execution. Active until cleared
2Project./.integration-mock/packs/
3User~/.integration-mock/packs/
4LibraryShipped in the package

If no route matches, the mock answers 501 with a hint. It never calls the real service and never returns empty data.

{
  "error": "integration-mock: no route",
  "service": "slack",
  "method": "GET",
  "path": "/nope",
  "hint": "run `integration-mock record` or add to ./.integration-mock/packs/slack"
}

Writing a pack by hand

Use this for a service that has no pack and publishes no OpenAPI file.

integration-mock packs init acme --domain api.acme.com
integration-mock packs validate acme
integration-mock packs enable acme
curl http://127.0.0.1:8080/acme/example

Writing a pack in four commands: init, the generated routes file, validate, curl

packs init writes a working route and a 404 route, so both shapes are there to copy. A pack created while the mock is running is served as soon as you enable it.

This creates:

.integration-mock/
└── packs/
    └── acme/
        ├── pack.json          id, domains, prefix, source
        └── routes/
            └── main.json      routes; files in routes/ are merged in file-name order

With an AI agent

skills/integration-mock-author-pack/SKILL.md is an agent skill that writes a pack from a service's documentation. The agent writes the files and packs validate --json tells it what is wrong. integration-mock itself never calls a model.

Responses that change

A static body cannot test a workflow that creates a record and then reads it back. Two additions to a route cover most of that.

sequence answers each call in turn — the first call gets the first response, the second the second — and past the end respond answers (or, without one, the last entry repeats). Counts reset with packs reset.

{
  "id": "create-order",
  "match": { "method": "POST", "path": "/orders" },
  "sequence": [
    { "status": 201, "body": { "id": "o_1" } },
    { "status": 429, "body": { "error": "rate limited" } }
  ],
  "respond": { "status": 201, "body": { "id": "o_n" } }
}

template: true renders placeholders in the body and headers from the request:

{
  "id": "get-order",
  "match": { "method": "GET", "path": "/orders/:id" },
  "respond": {
    "status": 200,
    "template": true,
    "body": { "id": "{{request.params.id}}", "customer": "{{request.body.customer}}", "seen": "{{counter}}", "at": "{{now}}" }
  }
}
PlaceholderValue
request.body.<path>, request.query.<key>, request.params.<name>, request.headers.<name>From the request. A string that is only a placeholder yields the value itself, so an object is echoed as an object
request.path, request.methodThe request line
uuidA fresh v4 id
now, timestampISO time, epoch seconds
counterHow many times this route has answered, from 1

Templating is off unless the route asks for it, so a recorded body that happens to contain {{…}} replays as it was. An unknown placeholder is left as written.

Stateful routes

A route with a store block reads and writes the mock's records instead of answering a fixed body, so a workflow that creates a record can read it back, list it, page through it and delete it. Its respond is rendered with template: true and these names:

NameHolds
recordThe record a create, get, update or delete acted on
recordsThe current page of a list
pagePaging values (below)
{
  "id": "acme:create-order",
  "match": { "method": "POST", "path": "/orders" },
  "store": {
    "op": "create",
    "collection": "orders",
    "id": { "field": "id", "format": "ord_{{seq:6}}" },
    "stamp": { "object": "order", "created": "{{timestamp}}" },
    "idempotency": { "header": "Idempotency-Key" }
  },
  "respond": { "status": 201, "template": true, "body": "{{record}}" }
}
KeyMeaning
opcreate, get, update, delete or list
collectionWhich records. Seeded from seed in pack.json
idParamThe path param holding the id. Needed only when the path has more than one param
idfield holds the id (default id); format mints new ones: literal text plus {{seq:N}} (a counter, zero-padded to N) or {{uuid}}
stampFields added to a new record, rendered as a template ({{record.id}}, {{now}}, {{timestamp}})
updatemerge (default; nested objects merge key by key) or replace
coerceFields to turn from form-encoded strings into the type the vendor returns: { "amount": "number", "paid": "boolean" }
notFound, badRequest, conflictThe vendor's own error responses. {{error}} holds the reason. conflict also answers a create whose body carries an id that already exists

A JSON body must be an object. A form-encoded body (application/x-www-form-urlencoded) is read with brackets: metadata[plan]=pro is { "metadata": { "plan": "pro" } }.

Paging

"pagination": { "style": "cursor", "cursorParam": "after", "limitParam": "limit", "defaultLimit": 10, "maxLimit": 100 }
styleReadspage holds
cursorcursorParam: the id of the last record servednext (empty on the last page), hasMore, total
offsetoffsetParamoffset, nextOffset (null on the last page), hasMore, total
pagepageParam, from 1number, nextNumber, totalPages, hasMore, total
nextUrla token in a URL the mock issuednextUrl (empty when done), done, total

in: "body" reads the params from a JSON body, for search endpoints. A nextUrl route sets nextUrlTemplate (with {{token}}), and the route that serves the next page sets tokenParam. A cursor naming a record that no longer exists, limit=0 and non-numeric values answer 400.

Filtering

"filters": [
  { "from": "query.email", "field": "email" },
  { "from": "query.created[gte]", "field": "created", "op": "gte" },
  { "from": "body.filterGroups", "style": "hubspot" },
  { "from": "query.q", "style": "soql" }
]

from is query.<name>, body.<path> or params.<name>; a filter whose input is absent or empty is skipped. A filter the mock does not implement can be declared { "from": "body.query", "style": "unsupported" }: sending it answers 400 instead of returning everything. A SOQL FROM naming an object the pack does not model answers 400 too. op is eq (default), ne, gt, gte, lt, lte, contains or in (comma-separated). Two vendor styles:

Idempotency

"idempotency": { "header": "Idempotency-Key" } on a create or update: the same key with the same body returns the first response again, errors included (a 404, or a 409 for an existing id), as Stripe does; a request refused as malformed (badRequest) is not saved, so it can be retried. With a different body the key answers conflict (409). packs reset clears records, counters, page tokens and keys. The mock keeps at most 1,000 page tokens and 10,000 idempotency keys per service, dropping the oldest.

Salesforce (Opportunity, Account, Contact, Lead), HubSpot (contacts, companies, deals) and Stripe (customers, charges) ship with stateful routes. verify compares only the status of a stateful route, since its body is computed.

Failure scenario shapes

A pack can say how its vendor answers a revoked credential, a missing permission and a rate limit. integration-mock auth and integration-mock limits (see cli.md) answer with these:

"scenarios": {
  "revoked":   { "status": 200, "body": { "ok": false, "error": "invalid_auth" }, "note": "https://docs.slack.dev/reference/methods/chat.postMessage" },
  "forbidden": { "status": 200, "body": { "ok": false, "error": "missing_scope" } },
  "rateLimit": { "status": 429, "headers": { "Retry-After": "{{retryAfter}}" }, "body": { "ok": false, "error": "ratelimited" } }
}

Each entry is a response (status, headers, body) and is always rendered as a template. A rate-limit entry can use {{retryAfter}} (seconds), {{limit}} (calls per window) and {{resetAt}} (epoch seconds). note records where the shape comes from and is never sent. A missing entry answers a generic 401 {"error":"unauthorized"}, 403 {"error":"forbidden"} or 429 {"error":"rate limited"}, and a rate-limit answer gets Retry-After even when its entry leaves it out. The nearest layer whose pack has a scenarios block supplies the shapes, so a project pack that only adds routes keeps the library's.

Slack, Stripe, HubSpot, Salesforce, OpenAI, Gmail, Google Drive and Google Sheets ship their shapes. Each comes from the vendor's documentation, its official OpenAPI spec or its official SDKs, and its note names the source. OpenAI documents no permission-error body, so auth forbid openai answers the generic 403.

Generating a pack from OpenAPI

integration-mock packs build acme --spec ./acme-openapi.json   # OpenAPI 3.x or Swagger 2
integration-mock packs build stripe --fetch                    # download the spec listed in sources.yaml

Editing a generated pack

FilePurpose
routes/10-generated.jsonWritten by packs build. Do not edit it
routes/00-overrides.jsonYour fixes. It sorts first, so it wins: a route there replaces the generated route with the same method and path

To edit a shipped pack, copy it into your project first:

integration-mock packs eject slack

Downloaded specs are cached under ~/.integration-mock/vendor-specs/, so a rebuild works offline.