Recording and checking per-user consent from your backend
Four endpoints store consent as a first-class record with categories, timestamps and source — and one gate you should put in front of every marketing send.
Consent is usually a boolean on a users table that nobody can explain the provenance of. Infrai models it properly: POST /v1/auth/consent/grant/{user_id} records a grant with a category and a source, GET /v1/auth/consent/check/{user_id}/{category} answers yes or no, GET /v1/auth/consent/list_for_user/{user_id} gives you the whole history, and POST /v1/auth/consent/revoke/{user_id} withdraws it.
The categories are fixed — marketing, analytics, essential and third_party — which is the useful kind of constraint. Four buckets map onto how regulators think, and a closed enum means two services can’t disagree about what “marketing_opt_in” meant.
Record a grant
curl -sS -X POST "https://api.infrai.cc/v1/auth/consent/grant/au_usr_lMmXGySJeGVM1xicqHGJaJbB" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"user_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
"category": "marketing",
"source": "signup_form_v3"
}'
{
"ok": true,
"data": {
"ok": true,
"consent_id": "au_cns_6tR2xQb9nVmK",
"user_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
"category": "marketing",
"granted": true,
"granted_at": "2026-09-21T02:24:30Z",
"revoked_at": null,
"source": "signup_form_v3"
}
}
source is the field that earns its keep.
When someone asks in eighteen months why you emailed a particular customer, “granted at 02:24 on 2026-09-21, from signup_form_v3” is an answer and “the checkbox was ticked” is not — so put a version in the source string and bump it whenever the wording of the consent changes, because consent to the old wording is not consent to the new one, and the version is the only thing that will let you tell six months from now which cohort agreed to which text.
Gate the send on the check
This is where consent stops being paperwork and becomes code.
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
HEADERS = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
SESSION = requests.Session()
SESSION.headers.update(HEADERS)
def has_consent(user_id: str, category: str) -> bool:
resp = SESSION.get(f"{API}/v1/auth/consent/check/{user_id}/{category}", timeout=10)
resp.raise_for_status()
return bool(resp.json()["data"].get("result"))
def send_campaign(user_id: str, email: str, subject: str, html: str) -> str:
"""Marketing mail goes out only behind a live consent check. Caching this
result is how a revocation gets ignored for an hour, so don't."""
if not has_consent(user_id, "marketing"):
return "skipped: no marketing consent"
resp = SESSION.post(
f"{API}/v1/email/send",
json={
"to": email,
"subject": subject,
"html": html,
"message_class": "marketing",
"auto_unsubscribe_link": True,
},
timeout=20,
)
resp.raise_for_status()
return f"sent: {resp.json()['data'].get('message_id')}"
if __name__ == "__main__":
print(send_campaign(os.environ["USER_ID"], os.environ["USER_EMAIL"],
"Product news", "<p>Hello</p>"))
Two things there are deliberate. The check is live, not cached — a consent you withdrew thirty seconds ago should stop the next send, and a five-minute cache turns that into thirty more emails. And the send declares message_class: "marketing" with auto_unsubscribe_link: true, which is the platform’s own way of keeping transactional and marketing traffic separable.
The audit trail
curl -sS "https://api.infrai.cc/v1/auth/consent/list_for_user/au_usr_lMmXGySJeGVM1xicqHGJaJbB" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
items is every consent record for that user, grants and revocations, each with granted_at, revoked_at and source. That’s your subject-access-request answer in one call, and it’s the reason to record through this API rather than a column you overwrite: an overwritten boolean has no history, and history is the whole point.
Revoking
curl -sS -X POST "https://api.infrai.cc/v1/auth/consent/revoke/au_usr_lMmXGySJeGVM1xicqHGJaJbB" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"user_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB", "category": "marketing"}'
The record comes back with revoked_at set. The grant isn’t deleted — it’s closed, which is what an auditor wants to see.
| Category | Typical use | Does withdrawal stop the service? |
|---|---|---|
essential | security mail, receipts, account notices | no — these aren’t consent-based |
marketing | campaigns, newsletters, product news | yes, immediately |
analytics | product telemetry, session analysis | yes, stop the events |
third_party | sharing with a partner or processor | yes, and tell the partner |
Note the first row. Treating a password-reset email as consent-based is a mistake in the other direction — you don’t need permission to tell someone their password changed, and suppressing it because a marketing box was unticked is its own failure.
What this is not
This records and answers consent. It doesn’t render a cookie banner, doesn’t generate your privacy policy, doesn’t classify which of your own data flows fall under which category, and doesn’t manage vendor processing agreements.
Auth0 and Descope both ship more around this — hosted consent screens, policy templates, per-tenant privacy settings — and if your compliance team wants a consent product rather than a consent API, you’d be better off buying one of those than assembling the equivalent here.
There’s also no automatic enforcement. Nothing stops a service on your side from sending marketing mail without checking first — the check is a call you have to make, and the discipline of putting it in one place is yours.
What you do get is the enforcement point and the delivery on one credential. The consent check and the POST /v1/email/send it guards are the same key, the same invoice and the same usage view, so “did we have permission, and did we send it” is one query against GET /v1/account/usage rather than a join across two vendors’ exports. When analytics consent is withdrawn, the events you stop sending are POST /v1/analytics/track on that same account.
Consent routes report billing_class: free in discovery — recording and checking aren’t billed per call. The email or analytics call behind the gate is the billable part; GET /v1/discovery carries the live per-route rate and GET /v1/account/usage your actual spend, both verified 2026-09-21. Read those rather than a figure in a guide, and expect them to fall over time rather than rise.