Server API
Use the server API when you collect consent in your own sign-up flow or app, and to check consent before you send a marketing message. It is a JSON REST API at https://api.optproof.in/v1.
Authentication#
Server calls use a secret key in the Authorization header:
curl https://api.optproof.in/v1/consents/check?property_id=prp_...&purpose=whatsapp_updates&phone=%2B919876543210 \ -H "Authorization: Bearer sk_live_..."- Secret keys are shown once when you create them, and stored by OptProof only as an argon2id hash. If you lose one, rotate it.
- Keep secret keys on your server. Never put them in a web page or mobile app; those use the publishable key.
- Rotation gives you an overlap window, so you can deploy the new key before the old one stops working.
Record consent#
Call POST /v1/consents after the person has made their choice in your UI.
curl https://api.optproof.in/v1/consents \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: signup-8f2c1d" \ -d '{ "property_id": "prp_01JBTT8000E008000000000002", "principal": { "phone": "+919876543210" }, "decisions": [ { "purpose_key": "appointments", "granted": true }, { "purpose_key": "whatsapp_reminders", "granted": true }, { "purpose_key": "health_tips", "granted": false } ], "notice_version": 3, "language": "en", "context": { "ui_variant": "signup_v2", "app_version": "4.12.0" } }'property_idstringrequired- The property this consent belongs to (
prp_…). principalobjectrequired- Exactly one of
phone,emailorexternal_id(your own stable user id). OptProof turns it into a pseudonymousprincipal_refstraight away; the raw value never enters the ledger. decisionsarrayrequired- At least one
{ purpose_key, granted }. Granted and refused purposes in one call become agrantevent and adenyevent. notice_versionintegerrequired- The version of the notice the person saw. It is recorded on the event and in the receipt.
languagestring- Language of the notice shown. Default
en. channelstring- Default
server. Useappfor mobile apps. occurred_atdate-time- When the person actually decided, for example the sign-up time. Defaults to now and cannot be in the future.
contextobject- Non-personal context such as
ui_variant,page_urlorapp_version. Values are strings, numbers or booleans. store_contactboolean- Default
true. Stores the phone or email encrypted, so the person can later sign in to the preference centre by OTP.
The response (201) contains the principal_ref and one receipt per event written:
{ "principal_ref": "3f9a0c...e1", "receipts": [ { "event_id": "evt_01JBTT8000E008000000000081", "receipt_code": "OP-7K3M-9Q2X", "receipt_jws": "eyJhbGciOiJFZERTQSIs...", "action": "grant", "purpose_keys": ["appointments", "whatsapp_reminders"], "seq": "1042", "occurred_at": "2026-10-20T06:12:45.120Z" } ]}Give the receipt code to the person (on screen, by SMS or email). With it they can check the record themselves on verify.optproof.in.
Check consent before you send#
Call GET /v1/consents/check before any marketing email, SMS or WhatsApp message. Identify the person with exactly one of phone, email, external_id or principal_ref.
curl "https://api.optproof.in/v1/consents/check?property_id=prp_...&purpose=whatsapp_reminders&email=asha%40example.in" \ -H "Authorization: Bearer sk_live_..."{ "granted": false, "status": "withdrawn", "notice_version": 3, "event_id": "evt_01JBTT8000E008000000000093", "updated_at": "2026-10-22T11:02:09.004Z"}status is granted, denied, withdrawn, expired, or null if OptProof has no record for this person and purpose. Treat anything other than granted: true as "do not send".
Link a device to a person#
The widget identifies visitors by a random device_ref until they tell you who they are. When someone logs in or submits a form, link the two so their device history follows them:
curl https://api.optproof.in/v1/principals/link \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "property_id": "prp_...", "device_ref": "0b7e4c1a-9f3d-4c2e-8a51-2d6f0e9b7c43", "principal": { "phone": "+919876543210" } }'The response is { "principal_ref": "…", "merged_events": 4 }. The inline form block puts the device_ref in a hidden optproof_device field for exactly this call. In the browser, optproof.identify() does the same thing.
Other server endpoints#
| Endpoint | What it does |
|---|---|
GET /v1/consents/events | Ledger events, newest first, cursor-paginated. Filter by property, action, time, principal or receipt code. |
GET /v1/principals/{ref}/consents | Current state of every purpose for one person. |
GET /v1/principals/{ref}/history | Every event for one person. |
GET /v1/requests, GET /v1/requests/{id} | Rights requests and their timelines. |
PATCH /v1/requests/{id} | Change status, assignee or resolution. |
POST /v1/requests/{id}/events | Add a note, a reply, or the outcome in one of your systems. |
POST /v1/access-logs | Record that someone viewed a person’s record (Business and above). |
POST /v1/imports | Import existing consents from CSV with evidence (Growth and above). |
POST /v1/exports, GET /v1/exports/{id} | Start an evidence export and download it. |
All fields are in the API reference.
Idempotency#
Send an Idempotency-Key header on every POST. If a request times out, retry it with the same key: OptProof returns the original result instead of writing a second event. Reusing a key with a different body returns 409 idempotency_conflict. Use something stable from your side, such as signup-<user id> or the id of the form submission.
Pagination#
List endpoints return { "data": [...], "next_cursor": "..." }. Pass cursor to get the next page and limit (1 to 200, default 50) to change the page size. next_cursor is null on the last page.
Errors#
Every error has the same shape, with a request_id to quote when you contact support:
{ "error": { "code": "validation_failed", "message": "decisions must contain at least one item", "request_id": "req_01JBTT9F2K3M4N5P6Q7R8S9T0V" }}Branch on code, not on message. The codes are:
| Code | Meaning |
|---|---|
bad_request | The request could not be read, for example malformed JSON. |
validation_failed | A field is missing or invalid. details lists the paths. |
unauthorized | No key, or the key is wrong or revoked. |
forbidden | The key is valid but cannot do this. |
not_found | The resource does not exist, or belongs to another workspace. |
conflict | The request conflicts with the current state. |
idempotency_conflict | This Idempotency-Key was already used with a different body. |
rate_limited | Too many requests. Wait and retry; see the Retry-After header. |
origin_not_allowed | A publishable-key call came from an origin not on the property’s list. |
notice_not_published | The notice version you sent is not published for this property. |
unknown_purpose | A purpose key does not exist on this property. |
otp_invalid | The one-time code is wrong. |
otp_expired | The one-time code has expired. Request a new one. |
otp_locked | Too many wrong attempts. Wait before trying again. |
sms_unavailable | |
plan_feature_unavailable | Your plan does not include this feature. |
internal | Something failed on our side. Retry with the same Idempotency-Key. |
Rate limits#
| Credential | Limit |
|---|---|
Secret key (sk_) | 1,000 requests a minute per key; higher on Business |
Publishable key (pk_) | 1,000 requests a minute per key |
| Public calls per device | 60 requests a minute |
Over the limit you get 429 rate_limited with a Retry-After header. Consents are never dropped for being over your plan's yearly consent limit: they are always recorded, you are notified, and the extra consents are billed as overage.
Test and live chains#
Test keys write to a sandbox chain that is separate from your live ledger. Test events never count toward usage, are marked is_test in the ledger, and arrive in webhooks with "test": true. Use them in staging and CI, then switch the key in production.