Quickstart
Three minutes to your first call. The API uses bearer-token auth, JSON bodies, and standard HTTP status codes.
- 1
Get your API key
Visit API Dashboard and click Generate key. Keys are prefixed
pf_. Treat them like passwords — never commit them to git or expose them on the client. - 2
Make your first request
List your policies. Replace
$POLICYFORGE_API_KEYwith your real key.curl https://policyforge.co/api/v1/policies \ -H "Authorization: Bearer $POLICYFORGE_API_KEY" - 3
Read the response
Successful responses return
200 OKwith a JSON body. Errors return a 4xx/5xx with{ error, details? }. See Errors for the full shape and Rate limits for the headers we return on every request.
Authentication
All endpoints under /api/v1/* require a bearer token, except /api/v1/consent/policy/{id}/record which is intentionally public for hosted-policy I-Agree clicks.
Generate keys from your API Dashboard. Each key is prefixed pf_ and grants access to data owned by your account.
- Server-side only. Never embed an API key in client JavaScript. Use the public consent endpoint or a backend proxy.
- Rotate freely. Generate a new key, deploy the update, then revoke the old one — both keys work concurrently.
- Scoped automatically. Reads return only your account's data. Writes attribute records to your account.
- Compromised? Revoke immediately from the dashboard and regenerate.
curl https://policyforge.co/api/v1/policies \
-H "Authorization: Bearer pf_YOUR_API_KEY"Errors
Errors use standard HTTP status codes. The response body is always JSON with an error field; some validation errors include a details field with the offending value.
Status codes
| Code | Meaning |
|---|---|
400Bad Request | The request body or query parameters are malformed (missing required fields, invalid JSON, bad UUID, etc.). The error message names the offending field. |
401Unauthorized | Missing or invalid API key. Check that the Authorization header is `Bearer pf_…` and the key hasn't been revoked. |
403Forbidden | Authenticated but the operation isn't allowed — typically because the resource isn't yours or a precondition isn't met (e.g. policy not hosted). |
404Not Found | Resource doesn't exist or isn't owned by your API key. We deliberately don't disclose existence across owners. |
405Method Not Allowed | HTTP method isn't supported on this endpoint. |
429Too Many Requests | Rate limit exceeded. The response includes `retry_after` (seconds) and the `X-RateLimit-Reset` header. |
500Internal Server Error | Something broke on our side. These are tracked automatically. Retry with exponential backoff is safe for idempotent operations (GET, PATCH with same body, DELETE). |
Error body shape
// Errors always return a JSON body of this shape:
{
"error": "policy_id not found or not owned by this API key",
"details": "..." // optional, present on validation errors
}
// Defensive client pattern:
const res = await fetch(url, options);
if (!res.ok) {
const { error } = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(`${res.status}: ${error}`);
}Rate limits
Limits are per API key. Headers on every response tell you how much budget you have left, so you don't need to guess or count locally.
| Tier | Per minute | Per day |
|---|---|---|
| Free | 10 | 100 |
| Pro | 60 | 5,000 |
| Enterprise | 300 | 50,000 |
429 responses are returned when the per-minute window is exceeded. The window resets continuously, not on a fixed clock minute.
// Every response includes:
X-RateLimit-Limit: 60 // requests/min for your tier
X-RateLimit-Remaining: 47 // remaining in the current window
X-RateLimit-Reset: 1778187600 // Unix timestamp when window resets
// On 429, we also return:
Retry-After: 32 // seconds to wait before retrying
// Defensive client pattern:
if (res.status === 429) {
const retryAfter = Number(res.headers.get("retry-after") ?? "30");
await new Promise((r) => setTimeout(r, retryAfter * 1000));
// ...retry once
}Policies
Generate, list, fetch, update, and delete policies. Generated policies are automatically saved and (when hosting is enabled) served at a public URL.
/api/v1/policiesGenerate a compliance policy with AI.
Returns a fully-formatted policy and saves it to your account. Generation typically takes 8–20 seconds depending on the policy type and jurisdiction. The response includes a hosted_url you can link to immediately.
Generation counts against your monthly policy quota (Free: 2 / month, calendar reset).
Headers
| Field | Type | Description |
|---|---|---|
Authorizationrequired | string | Bearer pf_… |
Body parameters
| Field | Type | Description |
|---|---|---|
typerequired | enum | One of privacy_policy, terms_of_service, cookie_policy, refund_policy, eula, disclaimer. |
business_typerequired | string | Your business category — e.g. saas, e-commerce, mobile_app, healthcare, fintech, education. |
jurisdictionrequired | string | Primary legal regime — e.g. gdpr (EU/UK), ccpa (California), pipeda (Canada), lgpd (Brazil). Multi-jurisdiction policies can list multiple, comma-separated. |
company_namerequired | string | Legal entity name to use throughout the document. |
contact_emailrequired | string | Contact email for privacy / legal queries. Surfaced verbatim in the policy. |
website_url | string | Your primary website URL. |
data_collection | string[] | Categories of data you collect — e.g. ["email", "name", "ip_address", "payment_info", "analytics"]. |
third_party_integrations | string[] | Names of third parties that process user data — e.g. ["Stripe", "Google Analytics", "Mixpanel"]. |
user_accounts | boolean | Do users sign up / authenticate? |
payments | boolean | Do you process payments? |
marketing | boolean | Do you send marketing communications? |
analytics | boolean | Do you use behavioural analytics? |
cookies | boolean | Does your site use cookies (any kind)? |
children_data | boolean | Do you knowingly collect data from anyone under 13? Triggers COPPA-aware language. |
curl -X POST https://policyforge.co/api/v1/policies \
-H "Authorization: Bearer $POLICYFORGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "privacy_policy",
"business_type": "saas",
"jurisdiction": "gdpr",
"company_name": "Acme Inc",
"contact_email": "privacy@acme.com",
"website_url": "https://acme.com",
"data_collection": ["email", "name", "usage_data"],
"third_party_integrations": ["Stripe", "Google Analytics"],
"user_accounts": true,
"payments": true,
"analytics": true,
"cookies": true
}'{
"id": "8331bf67-3748-471f-b595-9ba55a972cd5",
"title": "Acme Inc Privacy Policy",
"policy_type": "privacy_policy",
"status": "published",
"content": "...",
"hosted_url": "https://policyforge.co/policy/acme-inc-privacy-policy-1",
"hosting_enabled": true,
"created_at": "2026-05-08T22:30:00.000+00:00",
"updated_at": "2026-05-08T22:30:00.000+00:00"
}{
"error": "Missing required field: jurisdiction"
}{
"error": "Monthly policy quota exhausted. Upgrade to continue.",
"details": {
"tier": "free",
"limit": 2,
"used": 2,
"resets_at": "2026-06-01"
}
}/api/v1/policiesList all policies you've generated.
Headers
| Field | Type | Description |
|---|---|---|
Authorizationrequired | string | Bearer pf_… |
Query parameters
| Field | Type | Description |
|---|---|---|
limit | integer default: 20 | Page size, 1–100. |
offset | integer default: 0 | Number of results to skip. |
status | enum("draft", "published", "archived") | Filter by lifecycle status. |
type | string | Filter by policy type, same values as POST. |
sortBy | enum("createdAt", "updatedAt", "name") default: "updatedAt" | Sort field. |
sortOrder | enum("asc", "desc") default: "desc" | Sort direction. |
curl "https://policyforge.co/api/v1/policies?status=published&limit=50" \
-H "Authorization: Bearer $POLICYFORGE_API_KEY"{
"data": [
{
"id": "8331bf67-3748-471f-b595-9ba55a972cd5",
"title": "Acme Inc Privacy Policy",
"policy_type": "privacy_policy",
"status": "published",
"hosted_url": "https://policyforge.co/policy/acme-inc-privacy-policy-1",
"views": 1284,
"consent_count": 312,
"updated_at": "2026-05-08T22:30:00.000+00:00"
}
],
"total": 1,
"limit": 50,
"offset": 0
}/api/v1/policies/{id}Fetch a single policy by ID.
Headers
| Field | Type | Description |
|---|---|---|
Authorizationrequired | string | Bearer pf_… |
Path parameters
| Field | Type | Description |
|---|---|---|
idrequired | uuid | Policy UUID. |
curl https://policyforge.co/api/v1/policies/8331bf67-3748-471f-b595-9ba55a972cd5 \
-H "Authorization: Bearer $POLICYFORGE_API_KEY"{
"id": "8331bf67-3748-471f-b595-9ba55a972cd5",
"title": "Acme Inc Privacy Policy",
"policy_type": "privacy_policy",
"status": "published",
"content": "...",
"hosted_url": "https://policyforge.co/policy/acme-inc-privacy-policy-1",
"hosting_enabled": true,
"created_at": "2026-05-08T22:30:00.000+00:00",
"updated_at": "2026-05-08T22:30:00.000+00:00"
}{
"error": "Policy not found"
}/api/v1/policies/{id}Update a policy. Bumps updated_at, which bumps the policy_version stamp on subsequent consents.
Headers
| Field | Type | Description |
|---|---|---|
Authorizationrequired | string | Bearer pf_… |
Path parameters
| Field | Type | Description |
|---|---|---|
idrequired | uuid | Policy UUID. |
Body parameters
| Field | Type | Description |
|---|---|---|
title | string | Display title. |
status | enum("draft", "published", "archived") | Lifecycle status. Setting to `archived` removes from listings. |
hosting_enabled | boolean | Toggle the public hosted URL on/off. |
content | string | Markdown body. Replaces the entire policy text. Use sparingly — manual edits aren't re-validated against jurisdiction rules. |
curl -X PATCH https://policyforge.co/api/v1/policies/$POLICY_ID \
-H "Authorization: Bearer $POLICYFORGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "title": "Acme Privacy Policy (2026)", "status": "published" }'{
"id": "8331bf67-3748-471f-b595-9ba55a972cd5",
"title": "Acme Privacy Policy (2026)",
"status": "published",
"updated_at": "2026-05-08T22:35:00.000+00:00"
}/api/v1/policies/{id}Permanently delete a policy. Cascades to its analytics and consent records.
Destructive. Consider PATCH with { "status": "archived" } if you want to hide the policy without losing the consent audit trail.
Headers
| Field | Type | Description |
|---|---|---|
Authorizationrequired | string | Bearer pf_… |
Path parameters
| Field | Type | Description |
|---|---|---|
idrequired | uuid | Policy UUID. |
curl -X DELETE https://policyforge.co/api/v1/policies/$POLICY_ID \
-H "Authorization: Bearer $POLICYFORGE_API_KEY"{
"deleted": true,
"id": "8331bf67-3748-471f-b595-9ba55a972cd5"
}{
"error": "Policy not found"
}Consent
Record and retrieve user consent for compliance audits. Three input surfaces — cookie banner, hosted-policy "I Agree", and your own backend — write to a single audit table that you query through one endpoint.
How consent flows are stored
- Cookie banner (
source: "banner") — written when a visitor clicks Accept/Reject on the embeddable banner.banner_config_idis set;policy_idis also set if the banner is linked to a cookie policy. - Hosted-policy "I Agree" (
source: "api") — written when a visitor clicks the I Agree button on a policy you've hosted with PolicyForge.policy_idis always set. - Programmatic POST (
source: "api") — written when your backend callsPOST /api/v1/consent. Theuser_idandpolicy_versionyou send are preserved verbatim.
All three write to the same consent_records table. GET /api/v1/consent returns the unified view with source and policy_id filters so you can carve any slice you need.
/api/v1/consentRecord a consent event from your backend (e.g. ToS at checkout).
Use this when your own UI captures consent — your checkout form, an account-creation step, an in-app preference toggle. You set user_id to your internal identifier so you can look the record up later.
For records tied to a specific PolicyForge policy, pass policy_id: it makes the record show up in that policy's analytics card and lets you filter the list endpoint with ?policy_id=….
Headers
| Field | Type | Description |
|---|---|---|
Authorizationrequired | string | Bearer pf_…. See Authentication. |
Body parameters
| Field | Type | Description |
|---|---|---|
user_idrequired | string | Your end-user / visitor identifier. This is what you'll pass to GET /api/v1/consent/{user_id} later. |
policy_versionrequired | string | Version stamp tied to the wording in force at click time. Recommended: a slug of your policy's updated_at, e.g. "v2.1" or "policy-2026-05-08". |
consentsrequired | object<string, boolean> | Map of category name to accepted/rejected. Names are customer-defined — common patterns: { tos: true, marketing: false } or { essential: true, analytics: false }. |
policy_id | uuid | Optional. UUID of one of your policies. Must belong to the same account as the API key — mismatched IDs return 404. When set, the consent shows up in that policy's analytics. |
ip_address | string | Visitor's IP for audit trail. Recommended for GDPR Art. 7(1) demonstrability. |
user_agent | string | Visitor's user-agent string for audit trail. |
curl -X POST https://policyforge.co/api/v1/consent \
-H "Authorization: Bearer $POLICYFORGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"user_id": "user_47821",
"policy_version": "v2.1",
"policy_id": "8331bf67-3748-471f-b595-9ba55a972cd5",
"consents": { "tos": true, "marketing": false },
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0 ..."
}'{
"id": "fed536fe-d897-4691-b438-3511b6367e7d",
"user_id": "user_47821",
"policy_version": "v2.1",
"policy_id": "8331bf67-3748-471f-b595-9ba55a972cd5",
"consents": {
"tos": true,
"marketing": false
},
"recorded_at": "2026-05-08T22:25:59.637+00:00",
"status": "recorded"
}{
"error": "policy_id must be a UUID"
}{
"error": "policy_id not found or not owned by this API key"
}/api/v1/consentList your consent records, paginated and filterable.
Returns consent records owned by your API key, newest first. Mix-and-match filters to narrow the result set; combine with cursor for stable pagination.
Each row carries a source field ("banner" or "api") so you can split cookie-banner consents from policy consents in your audit reports without a second request.
Headers
| Field | Type | Description |
|---|---|---|
Authorizationrequired | string | Bearer pf_… |
Query parameters
| Field | Type | Description |
|---|---|---|
from | string (ISO 8601) | Lower bound on `recorded_at`, inclusive. Timestamps with a `+HH:MM` offset are accepted as-is — no URL-encoding needed. |
to | string (ISO 8601) | Upper bound on `recorded_at`, inclusive. |
end_user_id | string | Filter to one visitor. Equivalent to GET /api/v1/consent/{user_id} but with the same paginated shape. |
policy_id | uuid | Filter to consents linked to a specific policy. Set on banner consents (when banner has a linked policy), hosted-policy 'I Agree' clicks, and API consents that included `policy_id`. |
policy_version | string | Exact match against the version stamp on the record. |
banner_config_id | uuid | Filter to consents recorded via a specific banner. |
source | enum("banner", "api") | banner = recorded via the embeddable cookie banner. api = recorded via POST /api/v1/consent or a hosted-policy "I Agree" click. |
limit | integer default: 50 | Page size. Max 200, min 1. |
cursor | string | Opaque base64url cursor returned as `next_cursor` in the previous page. Pass it back as `?cursor=…` to fetch the next page. |
# Last 30 days, banner consents only, 100 per page
curl "https://policyforge.co/api/v1/consent?from=2026-04-08T00:00:00Z&source=banner&limit=100" \
-H "Authorization: Bearer $POLICYFORGE_API_KEY"
# Page 2 — pass next_cursor from the previous response
curl "https://policyforge.co/api/v1/consent?source=banner&limit=100&cursor=MjAyNi0w..." \
-H "Authorization: Bearer $POLICYFORGE_API_KEY"{
"consent_records": [
{
"id": "fed536fe-d897-4691-b438-3511b6367e7d",
"user_id": "user_47821",
"policy_version": "v2.1",
"consents": {
"tos": true,
"marketing": false
},
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0 ...",
"source": "api",
"banner_config_id": null,
"policy_id": "8331bf67-3748-471f-b595-9ba55a972cd5",
"recorded_at": "2026-05-08T22:25:59.637+00:00"
}
],
"page_size": 1,
"limit": 50,
"total_count": 1,
"next_cursor": null
}{
"error": "Invalid or inactive API key"
}/api/v1/consent/{user_id}Fetch the full consent history for one end-user.
Convenience for DSAR / right-to-access fulfilment when you have the visitor's user_id. Identical filtering can be done via the list endpoint with ?end_user_id=; this form returns an { user_id, consent_records, total_records } shape and skips pagination since per-user histories are short.
Headers
| Field | Type | Description |
|---|---|---|
Authorizationrequired | string | Bearer pf_… |
Path parameters
| Field | Type | Description |
|---|---|---|
user_idrequired | string | The visitor identifier you used when recording the consent. URL-encode if it contains special characters. |
curl https://policyforge.co/api/v1/consent/user_47821 \
-H "Authorization: Bearer $POLICYFORGE_API_KEY"{
"user_id": "user_47821",
"consent_records": [
{
"id": "fed536fe-d897-4691-b438-3511b6367e7d",
"user_id": "user_47821",
"policy_version": "v2.1",
"consents": {
"tos": true,
"marketing": false
},
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0 ...",
"source": "api",
"banner_config_id": null,
"policy_id": "8331bf67-3748-471f-b595-9ba55a972cd5",
"recorded_at": "2026-05-08T22:25:59.637+00:00"
}
],
"total_records": 1
}/api/v1/consent/policy/{policy_id}/recordNo API keyRecord a hosted-policy consent click. Public — no API key required.
The endpoint our hosted-policy "I Agree" button calls. Customers also call it directly from custom checkout pages or marketing forms when they don't want to mint an API key on the client.
Owner is discovered from the policy itself. The policy must have hosting enabled. CORS is fully open so any origin can POST.
Path parameters
| Field | Type | Description |
|---|---|---|
policy_idrequired | uuid | UUID of a hosting-enabled policy. Anyone can record consent against it — be intentional about which policies you publish. |
Body parameters
| Field | Type | Description |
|---|---|---|
end_user_id | string | Stable visitor identifier. If omitted, a UUID is generated server-side and returned. Persist this client-side (e.g. localStorage) so re-visits stitch into the same identity. |
consents | object<string, boolean> default: { "policy": true } | Override for finer-grained consent boxes (e.g. ToS + privacy + marketing as separate toggles). |
policy_version | string | Defaults to a slug derived from `policies.updated_at` so the consent is tied to the wording in force at click time. Override only if your client has a more specific version stamp. |
curl -X POST \
https://policyforge.co/api/v1/consent/policy/8331bf67-3748-471f-b595-9ba55a972cd5/record \
-H "Content-Type: application/json" \
-d '{
"end_user_id": "eu-l8g4q7y2-x9k3p1m",
"consents": { "policy": true }
}'{
"id": "55e58082-9ad0-4a8a-8ea9-6e5795554356",
"end_user_id": "eu-l8g4q7y2-x9k3p1m",
"recorded_at": "2026-05-08T22:26:03.437+00:00",
"policy_version": "policy-2026-05-08T06-23-48"
}{
"error": "Policy is not hosted"
}{
"error": "Policy not found"
}Webhooks
We POST events to your URL as soon as they happen. Configure endpoints and pick events from your API Dashboard. Every delivery is signed; verify the signature before trusting the payload.
Available events
| Event | Fires when |
|---|---|
policy.created | A new policy was generated. |
policy.updated | A policy's content or metadata was changed. |
policy.published | A policy was set to status='published'. |
policy.archived | A policy was set to status='archived'. |
consent.recorded | A consent event landed in the audit table. Fired for all three input surfaces (banner, hosted-policy, API). |
compliance.alert | A monitored regulatory change may impact your policies (Pro+ tier). |
// Every webhook delivery has this envelope:
{
"event": "consent.recorded",
"timestamp": "2026-05-08T22:25:59.637Z",
"data": {
// event-specific payload — see Available events below
}
}
// Headers we send:
// Content-Type: application/json
// User-Agent: PolicyForge-Webhook/2.0
// X-PolicyForge-Event: consent.recorded
// X-PolicyForge-Delivery: <uuid> (idempotency key)
// X-PolicyForge-Signature: sha256=<hmac>Verifying the signature
We sign each payload with HMAC-SHA256 using your endpoint's secret (visible in the dashboard once on creation). Compare against the X-PolicyForge-Signature header in constant time before processing.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySignature(req: Request, secret: string) {
const signature = req.headers.get("x-policyforge-signature");
if (!signature) return false;
const body = await req.text(); // raw body, not parsed JSON
const expected =
"sha256=" +
createHmac("sha256", secret).update(body).digest("hex");
// Constant-time compare
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
return sigBuf.length === expBuf.length && timingSafeEqual(sigBuf, expBuf);
}X-PolicyForge-Delivery so retries don't create duplicates downstream.