Skip to content

Programmatic policy management

The programmatic management API lets you create, update, publish, and retire policies from outside the InPolicy web app — from a CI/CD pipeline, a Terraform run, or an AI coding assistant. The same operations available in the editor are available over REST, keyed by an API key you control.

Management scopes are not included on governance keys by default. You need a separate key.

  1. Go to Settings → API Keys and click New API Key.
  2. Give it a name (e.g. ci-policy-sync).
  3. Under Scopes, select the scopes you need:
Scope What it allows
policies:read List and fetch policies
policies:write Create and update policies
policies:publish Publish drafts; required for publish=true in bulk upsert
policies:delete Soft-delete policies
divisions:read List and fetch divisions
divisions:write Create, update, and delete divisions
policy_areas:read List and fetch policy areas
policy_areas:write Create, update, and delete policy areas
documents:write Upload files for AI extraction; register public URL sources
webhooks:write Register and manage webhook subscriptions
  1. Copy the key immediately — it’s shown once. Store it in your secrets manager or CI environment as INPOLICY_API_KEY.

The Admin bundle (all ten scopes above) is the recommended choice for a CI/CD key. Keep it separate from the runtime governance keys used by the browser extension — if a management key leaks, it can’t be used for governance checks, and vice versa.


The CLI is the fastest way to sync a directory of Markdown policies.

Terminal window
npm install -g @inpolicy/cli
# One-time auth setup
export INPOLICY_API_KEY=inp_live_...
# Sync all .md files in ./policies/ (creates or updates by externalId)
inpolicy policies sync ./policies/
# Preview without sending anything
inpolicy policies sync ./policies/ --dry-run
# Sync and immediately publish
inpolicy policies sync ./policies/ --publish

Each Markdown file is upserted by externalId. The externalId defaults to the filename (without .md) sanitized to [A-Za-z0-9._/:-]. You can override it in frontmatter:

---
externalId: data-classification/v2
title: Data Classification Policy
severity: 4
enforcement: warning
confidenceRequired: 0.8
tags: [data, privacy]
policyAreaId: <uuid from Settings → Policy Areas>
publish: false
---
Employees must classify all files containing customer PII before sharing externally...

Supported frontmatter fields: externalId, title, severity (1–5), enforcement (fix | warning | audit), confidenceRequired (0.0–1.0), tags, policyAreaId, rationale, effectiveDate, expiryDate, publish.

confidenceRequired is an optional per-policy override. Omit it (or send null) and the policy uses the system-wide detection bar, which is what you want for almost every policy — see Confidence threshold. Policies created without it read back as confidenceRequired: null, which means “no override”, not “zero”.

Terminal window
inpolicy policies list # tabular list
inpolicy policies get <id> # fetch one policy
inpolicy policies publish <id> # publish a draft
inpolicy policies upload ./policy.pdf # upload a document for AI extraction

The policy admin MCP server lets Claude Desktop, Claude Code, or Cursor manage your policy catalog through natural language. It is distinct from the governance MCP server (which only reads policies to check content).

Add to ~/Library/Application Support/Claude/claude_desktop_config.json:

{
"mcpServers": {
"inpolicy-policy-admin": {
"command": "npx",
"args": ["-y", "@inpolicy/policy-admin-mcp"],
"env": {
"INPOLICY_API_KEY": "inp_live_..."
}
}
}
}

Restart Claude Desktop. You can then ask: “List my published data policies”, “Update the data classification policy to severity 4”, or “Publish the draft ‘AI Usage Policy’”.

Terminal window
# Add to your project's MCP config
INPOLICY_API_KEY=inp_live_... npx @inpolicy/policy-admin-mcp

Or in .claude/settings.json:

{
"mcpServers": {
"inpolicy-policy-admin": {
"command": "npx",
"args": ["-y", "@inpolicy/policy-admin-mcp"],
"env": { "INPOLICY_API_KEY": "inp_live_..." }
}
}
}

Required scopes: policies:read, policies:write, policies:publish, policies:delete, documents:write.


Base URL: https://api.inpolicy.ai/api/v1/agent. Authenticate with Authorization: Bearer <api-key>.

All endpoints in this section are mounted under /api/v1/agent (the public Agent Governance namespace). Use the full paths as listed in each section.

Paginated list. Requires policies:read.

Query params: status (draft | published | deprecated), externalId, tag, source, cursor, limit (max 100).

GET /api/v1/agent/policies?status=published&limit=50

Returns { data: PolicyV1[], nextCursor: string | null }.

Fetch one policy. Accepts an InPolicy UUID or external:<externalId> (e.g. /api/v1/agent/policies/external:data-classification-v2). A literal / in externalId is not supported here — it splits the URL into extra path segments and the request won’t match. Requires policies:read.

Idempotent bulk upsert. Up to 100 entries per call. Requires policies:write. Requires policies:publish if any entry has publish: true.

POST /api/v1/agent/policies/bulk
{
"policies": [
{
"externalId": "data-classification/v2",
"title": "Data Classification Policy",
"text": "Employees must classify...",
"severity": 4,
"enforcement": "warning",
"publish": false
}
]
}

