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:writeandclick:acceptscopes - A backend service that can call
https://api.propper.aiand 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
- JavaScript
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"
}'
const getAccessToken = async () => {
const response = await fetch('https://auth.propper.ai/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: process.env.PROPPER_CLIENT_ID,
client_secret: process.env.PROPPER_CLIENT_SECRET,
scope: 'click:read click:write click:accept',
}),
});
const { access_token } = await response.json();
return access_token;
};
const token = await getAccessToken();
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
- JavaScript
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" }'
const createTemplate = async (token) => {
const response = await fetch('https://api.propper.ai/v1/click/templates', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: 'Terms of Service', slug: 'terms-of-service' }),
});
if (!response.ok) throw new Error(`create template failed: ${response.status}`);
return response.json();
};
const template = await createTemplate(token);
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 &, 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
- JavaScript
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 & Privacy Policy.</p>",
"texts": { "acceptButtonText": "I agree", "checkboxText": "I have read the terms" },
"acknowledgments": [{ "labelText": "I am authorised to accept for my company" }]
}
]
}'
const writeDraft = async (token, templateId) => {
const response = await fetch(`https://api.propper.ai/v1/click/templates/${templateId}/draft`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
locales: [
{
locale: 'en-US',
title: 'Terms of Service',
body: '<p>You agree to the Terms of Service & Privacy Policy.</p>',
texts: { acceptButtonText: 'I agree', checkboxText: 'I have read the terms' },
acknowledgments: [{ labelText: 'I am authorised to accept for my company' }],
},
],
}),
});
const body = await response.json();
if (!response.ok) throw new Error(`${body.errorCode}: ${body.message}`);
return body;
};
await writeDraft(token, template.id);
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
- JavaScript
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" }'
const publishVersion = async (token, templateId) => {
const response = await fetch(`https://api.propper.ai/v1/click/templates/${templateId}/versions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ level: 'MAJOR', reason: 'Initial publication' }),
});
if (!response.ok) throw new Error(`publish failed: ${response.status}`);
return response.json();
};
const version = await publishVersion(token, template.id);
console.log('Version ID:', version.id, version.version);
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
- JavaScript
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"
}'
const createDeployment = async (token, versionId) => {
const response = await fetch('https://api.propper.ai/v1/click/deployments', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
versionId,
domain: 'www.example.com',
path: '/terms',
environment: 'production',
}),
});
if (!response.ok) throw new Error(`create deployment failed: ${response.status}`);
return response.json();
};
const deployment = await createDeployment(token, version.id); // the version published in Step 2
console.log('Deployment ID:', deployment.id);
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
- JavaScript
curl "https://api.propper.ai/v1/click/render/deployment/$DEPLOYMENT_ID?locale=en-US" \
-H "Authorization: Bearer $TOKEN"
const renderAgreement = async (token, deploymentId, locale = 'en-US') => {
const response = await fetch(
`https://api.propper.ai/v1/click/render/deployment/${deploymentId}?locale=${locale}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!response.ok) throw new Error(`render failed: ${response.status}`);
return response.json();
};
const rendered = await renderAgreement(token, deployment.id);
// Show rendered.content to the user in your own page, and keep
// rendered.checksum, rendered.versionId and rendered.locale with their session.
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
checksumthe 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-Keythat is new for each acceptance and the same on every retry of it: see Limits and retries.
- cURL
- JavaScript
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" }
}'
import { randomUUID } from 'node:crypto';
const recordAcceptance = async (token, rendered, userId, idempotencyKey = randomUUID()) => {
const response = await fetch('https://api.propper.ai/v1/click/accept', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'X-Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
deploymentId: rendered.deploymentId,
templateVersionId: rendered.versionId,
checksum: rendered.checksum,
consentMethod: 'BUTTON',
userId,
metadata: { locale: rendered.locale },
}),
});
const body = await response.json();
// Every error has the same body: { errorCode, message, requestId, details? }.
if (!response.ok) throw new Error(`${body.errorCode}: ${body.message} (${body.requestId})`);
return body;
};
// Keep the receipt id: it is how you look the acceptance up later.
const { receiptId, evidenceStatus } = await recordAcceptance(token, rendered, 'user-123');
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
404witherrorCode: "CLICK_NOT_FOUND". There is no verification result to read. valid: falsein a200response means only that the evidence failed verification. Readerrorsandchecksto see which check failed.
- cURL
- JavaScript
# 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"
const verifyAcceptance = async (token, receiptId) => {
const headers = { Authorization: `Bearer ${token}` };
const read = async (url) => {
const response = await fetch(url, { headers });
const body = await response.json();
// Every error has the same body: { errorCode, message, requestId, details? }.
if (!response.ok) throw new Error(`${body.errorCode}: ${body.message} (${body.requestId})`);
return body;
};
const receipt = await read(`https://api.propper.ai/v1/click/receipts/${receiptId}`);
const verification = await read(
`https://api.propper.ai/v1/click/evidence/verify/receipt/${receiptId}`,
);
return { receipt, verification };
};
const { receipt, verification } = await verifyAcceptance(token, receiptId);
console.log('Evidence status:', receipt.data.evidenceStatus);
console.log('Evidence valid:', verification.valid);
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"withreason: "accepted_other_major": show the agreement again.changecarries the reason, the summary and the re-acceptance gate and deadline of the new major, andchange.sinceAcceptedsays what changed since the version the signer accepted.status: "stale"withreason: "never_accepted"andaccepted: 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
expiresInSecondsfor another lifetime, up to seven days. - Minting again for the same deployment and
signerRefrevokes 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.