Your values Fill in your own to see them in every example
The part after /o/ in your hub’s address.
That doesn’t look right yet. For example: a1B2c3D4e5F6
The host your form lives on, as you registered it under HumanProof → Sites.
That doesn’t look right yet. For example: www.example.com
Saved in this browser only, for every page of these docs. Nothing you type leaves this page.
Five steps, three of them yours to write. Before you start, set up the hub.
1. Prove a person on your form
HumanProof keeps automated sign-ups out of your form. Load the script and mark the form. The visitor confirms with a passkey, creating an identity if they have none, and the form gains a short-lived token stating that a person is present. The token carries no identifier.
<script src="https://id.trustaige.com/humanproof.js" defer></script>
<form action="/signup" method="post" data-humanproof>
<input name="name">
<input name="email" type="email">
<!-- the widget draws itself here and adds a humanproof-token field -->
<button>Create account</button>
</form>const el = document.getElementById('proof');
HumanProof.render(el, {
onProof(token) { form.elements.humanProof.value = token; },
onUnavailable() { /* this browser can't hold a passkey: say so */ },
});
// after a submission spends the token:
HumanProof.reset(el); Your server verifies the token before it creates anything:
POST /humanproof/verify HTTP/1.1
Host: id.trustaige.com
Content-Type: application/json
{ "origin": "<your-site-host>", "token": "<token from the form>" }{ "valid": true, "tier": "prove" } Proceed only on "valid": true; otherwise refuse the submission and ask the
person to confirm again. A token is valid once, for the site it was issued to.
Where the browser can’t use passkeys, the form can’t be submitted: tell the
person so. Every answer is in the verify reference.
<script src="https://id.trustaige.com/humanproof.js" defer></script>
<form action="/signup" method="post" data-humanproof>
<input name="name">
<input name="email" type="email">
<!-- the widget draws itself here and adds a humanproof-token field -->
<button>Create account</button>
</form>const el = document.getElementById('proof');
HumanProof.render(el, {
onProof(token) { form.elements.humanProof.value = token; },
onUnavailable() { /* this browser can't hold a passkey: say so */ },
});
// after a submission spends the token:
HumanProof.reset(el); POST /humanproof/verify HTTP/1.1
Host: id.trustaige.com
Content-Type: application/json
{ "origin": "<your-site-host>", "token": "<token from the form>" }{ "valid": true, "tier": "prove" } 2. Get a client token
On your server, with the client-credentials grant and the same client ID and secret your customers sign in through. Any OAuth library does this and caches the token until it expires.
POST /oauth2/token HTTP/1.1
Host: id.trustaige.com
Authorization: Basic base64(TRUSTAIGE_CLIENT_ID:TRUSTAIGE_CLIENT_SECRET)
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=offers:write issued:read issued:managecurl -s https://id.trustaige.com/oauth2/token \
-u "$TRUSTAIGE_CLIENT_ID:$TRUSTAIGE_CLIENT_SECRET" \
-d grant_type=client_credentials \
-d scope="offers:write issued:read issued:manage" Request the scopes your calls need, by name; each call says which. A request
that names no scope still returns a token, but one that every call refuses with
403.
POST /oauth2/token HTTP/1.1
Host: id.trustaige.com
Authorization: Basic base64(TRUSTAIGE_CLIENT_ID:TRUSTAIGE_CLIENT_SECRET)
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=offers:write issued:read issued:managecurl -s https://id.trustaige.com/oauth2/token \
-u "$TRUSTAIGE_CLIENT_ID:$TRUSTAIGE_CLIENT_SECRET" \
-d grant_type=client_credentials \
-d scope="offers:write issued:read issued:manage" 3. Ask for a subject and a claim link
After verifying the HumanProof token, save your own record, then ask for a
subject for the person, with a token that has offers:write. The request
names the credential type and, optionally, its claim values and how long the
link stays open.
POST /o/<your-org-id>/api/credentials/offers HTTP/1.1
Host: id.trustaige.com
Authorization: Bearer <access_token>
Content-Type: application/json
{
"type": "Customer",
"claims": { "tier": "standard" },
"validForDays": 14
}POST /o/<your-org-id>/api/credentials/offers HTTP/1.1
Host: id.trustaige.com
Authorization: Bearer <access_token>
Content-Type: application/json
{
"type": "Customer",
"subject": "<the subject you stored>"
}curl -s "$TRUSTAIGE_ISSUER/api/credentials/offers" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"Customer","claims":{"tier":"standard"},"validForDays":14}' {
"id": "4f0c…",
"subject": "b1aa…",
"state": "open",
"claimUrl": "https://id.trustaige.com/o/<your-org-id>/credentials/Customer/claim?offer=…",
"expiresAt": "2026-10-20T09:00:00Z"
}
Store subject against your record, in a unique column, and send the person
claimUrl.
claims sets one of the allowed values for each claim your credential type
declares, such as tier from standard or gold; claims take no free text.
validForDays is how long the claim link stays open: 1 to 30, 7 if you leave it
out.
| Response | Means | What to do |
|---|---|---|
400 invalid_claims | A claim or value your type doesn’t declare | Fix your form’s choices |
400 invalid_valid_for_days | validForDays is outside 1–30 | Send a value in range, or leave it out |
404 unknown_subject | The subject you re-offered to no longer exists | Make a fresh offer and store the new subject |
409 already_claimed | The person already has their credential | Send them to sign in |
429 | More than 60 requests a minute from this client | Back off and retry |
Every response is in the offer reference.
POST /o/<your-org-id>/api/credentials/offers HTTP/1.1
Host: id.trustaige.com
Authorization: Bearer <access_token>
Content-Type: application/json
{
"type": "Customer",
"claims": { "tier": "standard" },
"validForDays": 14
}POST /o/<your-org-id>/api/credentials/offers HTTP/1.1
Host: id.trustaige.com
Authorization: Bearer <access_token>
Content-Type: application/json
{
"type": "Customer",
"subject": "<the subject you stored>"
}curl -s "$TRUSTAIGE_ISSUER/api/credentials/offers" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"Customer","claims":{"tier":"standard"},"validForDays":14}' {
"id": "4f0c…",
"subject": "b1aa…",
"state": "open",
"claimUrl": "https://id.trustaige.com/o/<your-org-id>/credentials/Customer/claim?offer=…",
"expiresAt": "2026-10-20T09:00:00Z"
}4. Send the link yourself
Deliver claimUrl in your own welcome email, from your own domain. Trustaige
doesn’t send it.
The link works once, for whoever opens it and confirms with a passkey; that
person then becomes subject. Because you sent it to the address you hold,
their first sign-in proves the address is theirs: mark it verified then.
When a link is lost or expires
A link stays open for validForDays and works once. People lose emails, and
links expire. When someone signs up again with an address whose account they
never finished, don’t ask for a new subject: re-offer to the subject you
already hold, by adding subject to the same call (the Re-offer tab
above).
- The subject never changes. You get a fresh link for the same person. Your record stays keyed to the subject you stored the first time.
- Only the newest link works. Every earlier link for that subject stops working at once, so an old email opened late can’t claim it.
- Claimed already? Send them to sign in. If the person opened a link in the
meantime, you get
409 already_claimed: they have their account. - Gone? Start fresh. Subjects nobody claimed are cleared thirty days after
their last link closed. A re-offer then answers
404 unknown_subject: make a new offer withoutsubject, and store the new subject in place of the old.
A resend comes through your sign-up form, so the form’s HumanProof check applies to it as to any submission.
5. The person claims
The link opens a Trustaige page showing your credential, with your organization’s name and proved domain. The person confirms with a passkey, or creates one, which takes a few seconds, and saves the credential to their identity. A Continue button then sends them to your initiate login URI, which starts a normal sign-in and lands them in their account with you.
Your initiate login URI must start a sign-in on its own, with no other input:
Trustaige sends people there with nothing attached. Usually it is the same
route your Sign in button uses, such as /auth/login.
If the person already has an account with you, the claim is refused and they are told to sign in instead. A subject you were given is never quietly replaced by another.
Next: sign people in and out.