Quickstart
Everything starts under Sharing → Clickwrap in the app. Nothing is recorded until your server confirms, so the only public endpoint is read-only and there's nothing to spam.
- Create an API key
pck_…, scoped to clickwrap for one company, shown once. We keep only its hash. - Make a groupThe Public documents to show together, and the origins the widget may run on, e.g.
https://app.example.com. - Add the widgetOne element and one script tag in your sign-up form. Build the snippet below.
- Confirm from your serverWhen you create the account, send the token with your key. Only then is the acceptance recorded.
Authentication
The clickwrap API authenticates with an API key, sent as a bearer token. The widget, the public document API and the verify API need no key.
Get an API key
- Sign in to Policy Controls as an Admin. Other roles can't see or make keys.
- Go to Sharing → Clickwrap → API keys and choose New API key.
- Give it a name that says where it's used, such as Production sign-up.
- Copy the key. It looks like
pck_followed by 43 characters, and it's shown only once: we keep just a hash of it, so we can't show it again.
Keep it on your server
A key can record acceptances and read them back for your whole account, so treat it like a password. Put it in an environment variable or your secrets manager, never in browser code, a mobile app or your repository. Use a separate key for each environment or service, so you can revoke one without touching the others.
# .env on your server, never in your repository or front end
POLICY_CONTROLS_KEY=pck_Xq3…Send it with each request
Put it in the Authorization header, as Bearer followed by the key, on every call to /api/v1/clickwrap/….
curl "https://portal.policycontrols.app/api/v1/clickwrap/status?userId=user_123&group=GROUP_ID" \
-H "Authorization: Bearer $POLICY_CONTROLS_KEY"const res = await fetch('https://portal.policycontrols.app/api/v1/clickwrap/status?userId=user_123&group=GROUP_ID', {
headers: {
Authorization: `Bearer ${process.env.POLICY_CONTROLS_KEY}`,
},
})
const body = await res.json()
if (!body.success) throw new Error(`${res.status} ${body.errorCode ?? ''}: ${body.errorMessage}`)
console.log(body.data)200 OK
{
"success": true,
"data": {
"allAccepted": false,
"retired": false,
"documents": [
{ "document": "PC-TRM-001", "accepted": false, "acceptedVersion": "1.3", "acceptedAt": "2025-04-02T10:14:00Z" },
{ "document": "PC-STM-002", "accepted": true, "acceptedVersion": "2.0", "acceptedAt": "2026-10-10T09:30:00Z" }
]
}
}401 Unauthorized
{
"success": false,
"errorCode": "invalid_api_key",
"errorMessage": "Missing, invalid or revoked API key",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}The JavaScript examples use fetch, built into Node 18 and later. A missing, mistyped or revoked key answers 401 with errorCode: "invalid_api_key".
What a key can do
- One account, clickwrap only. A key works for the company it was made in, and only for the clickwrap API.
- No expiry. A key works until it's revoked. The API keys list shows who made each one and when it was last used.
- Revoke at once. Revoking stops the key on the next request. To rotate, make a new key, deploy it, then revoke the old one.
- One answer for every failure. A missing, mistyped or revoked key all get
401 invalid_api_key, so the response never says whether a key exists.
API keys and widget tokens
They're different things. The API key is your secret, for your server. A widget token is what the widget gets from us when the person ticks the box: signed by us, valid for 30 minutes, and posted with your form as policyControlsToken. It records nothing on its own; your server sends it to /confirm with your API key, and only then is the acceptance recorded.
Clickwrap widget
About 4 KB, no dependencies, no cookies, and no inline scripts, styles or innerHTML, so it runs under a strict Content Security Policy. Set the options, try the preview, and copy the code.
Attributes
| Attribute | Default | What it does |
|---|---|---|
data-policy-controls-clickwrap | required | Marks the element the widget renders into. |
data-group | required | Your group ID, from Sharing → Clickwrap. |
data-mode | "modal" | Show a button that opens each document in full, with Accept and Decline. Inline when left out. |
data-label | "Read and accept" | The window mode's button text. |
data-name | "policyControlsToken" | The name of the hidden field the token goes in. |
data-optional | off | Inline only: the tick box isn't required for the form to submit. |
data-base | the script's origin | On the script tag: the origin it calls, for a self-hosted copy. |
Events and state
The widget fires policycontrols:clickwrap on its element, bubbling, withdetail.state of ready, accepted, declined orerror, and detail.token once there is one. The same is available onwindow.PolicyControlsClickwrap: state(el), token(el), andmount(root) for widgets added after the page loads.
Every token request also sends the browser's time zone, UTC offset and language. They're kept with the record as supporting evidence, labelled as reported by the browser.
Styling
Plain DOM with class names, no styles of its own: pc-clickwrap,__checkbox, __label, __status, __optional, and in the window mode __open, __dialog, __document,__frame, __statement, __accept and __decline.
Confirm from your server
The token in the form describes exactly what was shown, signed by us. When you create the account, send it with the user's ID. Retries are safe: each acceptance is recorded once per user and document version.
curl -X POST "https://portal.policycontrols.app/api/v1/clickwrap/confirm" \
-H "Authorization: Bearer $POLICY_CONTROLS_KEY" \
-H "Content-Type: application/json" \
-d '{
"token": "<policyControlsToken from the form>",
"user": {
"id": "user_123",
"email": "jane@example.com",
"name": "Jane Smith"
}
}'const res = await fetch('https://portal.policycontrols.app/api/v1/clickwrap/confirm', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POLICY_CONTROLS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: "<policyControlsToken from the form>",
user: {
id: "user_123",
email: "jane@example.com",
name: "Jane Smith"
}
}),
})
const body = await res.json()
if (!body.success) throw new Error(`${res.status} ${body.errorCode ?? ''}: ${body.errorMessage}`)
console.log(body.data)201 Created
{
"success": true,
"data": {
"userId": "user_123",
"acceptances": [
{
"created": true,
"receiptId": "kX9mQ2vB7nLp4RtW8yZc",
"document": "PC-TRM-001",
"documentId": "x7Kd2…",
"title": "Terms of Service",
"version": "2.1",
"contentHash": "9f3c51e0…44c2b9",
"statement": "I agree to the Terms of Service v2.1 and Privacy Notice v1.4",
"acceptedAt": "2026-10-10T09:30:00.000Z",
"recordedAt": "2026-10-10T09:30:01.412Z",
"verifiedAt": "2026-10-10T09:30:01.412Z",
"timeline": [
{ "step": "accepted", "label": "Accepted", "at": "2026-10-10T09:30:00.000Z", "source": "widget" },
{ "step": "verified", "label": "Verified", "at": "2026-10-10T09:30:01.412Z", "source": "server", "note": "confirmed by the customer’s server" }
],
"method": "widget",
"user": { "id": "user_123", "email": "jane@example.com", "name": "Jane Smith" },
"ipAddress": "203.0.113.7",
"userAgent": "Mozilla/5.0 …",
"location": { "country": "GB", "city": "London" },
"device": { "browser": "Chrome 141", "os": "macOS", "type": "desktop" },
"client": { "timeZone": "Europe/London", "utcOffsetMinutes": 60, "language": "en-GB" },
"groupId": "Gp4…",
"origin": "https://app.example.com"
}
]
}
}409 Conflict
{
"success": false,
"errorCode": "token_used",
"errorMessage": "This widget token was already confirmed for another user",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}Clickwrap API
From your server only, with your key as a bearer token (see Authentication). Rate-limited per key and per IP address (see Limits and versions). Every response carries an x-correlation-id header; quote it if you ask us about a request.
{ "success": true, "data": { … } }
{ "success": false, "errorCode": "…", "errorMessage": "…", "correlationId": "…" }- POST
/api/v1/clickwrap/confirmConfirm a widget token - POST
/api/v1/clickwrap/acceptancesRecord an acceptance without the widget - POST
/api/v1/clickwrap/declinesRecord a decline - GET
/api/v1/clickwrap/statusHas this user accepted the current versions? - GET
/api/v1/clickwrap/acceptances/{receiptId}Get one acceptance - GET
/api/v1/clickwrap/acceptances/{receiptId}/receiptDownload its proof of record - DELETE
/api/v1/clickwrap/users/{userId}Retire a user - POST
/api/v1/clickwrap/users/{userId}/reinstateReinstate a retired user
Record an acceptance without the widget
For flows of your own. acceptedAt defaults to now and can't be in the future or before the version was approved. The location comes from ip.
curl -X POST "https://portal.policycontrols.app/api/v1/clickwrap/acceptances" \
-H "Authorization: Bearer $POLICY_CONTROLS_KEY" \
-H "Content-Type: application/json" \
-d '{
"documents": [
"PC-TRM-001",
{
"document": "PC-STM-002",
"version": "2.0"
}
],
"user": {
"id": "user_123",
"email": "jane@example.com",
"organisation": "Acme Ltd"
},
"ip": "203.0.113.7",
"userAgent": "Mozilla/5.0 …",
"acceptedAt": "2026-10-10T09:30:00Z"
}'const res = await fetch('https://portal.policycontrols.app/api/v1/clickwrap/acceptances', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POLICY_CONTROLS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
documents: [
"PC-TRM-001",
{
document: "PC-STM-002",
version: "2.0"
}
],
user: {
id: "user_123",
email: "jane@example.com",
organisation: "Acme Ltd"
},
ip: "203.0.113.7",
userAgent: "Mozilla/5.0 …",
acceptedAt: "2026-10-10T09:30:00Z"
}),
})
const body = await res.json()
if (!body.success) throw new Error(`${res.status} ${body.errorCode ?? ''}: ${body.errorMessage}`)
console.log(body.data)201 Created
{
"success": true,
"data": {
"userId": "user_123",
"acceptances": [
{
"created": true,
"receiptId": "kX9mQ2vB7nLp4RtW8yZc",
"document": "PC-TRM-001",
"documentId": "x7Kd2…",
"title": "Terms of Service",
"version": "2.1",
"contentHash": "9f3c51e0…44c2b9",
"statement": "I confirm I have read and agree to Terms of Service v2.1 on behalf of Acme Ltd, and that I have the authority to agree to it on Acme Ltd's behalf.",
"acceptedAt": "2026-10-10T09:30:00.000Z",
"recordedAt": "2026-10-10T09:30:01.412Z",
"verifiedAt": "2026-10-10T09:30:01.412Z",
"timeline": [
{ "step": "accepted", "label": "Accepted", "at": "2026-10-10T09:30:00.000Z", "source": "customer" },
{ "step": "verified", "label": "Verified", "at": "2026-10-10T09:30:01.412Z", "source": "server", "note": "received through the API" }
],
"method": "api",
"user": { "id": "user_123", "email": "jane@example.com", "organisation": "Acme Ltd" },
"ipAddress": "203.0.113.7",
"userAgent": "Mozilla/5.0 …",
"location": { "country": "GB", "city": "London" },
"device": { "browser": "Chrome 141", "os": "macOS", "type": "desktop" },
"client": null
}
]
}
}400 Bad Request
{
"success": false,
"errorMessage": "PC-STM-002 v2.0 is not an approved version",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}Check status at sign-in
By documents (numbers or IDs, comma-separated) or group. A minor version keeps an acceptance current; a major one doesn't, so ask the user again whenallAccepted is false. Optional documents left unaccepted don't count.
curl "https://portal.policycontrols.app/api/v1/clickwrap/status?userId=user_123&documents=PC-TRM-001,PC-STM-002" \
-H "Authorization: Bearer $POLICY_CONTROLS_KEY"const res = await fetch('https://portal.policycontrols.app/api/v1/clickwrap/status?userId=user_123&documents=PC-TRM-001,PC-STM-002', {
headers: {
Authorization: `Bearer ${process.env.POLICY_CONTROLS_KEY}`,
},
})
const body = await res.json()
if (!body.success) throw new Error(`${res.status} ${body.errorCode ?? ''}: ${body.errorMessage}`)
console.log(body.data)200 OK
{
"success": true,
"data": {
"allAccepted": false,
"retired": false,
"documents": [
{ "document": "PC-TRM-001", "accepted": false, "acceptedVersion": "1.3", "acceptedAt": "2025-04-02T10:14:00Z" },
{ "document": "PC-STM-002", "accepted": true, "acceptedVersion": "2.0", "acceptedAt": "2026-10-10T09:30:00Z" }
]
}
}404 Not Found
{
"success": false,
"errorMessage": "Document not found: PC-STM-009",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}Record a decline
In the window mode, Decline gives a token, like Accept: send it here. From your own flow, senddocuments instead of token, optionally with ip,userAgent, declinedAt and client. The status call then answers declined: true for that version until they accept it. Declines never touch acceptances, and repeating one is safe.
curl -X POST "https://portal.policycontrols.app/api/v1/clickwrap/declines" \
-H "Authorization: Bearer $POLICY_CONTROLS_KEY" \
-H "Content-Type: application/json" \
-d '{
"token": "<policyControlsToken from the form>",
"user": {
"id": "user_123",
"email": "jane@example.com",
"name": "Jane Smith"
}
}'const res = await fetch('https://portal.policycontrols.app/api/v1/clickwrap/declines', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POLICY_CONTROLS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: "<policyControlsToken from the form>",
user: {
id: "user_123",
email: "jane@example.com",
name: "Jane Smith"
}
}),
})
const body = await res.json()
if (!body.success) throw new Error(`${res.status} ${body.errorCode ?? ''}: ${body.errorMessage}`)
console.log(body.data)201 Created
{
"success": true,
"data": {
"userId": "user_123",
"declines": [
{
"declineId": "b2LwQ9…",
"created": true,
"document": "PC-TRM-001",
"documentId": "x7Kd2…",
"title": "Terms of Service",
"version": "2.1",
"contentHash": "9f3c51e0…44c2b9",
"statement": "I agree to the Terms of Service v2.1",
"declinedAt": "2026-10-12T08:15:00.000Z",
"recordedAt": "2026-10-12T08:15:02.031Z",
"verifiedAt": "2026-10-12T08:15:02.031Z",
"timeline": [
{ "step": "declined", "label": "Declined", "at": "2026-10-12T08:15:00.000Z", "source": "widget" },
{ "step": "verified", "label": "Verified", "at": "2026-10-12T08:15:02.031Z", "source": "server", "note": "confirmed by the customer’s server" }
],
"method": "widget",
"user": { "id": "user_123", "email": "jane@example.com", "name": "Jane Smith" }
}
]
}
}409 Conflict
{
"success": false,
"errorCode": "token_is_accept",
"errorMessage": "This token records an acceptance: send it to /confirm instead",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}Get one acceptance
By the receiptId from recording it or from the status call: the statement, version and content hash, and where and how it was accepted.
curl "https://portal.policycontrols.app/api/v1/clickwrap/acceptances/kX9mQ2vB7nLp4RtW8yZc" \
-H "Authorization: Bearer $POLICY_CONTROLS_KEY"const res = await fetch('https://portal.policycontrols.app/api/v1/clickwrap/acceptances/kX9mQ2vB7nLp4RtW8yZc', {
headers: {
Authorization: `Bearer ${process.env.POLICY_CONTROLS_KEY}`,
},
})
const body = await res.json()
if (!body.success) throw new Error(`${res.status} ${body.errorCode ?? ''}: ${body.errorMessage}`)
console.log(body.data)200 OK
{
"success": true,
"data": {
"receiptId": "kX9mQ2vB7nLp4RtW8yZc",
"document": "PC-TRM-001",
"documentId": "x7Kd2…",
"title": "Terms of Service",
"version": "2.1",
"contentHash": "9f3c51e0…44c2b9",
"statement": "I agree to the Terms of Service v2.1 and Privacy Notice v1.4",
"acceptedAt": "2026-10-10T09:30:00.000Z",
"recordedAt": "2026-10-10T09:30:01.412Z",
"verifiedAt": "2026-10-10T09:30:01.412Z",
"timeline": [
{ "step": "accepted", "label": "Accepted", "at": "2026-10-10T09:30:00.000Z", "source": "widget" },
{ "step": "verified", "label": "Verified", "at": "2026-10-10T09:30:01.412Z", "source": "server", "note": "confirmed by the customer’s server" }
],
"method": "widget",
"user": { "id": "user_123", "email": "jane@example.com", "name": "Jane Smith" },
"ipAddress": "203.0.113.7",
"userAgent": "Mozilla/5.0 …",
"location": { "country": "GB", "city": "London" },
"device": { "browser": "Chrome 141", "os": "macOS", "type": "desktop" },
"client": { "timeZone": "Europe/London", "utcOffsetMinutes": 60, "language": "en-GB" },
"groupId": "Gp4…",
"origin": "https://app.example.com"
}
}404 Not Found
{
"success": false,
"errorMessage": "Acceptance not found",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}Download its proof of record
The signed PDF described under Proofs of record. Acceptances from before proofs of record get a receipt PDF instead.
curl -o proof-of-record.pdf "https://portal.policycontrols.app/api/v1/clickwrap/acceptances/kX9mQ2vB7nLp4RtW8yZc/receipt" \
-H "Authorization: Bearer $POLICY_CONTROLS_KEY"import { writeFile } from 'node:fs/promises'
const res = await fetch('https://portal.policycontrols.app/api/v1/clickwrap/acceptances/kX9mQ2vB7nLp4RtW8yZc/receipt', {
headers: {
Authorization: `Bearer ${process.env.POLICY_CONTROLS_KEY}`,
},
})
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).errorMessage}`)
await writeFile('proof-of-record.pdf', Buffer.from(await res.arrayBuffer()))200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="proof-of-record-POR-7K2M-Q9XD-4HFA-R3TB.pdf"
(the signed PDF)404 Not Found
{
"success": false,
"errorMessage": "Acceptance not found",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}Retire a user
When you delete or disable someone: they get no more update emails about your documents and aren't counted as needing to accept again, and the status call answers retired: true. Their acceptances stay, as evidence. The reason is optional, and repeating the call is safe.
curl -X DELETE "https://portal.policycontrols.app/api/v1/clickwrap/users/user_123" \
-H "Authorization: Bearer $POLICY_CONTROLS_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "Account deleted"
}'const res = await fetch('https://portal.policycontrols.app/api/v1/clickwrap/users/user_123', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POLICY_CONTROLS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
reason: "Account deleted"
}),
})
const body = await res.json()
if (!body.success) throw new Error(`${res.status} ${body.errorCode ?? ''}: ${body.errorMessage}`)
console.log(body.data)200 OK
{
"success": true,
"data": { "userId": "user_123", "retired": true, "retiredAt": "2026-11-01T09:00:00.000Z" }
}404 Not Found
{
"success": false,
"errorMessage": "No acceptances are recorded for this user",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}Reinstate a user
They get update emails again and count as needing to accept new versions. Recording a new acceptance reinstates them too, so you only need this when they come back without accepting anything.
curl -X POST "https://portal.policycontrols.app/api/v1/clickwrap/users/user_123/reinstate" \
-H "Authorization: Bearer $POLICY_CONTROLS_KEY"const res = await fetch('https://portal.policycontrols.app/api/v1/clickwrap/users/user_123/reinstate', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POLICY_CONTROLS_KEY}`,
},
})
const body = await res.json()
if (!body.success) throw new Error(`${res.status} ${body.errorCode ?? ''}: ${body.errorMessage}`)
console.log(body.data)200 OK
{
"success": true,
"data": { "userId": "user_123", "retired": false, "retiredAt": null }
}404 Not Found
{
"success": false,
"errorMessage": "No acceptances are recorded for this user",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}Public document API
The approved text of any document classified Public, for your help pages, your app or your own sign-up flow. No key needed, and open to any origin. Every request is a GET to the same address; query parameters choose the version and the format.
GET /api/v1/public/companies/{companyId}/documents/{docNumber}companyId: your company's ID, shown with the exact address on each document's API tab in the app.docNumber: the document number, such asPC-LEG-001, or the document's ID.- Only what's public. Drafts are never served, and a document that isn't Public answers
404, exactly like one that doesn't exist. - Cached. 5 minutes for the latest version, an hour for a named one. The
ETagis the version's content hash.
Get the latest version
With no parameters you get the latest approved version as JSON: its text, dates, change summary, content hash and the list of every version. Documents written in the app carrymarkdown; uploaded PDFs carry a pdf object and their extractedtext; tables carry a table with its columns and rows. formatsays which.
curl "https://portal.policycontrols.app/api/v1/public/companies/northwind/documents/PC-LEG-001"const res = await fetch('https://portal.policycontrols.app/api/v1/public/companies/northwind/documents/PC-LEG-001')
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).errorMessage}`)
const doc = await res.json()200 OK
Cache-Control: public, max-age=300
ETag: "9f3c51e0…44c2b9"
{
"company": { "id": "northwind", "name": "Northwind Analytics" },
"docNumber": "PC-LEG-001",
"title": "Terms of Service",
"version": "2.1",
"isLatest": true,
"latestVersion": "2.1",
"approvedAt": "2026-09-01T09:00:00.000Z",
"changeSummary": "Clarified refund timings",
"contentHash": "9f3c51e0…44c2b9",
"contentHashAlgorithm": "SHA-256",
"format": "markdown",
"versions": [
{ "version": "2.1", "approvedAt": "2026-09-01T09:00:00.000Z", … },
{ "version": "2.0", "approvedAt": "2026-06-12T14:20:00.000Z", … }
],
"markdown": "## 1. About these terms…",
"pdfUrl": "https://portal.policycontrols.app/api/v1/public/companies/northwind/documents/PC-LEG-001?v=2.1&format=pdf"
}404 Not Found
{
"success": false,
"errorMessage": "Document not found",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}Get an earlier version
Add v with the version number (2.0 or v2.0) to show exactly what someone saw then. The response is the same shape, with isLatest: false andlatestVersion telling you what's current. Combine it with any format below.
curl "https://portal.policycontrols.app/api/v1/public/companies/northwind/documents/PC-LEG-001?v=2.0"const res = await fetch('https://portal.policycontrols.app/api/v1/public/companies/northwind/documents/PC-LEG-001?v=2.0')
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).errorMessage}`)
const doc = await res.json()200 OK
Cache-Control: public, max-age=3600
ETag: "9f3c51e0…44c2b9"
{
"company": { "id": "northwind", "name": "Northwind Analytics" },
"docNumber": "PC-LEG-001",
"title": "Terms of Service",
"version": "2.0",
"isLatest": false,
"latestVersion": "2.1",
"approvedAt": "2026-06-12T14:20:00.000Z",
"changeSummary": "New liability cap",
"contentHash": "9f3c51e0…44c2b9",
"contentHashAlgorithm": "SHA-256",
"format": "markdown",
"versions": [
{ "version": "2.1", "approvedAt": "2026-09-01T09:00:00.000Z", … },
{ "version": "2.0", "approvedAt": "2026-06-12T14:20:00.000Z", … }
],
"markdown": "## 1. About these terms…",
"pdfUrl": "https://portal.policycontrols.app/api/v1/public/companies/northwind/documents/PC-LEG-001?v=2.0&format=pdf"
}404 Not Found
{
"success": false,
"errorMessage": "Version 1.7 not found",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}Get just the text, as Markdown
format=md returns the Markdown alone, ready for your own renderer. For documents written in the app only: an uploaded PDF or a table answers 400 and points you to its other formats.
curl "https://portal.policycontrols.app/api/v1/public/companies/northwind/documents/PC-LEG-001?format=md"const res = await fetch('https://portal.policycontrols.app/api/v1/public/companies/northwind/documents/PC-LEG-001?format=md')
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).errorMessage}`)
const markdown = await res.text()200 OK
Content-Type: text/markdown; charset=utf-8
## 1. About these terms
These terms apply to your use of the Northwind service…415 Unsupported Media Type
{
"success": false,
"errorCode": "pdf_document",
"errorMessage": "This is a PDF document, so it has no Markdown. Use format=pdf for the PDF, or the JSON for its text.",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}Get the PDF
format=pdf returns the version as a PDF, for any kind of document.
curl -o terms.pdf "https://portal.policycontrols.app/api/v1/public/companies/northwind/documents/PC-LEG-001?format=pdf"import { writeFile } from 'node:fs/promises'
const res = await fetch('https://portal.policycontrols.app/api/v1/public/companies/northwind/documents/PC-LEG-001?format=pdf')
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).errorMessage}`)
await writeFile('terms.pdf', Buffer.from(await res.arrayBuffer()))200 OK
Content-Type: application/pdf
Content-Disposition: inline; filename="PC-LEG-001-v2.1.pdf"
(the PDF)404 Not Found
{
"success": false,
"errorMessage": "Document not found",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}Get the original uploaded PDF
For uploaded PDFs only: format=original returns the file exactly as it was uploaded, byte for byte, so its SHA-256 matches the version's contentHash.
curl -o dpa-original.pdf "https://portal.policycontrols.app/api/v1/public/companies/northwind/documents/PC-LEG-004?format=original"import { writeFile } from 'node:fs/promises'
const res = await fetch('https://portal.policycontrols.app/api/v1/public/companies/northwind/documents/PC-LEG-004?format=original')
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).errorMessage}`)
await writeFile('dpa-original.pdf', Buffer.from(await res.arrayBuffer()))200 OK
Content-Type: application/pdf
Content-Disposition: inline; filename="PC-LEG-004-v1.0-original.pdf"
(the file exactly as uploaded)400 Bad Request
{
"success": false,
"errorMessage": "format=original is only available for PDF documents",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}Get a table's rows as CSV
For table documents only, such as a register: format=csv returns the version's rows as CSV. The JSON has the same rows, with the columns described.
curl -o suppliers.csv "https://portal.policycontrols.app/api/v1/public/companies/northwind/documents/PC-REG-003?format=csv"import { writeFile } from 'node:fs/promises'
const res = await fetch('https://portal.policycontrols.app/api/v1/public/companies/northwind/documents/PC-REG-003?format=csv')
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).errorMessage}`)
await writeFile('suppliers.csv', Buffer.from(await res.arrayBuffer()))200 OK
Content-Type: text/csv; charset=utf-8
Supplier,Service,Data shared,Contract ends
Mailgun,Email delivery,Email addresses,2027-03-31
…400 Bad Request
{
"success": false,
"errorMessage": "format=csv is only available for table documents",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}Proofs of record
Each acceptance gets a PDF made once: the document, version and content hash, the statement, the time, IP address and location, browser and device, and the browser's time zone and language. It's signed with a Cloud KMS key (RSA 2048, SHA-256) and stored write-once. Download it from/clickwrap/acceptances/{receiptId}/receipt.
The timeline
The proof, the verify page and every acceptance and decline the API returns carry onetimeline: the moments in order, each in ISO 8601 UTC (UK time too, on the proof) and with its source, whose clock gave the time. Presented (when the widget showed the terms, once that's recorded), Accepted or Declined (the widget's tick, widget; or the time your server sent, customer; or ours on receipt if you sent none, server), Verified (when we recorded it: confirmed by your server through /confirm or /declines, or received through the API) andProof issued. Acceptance links show Sent, First opened, Accepted and Proof issued.
verifiedAt is the same time as recordedAt, named as on the timeline; both stay. Proofs issued before the timeline keep their separate Accepted at and Proof issued rows.
Anyone can check one at portal.policycontrols.app/verify, or with the verify API. It says which account, document, version and time, whether the file matches, and whether the document version is unchanged. It never shows who accepted.
Check by reference
curl "https://portal.policycontrols.app/api/v1/verify/POR-7K2M-Q9XD-4HFA-R3TB"const res = await fetch('https://portal.policycontrols.app/api/v1/verify/POR-7K2M-Q9XD-4HFA-R3TB')
const body = await res.json()
if (!body.success) throw new Error(`${res.status} ${body.errorCode ?? ''}: ${body.errorMessage}`)
console.log(body.data)200 OK
{
"success": true,
"data": {
"reference": "POR-7K2M-Q9XD-4HFA-R3TB",
"company": "Northwind Analytics",
"document": { "number": "PC-TRM-001", "title": "Terms of Service", "version": "2.1", "contentHash": "9f3c51e0…44c2b9" },
"kind": "clickwrap",
"acceptedAt": "2026-10-11T14:32:05.000Z",
"issuedAt": "2026-10-11T14:32:09.000Z",
"timeline": [
{ "step": "accepted", "label": "Accepted", "at": "2026-10-11T14:32:05.000Z", "source": "widget" },
{ "step": "verified", "label": "Verified", "at": "2026-10-11T14:32:07.215Z", "source": "server", "note": "confirmed by the customer’s server" },
{ "step": "issued", "label": "Proof issued", "at": "2026-10-11T14:32:09.000Z", "source": "server" }
],
"signed": true,
"sha256": "c41d7a…e09b3f",
"documentUnchanged": true
}
}404 Not Found
{
"success": false,
"errorMessage": "No proof of record matches. Check the reference, or the file is not one we issued.",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}Check a file by its hash
Hash the PDF yourself and send the SHA-256. fileMatches says whether it's exactly the file we issued; any change to it, even re-saving, gives a different hash.
curl -X POST "https://portal.policycontrols.app/api/v1/verify" \
-H "Content-Type: application/json" \
-d "{ \"sha256\": \"$(shasum -a 256 proof-of-record.pdf | cut -d' ' -f1)\" }"import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
const sha256 = createHash('sha256').update(await readFile('proof-of-record.pdf')).digest('hex')
const res = await fetch('https://portal.policycontrols.app/api/v1/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sha256 }),
})
const body = await res.json()
if (!body.success) throw new Error(`${res.status} ${body.errorCode ?? ''}: ${body.errorMessage}`)
console.log(body.data.fileMatches) // true: exactly the file we issued200 OK
{
"success": true,
"data": {
"reference": "POR-7K2M-Q9XD-4HFA-R3TB",
…
"documentUnchanged": true,
"fileMatches": true
}
}400 Bad Request
{
"success": false,
"errorCode": "validation",
"errorMessage": "sha256: Expected a SHA-256 in hex",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
}Errors
Branch on errorCode; errorMessage is for people and may change.
| Status | errorCode | Meaning |
|---|---|---|
401 | invalid_api_key | The key is missing, unknown or revoked. |
400 | validation | A field is missing or malformed; the message names it. |
400 | invalid_token | The widget token isn't one we issued. |
400 | token_expired | Tokens last 30 minutes; the widget fetches a fresh one for a form left open. |
403 | origin_not_allowed | The page's origin isn't in the group's allowed origins. |
409 | token_used | This token was already confirmed for a different user. |
409 | token_is_decline | A decline token sent to /confirm: send it to /declines. |
409 | token_is_accept | An acceptance token sent to /declines: send it to /confirm. |
409 | group_unavailable | One of the group's documents is no longer public, active and approved. |
Limits and versions
Rate limits
Over a limit, a call answers 429 with an error body. Wait a minute and try again, ideally backing off. The limits are approximate: they're counted per server, and we may change them to protect the service.
| API | Limit |
|---|---|
| Clickwrap API | About 600 requests a minute per IP address, and 300 per API key |
| Public document API | About 120 requests a minute per IP address. Responses are cached, so honour Cache-Control and ETag |
| Verify API | About 60 requests a minute per IP address |
Request bodies to the clickwrap API can be up to 64 KB.
Versions
Every path starts /api/v1. Within v1 we only add things: new endpoints, new optional fields in requests, new fields in responses and new errorCode values. So ignore fields you don't recognise, and treat an unknown errorCode by its HTTP status. A change that would break an integration comes as a new version path, and v1 keeps working alongside it.
OpenAPI
The whole API is described in openapi.json (OpenAPI 3.1), for client generators, Postman or your own tests. It's checked against the API's code in our tests, so it can't drift from what the API does.