Response: { results: UpsertResult[], created, updated, unchanged, published, errors }. Each UpsertResult has action: created | updated | unchanged | published | error. Partial failures are non-fatal — one bad row does not abort the rest.

An entry that changes text or examples on an already-published policy regenerates that policy’s search embedding, for the same reason as PATCH below. With publish: true the batch publish does it instead, in one call for the whole batch.

An entry can also set the policy’s scope:

scope key Type Meaning
audience internal | external | internal_external Who the rule governs speech or conduct towards.
condition string The context the rule applies in. Shown to the detector.
exceptions string[] Carve-outs the rule permits. Shown to the detector.
countryCodes string[] ISO 3166-1 alpha-2 codes. Enforced: the policy applies only to users in these countries.
teamIds string[] Team ids in your tenant. Enforced: the policy applies only to members of these teams.

When the externalId matches an existing policy, the scope object is merged over the stored scope: keys you omit keep their value, so re-running a sync that carries only audience does not remove teams or countries an admin set in the dashboard. Send an empty array ("teamIds": []) to clear an enforced axis. An audience outside the three values, an unrecognised country code, or a team id that is not an active team in your tenant fails that row with action: error and leaves the policy unchanged.

An entry can also set the policy’s enforceability tier: enforced_humans_and_agents, enforced_agents_only, documentation_only or not_yet_enforced, with an optional tierRationale (up to 200 characters) saying why. A new policy without a tier is documentation_only. The rationale is always empty while the tier is enforced_humans_and_agents, and promoting a policy to that tier clears it. A tier outside the four values returns 400 for the whole request.

Links in text are saved as the policy’s reference links. When the policy is published, InPolicy fetches each linked page and extracts facts that messages are checked against, the same as for a policy written in the dashboard.

Partial update. Accepts InPolicy UUID or external:<externalId> (same slash restriction as above — avoid / in externalId). Requires policies:write. All fields are optional; omitted fields are unchanged. scope takes the same keys as in bulk upsert, is merged the same way, and is validated the same way (an invalid value returns 400). tier and tierRationale follow the same rules as in bulk upsert, and a new text replaces the policy’s reference links with the links it contains.

Editing text or examples on a policy that is already published regenerates its search embedding, because both feed the vector that detection retrieves on. The write and the new embedding are applied together: if the AI service is unavailable the call returns 503 and the policy is left exactly as it was, so a retry is safe. Edits to other fields, and edits to a policy that has never been published, do not call the AI service.

Soft-delete. Sets status=DEPRECATED and isActive=false. Requires policies:delete. There is no hard delete anywhere in the product — this is the only delete operation, and the record is retained for audit/compliance.

Publishes a DRAFT or SUGGESTION policy. Triggers embedding regeneration via the AI service (same as clicking Save & publish in the editor). Requires policies:publish. Republishing an already-PUBLISHED policy returns 400.


Manage the top level of the policy taxonomy (Division → PolicyArea → Policy).

List divisions for the tenant, ordered by name. Requires divisions:read.

Fetch one division. Requires divisions:read.

Create a division. Requires divisions:write. A duplicate name within the tenant returns 409.

Partial update. Requires divisions:write.

Delete a division. Policy areas under it are reparented to the “Unassigned” bucket (divisionId: null) before the row is deleted; their policies are untouched. Requires divisions:write.


List policy areas for the tenant, ordered by name. Optional ?divisionId= filter. Requires policy_areas:read.

Fetch one policy area. Requires policy_areas:read.

Create a policy area, optionally under a division (divisionId, or null for the “Unassigned” bucket). Requires policy_areas:write. A duplicate name within the tenant returns 409; an out-of-tenant divisionId returns 400.

Partial update. Pass divisionId: null to reparent to the “Unassigned” bucket. Requires policy_areas:write.

Delete a policy area. Policies under it are reparented to the “Unassigned” bucket (policyAreaId: null) before the row is deleted. Requires policy_areas:write.


Upload a file and let the AI pipeline extract policies from it.

Async — returns 202 immediately. Requires documents:write.

Terminal window
curl -X POST https://api.inpolicy.ai/api/v1/agent/documents \
-H "Authorization: Bearer $INPOLICY_API_KEY" \
-F "file=@./security-handbook.pdf" \
-F "mode=stage" \
-F "policyAreaId=<uuid>"
Field Required Values Default
file yes PDF, DOCX, TXT, MD (max 25 MB) —
title no string filename
policyAreaId no UUID —
mode no stage | draft | publish stage
externalDocumentId no string —

Modes:

  • stage — extracted policies go to the Policy Inbox for human review before any action is taken.
  • draft — extracted policies are created as DRAFT; reviewable in the web app.
  • publish — runs the full publish pipeline immediately. Requires policies:publish on the key.

Response: { documentId: string, checkUrl: string, status: "pending" }.

Poll for status. Requires documents:write.

{
"id": "doc_...",
"status": "completed",
"extractedPolicies": [
{ "id": "pol_...", "title": "Data Retention", "status": "draft" }
]
}

