Skip to main content

Click API Quickstart

Deploy an agreement and record a user's acceptance of it from your own backend.

Click works server to server: your backend renders the agreement, shows it to the user in your own page or app, and records the acceptance with the checksum of what it rendered. There is no browser SDK or embeddable script, and the render and accept calls need a token, so they are never made from a browser.

Prerequisites​

  • A Propper account with Click
  • An OAuth client from the dashboard, with the click:read, click:write and click:accept scopes
  • A backend service that can call https://api.propper.ai and keep the client secret

Step 1: Get an Access Token​

Exchange your client credentials for an access token. click:write creates the deployment, click:read renders it and reads receipts, and click:accept records the acceptance. A production backend that only records acceptances needs click:accept alone: see Limits and retries.

curl -X POST "https://auth.propper.ai/oauth2/token" \
-H "Content-Type: application/json" \
-d '{
"grant_type": "client_credentials",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"scope": "click:read click:write click:accept"
}'

Response:

{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "click:read click:write click:accept"
}

Step 2: Create, Write and Publish a Template​

Everything in this step is an API call: create the template, write its content into the draft, then publish the draft as a version.

Create a template. It starts as a draft with no content, and the response carries its id.

curl -X POST "https://api.propper.ai/v1/click/templates" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "Terms of Service", "slug": "terms-of-service" }'

Write the draft with PUT /v1/click/templates/{templateId}/draft. Each entry in locales replaces that locale's title, body, button and checkbox texts, and acknowledgments; locales you do not name are left as they are. A text you leave out is cleared.

The body is HTML with text and structure only: p, br, hr, strong, b, em, i, u, s, blockquote, ul, ol, li, h1 to h6, a, and tables. There are no attributes except a link's href, which must be https or mailto. Formatting is normalized when the draft is saved: <br> is stored as <br /> and & as &amp;, and the draft reads back in that form, which is exactly what signers are shown. A body that carries markup that would have to be removed, such as a <script>, an onclick attribute or a tag outside the list, is refused with 422 CLICK_CONTENT_UNSAFE; the message names what would be removed, and nothing is written. Publishing applies the same rule.

curl -X PUT "https://api.propper.ai/v1/click/templates/TEMPLATE_ID/draft" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"locales": [
{
"locale": "en-US",
"title": "Terms of Service",
"body": "<p>You agree to the Terms of Service &amp; Privacy Policy.</p>",
"texts": { "acceptButtonText": "I agree", "checkboxText": "I have read the terms" },
"acknowledgments": [{ "labelText": "I am authorised to accept for my company" }]
}
]
}'

The response is the draft as it will be published, and GET /v1/click/templates/{templateId}/draft reads it back. Writing the draft changes nothing a signer is shown: deployments serve published versions only.

Publishing creates an immutable version. The first publish is 1.0.0 and must be a MAJOR; you choose the level of every later one and give a reason, which is recorded.

curl -X POST "https://api.propper.ai/v1/click/templates/TEMPLATE_ID/versions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "level": "MAJOR", "reason": "Initial publication" }'

The response is the version, with its id and its number in version (1.0.0). You can preview a publish first with POST /v1/click/templates/{templateId}/versions/preview, which suggests a level and shows what changed without writing anything. A template whose draft has no content in any locale is refused with 422 CLICK_TEMPLATE_NOT_PUBLISHABLE. You can also author and edit the same draft in the dashboard: see Creating Templates.

Step 3: Create a Deployment​

Deploy the published version. A deployment is addressed by its ID alone: domain and path are recorded for allowlisting and reporting, and are not how you look it up.

Send the version ID in versionId. The deployment serves that version, as revision 1, until you move it to another version or roll it back; a newly published minor or patch of the same major reaches it automatically, and a new major waits until you move it (POST /v1/click/deployments/{id}/move).

curl -X POST "https://api.propper.ai/v1/click/deployments" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"versionId": "VERSION_ID",
"domain": "www.example.com",
"path": "/terms",
"environment": "production"
}'

Response (abridged):

{
"id": "c54f4b8c-d36d-4670-855d-f454c8d3b7a8",
"templateId": "123e4567-e89b-12d3-a456-426614174000",
"domain": "www.example.com",
"path": "/terms",
"environment": "PRODUCTION",
"active": true
}

Step 4: Render the Agreement​

Your backend asks the Render API for what the deployment serves. The response carries the agreement content, the checksum of that exact content, the versionId, and the locale it was served in.

curl "https://api.propper.ai/v1/click/render/deployment/$DEPLOYMENT_ID?locale=en-US" \
-H "Authorization: Bearer $TOKEN"

Response (abridged):

{
"deploymentId": "c54f4b8c-d36d-4670-855d-f454c8d3b7a8",
"versionId": "456e7890-e89b-12d3-a456-426614174001",
"title": "Terms of Service",
"content": "<div class=\"terms\">...</div>",
"checksum": "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3",
"locale": "en-US"
}

Every render is counted as a view of the deployment.

Step 5: Record the Acceptance​

When the user accepts, your backend sends the Accept API the checksum it rendered, and the locale it was rendered in as metadata.locale. The API checks the checksum against what the deployment serves for that locale and rejects the acceptance with 422 CLICK_CHECKSUM_MISMATCH if they differ, so an acceptance can only be recorded for content that was actually shown.

  • Send the checksum the render returned. Do not compute it yourself, and do not send an empty one.
  • Always send metadata.locale. When it is omitted, the checksum is checked against the default locale, so a checksum rendered for another locale is rejected.
  • Send an X-Idempotency-Key that is new for each acceptance and the same on every retry of it: see Limits and retries.
