docs / api v1

Build on Policy Controls.

A clickwrap for your sign-up form, an API for your terms, and proofs of record anyone can check. Everything here runs against https://portal.policycontrols.app.

Base URL
https://portal.policycontrols.app/api/v1
Auth
Authorization: Bearer pck_…
Format
application/json

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.

  1. Create an API keypck_…, scoped to clickwrap for one company, shown once. We keep only its hash.
  2. Make a groupThe Public documents to show together, and the origins the widget may run on, e.g. https://app.example.com.
  3. Add the widgetOne element and one script tag in your sign-up form. Build the snippet below.
  4. 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

  1. Sign in to Policy Controls as an Admin. Other roles can't see or make keys.
  2. Go to Sharing → Clickwrap → API keys and choose New API key.
  3. Give it a name that says where it's used, such as Production sign-up.
  4. 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"

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.

Widget
Mode
Group documents set in the app
Code
app.northwind.example/sign-upPreview, demo data

Create your Northwind account

jane@example.com••••••••••
Events

    Attributes

    AttributeDefaultWhat it does
    data-policy-controls-clickwraprequiredMarks the element the widget renders into.
    data-grouprequiredYour 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-optionaloffInline only: the tick box isn't required for the form to submit.
    data-basethe script's originOn 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"
        }
      }'

    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": "…" }

    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"
      }'

    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"

    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"
        }
      }'

    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"

    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"

    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"
      }'

    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"

    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 as PC-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 ETag is 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"

    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"

    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"

    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"

    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"

    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"

    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"

    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)\" }"

    Errors

    Branch on errorCode; errorMessage is for people and may change.

    StatuserrorCodeMeaning
    401invalid_api_keyThe key is missing, unknown or revoked.
    400validationA field is missing or malformed; the message names it.
    400invalid_tokenThe widget token isn't one we issued.
    400token_expiredTokens last 30 minutes; the widget fetches a fresh one for a form left open.
    403origin_not_allowedThe page's origin isn't in the group's allowed origins.
    409token_usedThis token was already confirmed for a different user.
    409token_is_declineA decline token sent to /confirm: send it to /declines.
    409token_is_acceptAn acceptance token sent to /declines: send it to /confirm.
    409group_unavailableOne 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.

    APILimit
    Clickwrap APIAbout 600 requests a minute per IP address, and 300 per API key
    Public document APIAbout 120 requests a minute per IP address. Responses are cached, so honour Cache-Control and ETag
    Verify APIAbout 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.