Status values: pending → processing → completed | failed. Poll until terminal. Typical completion time: 10–60 seconds depending on file size.


Register a public URL for recurring sync. Useful for policies hosted in Confluence, Notion, or a public handbook URL. InPolicy fetches the URL, extracts policies, and re-syncs on a daily schedule.

Returns 202. Requires documents:write.

POST /api/v1/agent/sources
{
"url": "https://handbook.acme.com/security-policy",
"title": "Security Handbook",
"mode": "stage",
"schedule": "daily",
"policyAreaId": "<uuid>"
}

schedule accepts daily or none, and defaults to none — a one-time fetch with no recurring sync. Pass "schedule": "daily" explicitly (as in the example above) if you want InPolicy to keep re-fetching the URL; omitting the field gets you a single import that never re-syncs.

The initial fetch is enqueued immediately. Subsequent fetches run on the configured schedule, if any.

Note: v1 supports public URLs only — no authentication headers, no Confluence OAuth, no Notion tokens. For authenticated sources, upload directly with POST /api/v1/agent/documents.

List registered sources. Requires documents:write.

Fetch one source. Requires documents:write.

Stop syncing. The daily refresh is disabled; previously extracted policies remain in their current state. Requires documents:write.


Receive real-time events when policies or documents change.

Register a webhook. Requires webhooks:write.

POST /api/v1/agent/webhooks
{
"url": "https://your-server.example.com/inpolicy-hook",
"events": ["policy.published", "document.processing.completed"],
"description": "Notify CI when policies publish"
}

Response: { id, url, events, secret, createdAt }. The secret is returned once — store it immediately to verify the X-InPolicy-Signature header on incoming deliveries.

Event When it fires
policy.created New policy saved (any status)
policy.updated Policy body or metadata changed
policy.published Status set to PUBLISHED
policy.retired Status set to DEPRECATED
document.processing.completed Document extraction finished
document.processing.failed Document extraction failed
source.synced Source URL re-fetched successfully
source.failed Source URL fetch failed

InPolicy signs every delivery with HMAC-SHA256 using the secret returned at registration. Verify it on the receiver before processing:

import { createHmac, timingSafeEqual } from "crypto";
function verifyInPolicySignature(
body: Buffer,
signatureHeader: string,
secret: string,
): boolean {
const expected = `sha256=${createHmac("sha256", secret)
.update(body)
.digest("hex")}`;
const expected_buf = Buffer.from(expected, "utf8");
const received_buf = Buffer.from(signatureHeader, "utf8");
if (expected_buf.length !== received_buf.length) return false;
return timingSafeEqual(expected_buf, received_buf);
}

The X-InPolicy-Signature header value is sha256=<hex digest> — the sha256= prefix is literal, not a placeholder. Compare against the header as-is; don’t strip the prefix before hashing, and don’t add it to the digest you compute unless your comparison expects it (as in the example above).

  • InPolicy retries failed deliveries with exponential back-off. Your endpoint should return 2xx within 30 seconds.
  • Each delivery has a unique deliveryId — safe to use as an idempotency key.
  • Use GET /api/v1/agent/webhooks/:id/deliveries to inspect recent delivery attempts.
GET /api/v1/agent/webhooks — list subscriptions
GET /api/v1/agent/webhooks/:id — fetch one subscription
PATCH /api/v1/agent/webhooks/:id — update url, events, or description
DELETE /api/v1/agent/webhooks/:id — delete subscription
GET /api/v1/agent/webhooks/:id/deliveries — delivery history (last 50)

All management endpoints share the same rate limit as the governance API. The default limit is 120 requests per minute per API key (configurable per key via rpmLimit). Bulk upsert counts as one request regardless of how many policies it contains.

API usage is metered per tenant. You can view usage in Settings → API Keys.


  • externalId is your primary key, not a label. Changing an externalId in frontmatter creates a new policy rather than updating the old one. If you need to rename, use PATCH /api/v1/agent/policies/:id to update externalId explicitly, then delete the old entry.
  • publish=true in bulk upsert requires policies:publish on the API key, not just policies:write. The request will fail with 403 if the key lacks that scope.
  • mode=publish on document upload also requires policies:publish. The same scope check applies.
  • DELETE is soft, and there’s no hard delete anywhere in the product — not from this API, not from the admin UI. Deleted policies stay in the database as DEPRECATED with deletedAt set, retained for audit/compliance.
  • Embeddings regenerate on publish. If you publish 50 policies in one bulk call, 50 embedding jobs are queued. There may be a brief window (30–60 seconds) where the new policies aren’t yet enforced by the extension.
  • POST /api/v1/agent/policies/:id/publish is idempotent on DRAFT → PUBLISHED but not on PUBLISHED → PUBLISHED. Calling it on an already-published policy returns 400. Check status first if needed.
  • Webhook secrets are stored and returned once. There is no GET /api/v1/agent/webhooks/:id/secret endpoint. If you lose the secret, delete the subscription and re-register.