curl -X POST "https://api.propper.ai/v1/click/accept" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: $(uuidgen)" \
-d '{
"deploymentId": "DEPLOYMENT_ID",
"templateVersionId": "VERSION_ID",
"checksum": "RENDERED_CHECKSUM",
"consentMethod": "BUTTON",
"userId": "user-123",
"metadata": { "locale": "en-US" }
}'

Response:

{
"receiptId": "550e8400-e29b-41d4-a716-446655440002",
"acceptedAt": "2026-01-15T10:30:00.000Z",
"message": "Acceptance recorded successfully",
"evidenceStatus": "pending"
}

Step 6: Verify the Acceptance​

Look the acceptance up by the receiptId the Accept API returned, then verify its evidence. Evidence is built asynchronously, so evidenceStatus is pending for a short time after the acceptance and complete once the signed evidence exists.

Two different answers are easy to confuse here:

  • A receipt ID that does not exist in your organization, including one that belongs to another organization, is a 404 with errorCode: "CLICK_NOT_FOUND". There is no verification result to read.
  • valid: false in a 200 response means only that the evidence failed verification. Read errors and checks to see which check failed.
# The receipt: when it was accepted, the content hash, and the evidence status
curl "https://api.propper.ai/v1/click/receipts/$RECEIPT_ID" \
-H "Authorization: Bearer $TOKEN"

# Verify the evidence: checks the hash chain, the signature and the environment
curl "https://api.propper.ai/v1/click/evidence/verify/receipt/$RECEIPT_ID" \
-H "Authorization: Bearer $TOKEN"

Step 7: Ask Whether a Signer Is Current​

Before you show the agreement again, ask whether the signer has already accepted the major the deployment serves. Pass the same userId you sent with the acceptance as userRef, in the body: it is a read sent as a POST, so the signer reference never appears in a URL or a request log.

curl -X POST "https://api.propper.ai/v1/click/deployments/$DEPLOYMENT_ID/signer-status" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"userRef": "user-123"}'
  • status: "current": the signer has accepted the major this deployment serves. A minor or patch since then does not change that, and neither does a later acceptance of another major.
  • status: "stale" with reason: "accepted_other_major": show the agreement again. change carries the reason, the summary and the re-acceptance gate and deadline of the new major, and change.sinceAccepted says what changed since the version the signer accepted.
  • status: "stale" with reason: "never_accepted" and accepted: null: the signer has never accepted this template in this environment.

Acceptances through any deployment of the same template in the same environment count, and a rollback to a major the signer never accepted makes them stale. It needs the click:read scope.

Step 8: Host the Agreement on a Propper Page​

If you would rather not build the page yourself, send the signer to a page Propper hosts. It works in a browser, a desktop app's browser window and a mobile web view, with no SDK. Your backend mints a link for one signer, sends them to it, and reads the outcome afterwards. Hosted links serve static agreements only.

Mint the link with a click:accept token (click:write and click:admin include it). signerRef is an opaque identifier for the signer, such as your user id. Never put an email address or a name in it. returnUrl is where the page sends the signer afterwards: it must be an https URL on the deployment's domain.

curl -X POST "https://api.propper.ai/v1/click/deployments/$DEPLOYMENT_ID/acceptance-links" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"signerRef": "user-123", "returnUrl": "https://www.example.com/agreement-done"}'

The answer carries the link's fields and its url, of the form https://app.propper.ai/click-session/<linkId>#<secret>. Send the signer to url exactly as returned.

  • The part after # is a one-time secret, returned in this response only. Propper keeps only its hash, so you cannot read it again: treat the URL like a password until the signer opens it.
  • A link lasts one hour by default. Set expiresInSeconds for another lifetime, up to seven days.
  • Minting again for the same deployment and signerRef revokes that signer's earlier link.

The redirect to your returnUrl is not proof of an outcome, because anyone can open that address. Read the link's status to confirm what the signer did, and put nothing sensitive in the return URL:

curl "https://api.propper.ai/v1/click/acceptance-links/$LINK_ID" \
-H "Authorization: Bearer $TOKEN"

status is ACTIVE until the signer opens the link, OPENED once they have, then ACCEPTED, DECLINED or REVOKED. expired: true means the link passed expiresAt with no outcome. The answer never carries the url or its secret again.

If a link leaks, for example through a forwarded email or a screenshot of its QR code, revoke it:

curl -X POST "https://api.propper.ai/v1/click/acceptance-links/$LINK_ID/revoke" \
-H "Authorization: Bearer $TOKEN"

The answer is the link with status: "REVOKED" and revokedAt, and anyone who opens it afterwards is told it is not usable. Revoking a revoked link answers it again, unchanged. Once the signer has accepted or declined, the link keeps its outcome and a revoke answers 409 CLICK_ACCEPTANCE_LINK_COMPLETED. A link of another organization answers 404, the same as an ID that does not exist.

The same calls are available from the command line: propper-cli click deployments mint-acceptance-link, propper-cli click acceptance-links get and propper-cli click acceptance-links revoke.

What's Next?​