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.
Before you start
Section titled “Before you start”Create an API key with management scopes
Section titled “Create an API key with management scopes”Management scopes are not included on governance keys by default. You need a separate key.
- Go to Settings → API Keys and click New API Key.
- Give it a name (e.g.
ci-policy-sync). - 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 |
- 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 @inpolicy/cli quickstart
Section titled “The @inpolicy/cli quickstart”The CLI is the fastest way to sync a directory of Markdown policies.
npm install -g @inpolicy/cli
# One-time auth setupexport INPOLICY_API_KEY=inp_live_...
# Sync all .md files in ./policies/ (creates or updates by externalId)inpolicy policies sync ./policies/
# Preview without sending anythinginpolicy policies sync ./policies/ --dry-run
# Sync and immediately publishinpolicy policies sync ./policies/ --publishEach 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/v2title: Data Classification Policyseverity: 4enforcement: warningconfidenceRequired: 0.8tags: [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”.
Other CLI commands
Section titled “Other CLI commands”inpolicy policies list # tabular listinpolicy policies get <id> # fetch one policyinpolicy policies publish <id> # publish a draftinpolicy policies upload ./policy.pdf # upload a document for AI extractionThe @inpolicy/policy-admin-mcp server
Section titled “The @inpolicy/policy-admin-mcp server”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).
Claude Desktop
Section titled “Claude Desktop”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’”.
Claude Code / Cursor
Section titled “Claude Code / Cursor”# Add to your project's MCP configINPOLICY_API_KEY=inp_live_... npx @inpolicy/policy-admin-mcpOr 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.
REST API reference
Section titled “REST API reference”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.
Policies
Section titled “Policies”GET /api/v1/agent/policies
Section titled “GET /api/v1/agent/policies”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=50Returns { data: PolicyV1[], nextCursor: string | null }.
GET /api/v1/agent/policies/:id
Section titled “GET /api/v1/agent/policies/:id”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.
POST /api/v1/agent/policies/bulk
Section titled “POST /api/v1/agent/policies/bulk”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.
PATCH /api/v1/agent/policies/:id
Section titled “PATCH /api/v1/agent/policies/:id”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.
DELETE /api/v1/agent/policies/:id
Section titled “DELETE /api/v1/agent/policies/:id”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.
POST /api/v1/agent/policies/:id/publish
Section titled “POST /api/v1/agent/policies/:id/publish”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.
Divisions
Section titled “Divisions”Manage the top level of the policy taxonomy (Division → PolicyArea → Policy).
GET /api/v1/agent/divisions
Section titled “GET /api/v1/agent/divisions”List divisions for the tenant, ordered by name. Requires divisions:read.
GET /api/v1/agent/divisions/:id
Section titled “GET /api/v1/agent/divisions/:id”Fetch one division. Requires divisions:read.
POST /api/v1/agent/divisions
Section titled “POST /api/v1/agent/divisions”Create a division. Requires divisions:write. A duplicate name within the tenant returns 409.
PATCH /api/v1/agent/divisions/:id
Section titled “PATCH /api/v1/agent/divisions/:id”Partial update. Requires divisions:write.
DELETE /api/v1/agent/divisions/:id
Section titled “DELETE /api/v1/agent/divisions/:id”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.
Policy areas
Section titled “Policy areas”GET /api/v1/agent/policy-areas
Section titled “GET /api/v1/agent/policy-areas”List policy areas for the tenant, ordered by name. Optional ?divisionId= filter. Requires policy_areas:read.
GET /api/v1/agent/policy-areas/:id
Section titled “GET /api/v1/agent/policy-areas/:id”Fetch one policy area. Requires policy_areas:read.
POST /api/v1/agent/policy-areas
Section titled “POST /api/v1/agent/policy-areas”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.
PATCH /api/v1/agent/policy-areas/:id
Section titled “PATCH /api/v1/agent/policy-areas/:id”Partial update. Pass divisionId: null to reparent to the “Unassigned” bucket. Requires policy_areas:write.
DELETE /api/v1/agent/policy-areas/:id
Section titled “DELETE /api/v1/agent/policy-areas/:id”Delete a policy area. Policies under it are reparented to the “Unassigned” bucket (policyAreaId: null) before the row is deleted. Requires policy_areas:write.
Documents
Section titled “Documents”Upload a file and let the AI pipeline extract policies from it.
POST /api/v1/agent/documents
Section titled “POST /api/v1/agent/documents”Async — returns 202 immediately. Requires documents:write.
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. Requirespolicies:publishon the key.
Response: { documentId: string, checkUrl: string, status: "pending" }.
GET /api/v1/agent/documents/:id
Section titled “GET /api/v1/agent/documents/:id”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.
Sources
Section titled “Sources”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.
POST /api/v1/agent/sources
Section titled “POST /api/v1/agent/sources”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.
GET /api/v1/agent/sources
Section titled “GET /api/v1/agent/sources”List registered sources. Requires documents:write.
GET /api/v1/agent/sources/:id
Section titled “GET /api/v1/agent/sources/:id”Fetch one source. Requires documents:write.
DELETE /api/v1/agent/sources/:id
Section titled “DELETE /api/v1/agent/sources/:id”Stop syncing. The daily refresh is disabled; previously extracted policies remain in their current state. Requires documents:write.
Webhooks
Section titled “Webhooks”Receive real-time events when policies or documents change.
POST /api/v1/agent/webhooks
Section titled “POST /api/v1/agent/webhooks”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 types
Section titled “Event types”| 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 |
Verifying the signature
Section titled “Verifying the signature”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).
Webhook delivery semantics
Section titled “Webhook delivery semantics”- 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/deliveriesto inspect recent delivery attempts.
Other webhook endpoints
Section titled “Other webhook endpoints”GET /api/v1/agent/webhooks — list subscriptionsGET /api/v1/agent/webhooks/:id — fetch one subscriptionPATCH /api/v1/agent/webhooks/:id — update url, events, or descriptionDELETE /api/v1/agent/webhooks/:id — delete subscriptionGET /api/v1/agent/webhooks/:id/deliveries — delivery history (last 50)Rate limits and metering
Section titled “Rate limits and metering”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.
Gotchas
Section titled “Gotchas”externalIdis your primary key, not a label. Changing anexternalIdin frontmatter creates a new policy rather than updating the old one. If you need to rename, usePATCH /api/v1/agent/policies/:idto updateexternalIdexplicitly, then delete the old entry.publish=truein bulk upsert requirespolicies:publishon the API key, not justpolicies:write. The request will fail with 403 if the key lacks that scope.mode=publishon document upload also requirespolicies:publish. The same scope check applies.DELETEis 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 withdeletedAtset, 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/publishis 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/secretendpoint. If you lose the secret, delete the subscription and re-register.
Cookie PreferencesDo Not Sell or Share My Personal Information