Goal: after an account is created or resolved, optionally return an RP-signed
signup_token that proves who signed up, for whom, under which terms and when.
The receipt is optional. If you issue one, the broker records it as attribution evidence. The claim set is defined in spec section 11; the field reference is Receipt and context.
A v1.2 receipt is signed by your relying-party key, not the broker. The key
must be present in the exact provider attestation named by
service.attestation_jti. There is no broker-key fallback.
What goes in the receipt
| Claim | Value |
|---|---|
iss |
Your canonical RP origin, for example https://example.com. It must equal service.origin. |
sub |
The agent id (agt_...). |
aud |
The human broker origin, for example https://agentregistrar.dev. |
jti |
A unique receipt id (srp_ + 26-character lowercase Crockford base32 ULID). |
iat, signed_up_at |
Unix seconds. Both must fall inside the named attestation's [iat, exp] window. |
assertion_jti |
The jti of the assertion presented at signup. |
service.audience |
Your discovery audience. |
service.origin |
Your canonical origin; must equal iss. |
service.provider_name |
Your provider_name. |
service.provider_url |
Optional canonical URL. It is not an identity binding. |
service.account_id |
An opaque account reference. It is not a resource inventory. |
service.attestation_jti |
The jti of the attestation that contained the signing key. |
service.key_id |
The key id that signs the receipt. It must equal the JWS header kid. |
agent.* |
id, name, email. |
human.* |
id, name, email, verified. |
tos.* |
version, url, sha256, optional master_tos_version. |
context |
Optional { url, sha256 } pointer to your guide. |
exp |
Do not add one. A v1.2 receipt has no expiry. A legacy exp is ignored. |
Do not add service.instance_sku, service.endpoint_url, a free-form metadata
object, resource credentials or API usage. The receipt is not a resource
inventory. Vendor capability belongs in the context guide.
Sign it
Use the RP key registered with your broker and the exact attestation that
contained it. If you use the shared implementation, signRpSignupReceipt builds
the frozen header and payload and rejects a missing attestation key. The
equivalent jose flow is below.
mkdir provider-receipt
cd provider-receipt
npm init -y
npm install jose@6.2.13Save as sign-receipt.mjs. rp-private.jwk.json is your private key;
attestation.jws is the exact attestation that was current at signup.
import { readFileSync } from "node:fs";
import { importJWK, SignJWT } from "jose";
const privateJwk = JSON.parse(readFileSync("rp-private.jwk.json", "utf8"));
const attestation = readFileSync("attestation.jws", "utf8").trim();
const payload = JSON.parse(
Buffer.from(attestation.split(".")[1], "base64url").toString("utf8"),
);
const key = payload.keys.find((candidate) => candidate.kid === privateJwk.kid);
if (!key || !key.purposes.includes("receipt")) {
throw new Error("the key is not a receipt key in this attestation");
}
const now = Math.floor(Date.now() / 1000);
if (now < payload.iat || now > payload.exp) {
throw new Error("the attestation is not valid at signing time");
}
const claims = {
iss: payload.rp.origin,
sub: "agt_01j00000000000000000000000",
aud: "https://agentregistrar.dev",
jti: "srp_01j00000000000000000000000",
iat: now,
signed_up_at: now,
assertion_jti: "jti_01j00000000000000000000000",
service: {
audience: payload.rp.audience,
origin: payload.rp.origin,
provider_name: "Example RP",
provider_url: "https://example.com",
account_id: "acct_opaque_0001",
attestation_jti: payload.jti,
key_id: privateJwk.kid,
},
agent: {
id: "agt_01j00000000000000000000000",
name: "opencode-agent",
email: "opencode-agent@agents.example.com",
},
human: {
id: "hum_01j00000000000000000000000",
name: "Example Human",
email: "human@example.com",
verified: true,
},
tos: {
version: "2026.1",
url: "https://example.com/terms",
sha256: "sha256:6c18126f93de8d1972589ca94633ca82e76bd9ba1f0592618b0105753f3501d6",
master_tos_version: "2026.1",
},
context: {
url: "https://example.com/docs/SKILL.md",
sha256: "sha256:a3307e1b3904c7671f341730017f21bed656a85241b645c854b4f2887e7976ac",
},
};
const signupToken = await new SignJWT(claims)
.setProtectedHeader({
alg: "EdDSA",
typ: "agent-signup-receipt+jwt",
kid: privateJwk.kid,
})
.sign(await importJWK(privateJwk, "EdDSA"));
console.log(signupToken);There is no exp in the payload. The receipt does not expire; its validity is
the historical question "did this key and identity exist at iat?".
Return it and record it
Return the token in the signup response as signup_token, next to the
credentials:
{
"resolution": "created",
"account_id": "acct_opaque_0001",
"api_key": "sk_live_<show-once>",
"signup_token": "<compact JWS>",
"context": {
"url": "https://example.com/docs/SKILL.md",
"sha256": "sha256:a3307e1b3904c7671f341730017f21bed656a85241b645c854b4f2887e7976ac"
}
}The agent records it with identity_record_signup, or you deliver it to the
broker through Contract F:
SIGNUP_TOKEN="<compact JWS from the previous step>"
RP_ATTESTATION="<the exact attestation JWS>"
curl -sS -X POST https://agentregistrar.dev/api/broker/signup-receipt \
-H 'Content-Type: application/json' \
-d "{\"signup_token\":\"$SIGNUP_TOKEN\",\"rp_attestation\":\"$RP_ATTESTATION\"}"Contract F accepts exactly {signup_token, rp_attestation}. It verifies the
effective RP-broker trust list before any JWKS fetch, the historical attestation,
the receipt signature with the named key, the origin/audience binding, and the
receipt sub against the acceptance. It records proof_source:"rp_callback".
A duplicate jti with the same body returns deduplicated:true; a different
body is 409 signup_conflict.
If the attestation is unavailable, Contract F returns attestation_unavailable
rather than using a newer one. A transient broker metadata/JWKS outage is
503 rp_broker_unavailable and is retryable. An unauthenticated legacy body is
401 with a stable code.
Tier 2 and self-report
identity_record_signup accepts either:
{signup_token, rp_attestation?}: the same RP-signed receipt. If the exact attestation is omitted, the broker fetches discovery atservice.originand requires the presented attestation'sjtito equalservice.attestation_jti; it never substitutes a newer attestation. This recordsproof_source:"rp_signed".{self_report}: an unsigned report for an RP that issued no receipt. It has no proof token, assertion, verified origin, or verification row, recordsagent_self_reported, cannot overwrite a stronger proof, and still requires a real recorded acceptance.
A pre-v1.2 broker-signed receipt is rejected for a new proof. Historical rows stay
legacy_broker_signed and are never relabeled.
Resolve and retry
- Create the account once. On a repeat, resolve it and return
resolution: "resolved". - On resolve, omit the provider credential. Do not send duplicate welcome or legal mail.
- You may mint a fresh receipt on resolve so a dropped-response retry can still record the proof.
- The receipt
jtiidentifies the receipt, not the account. Do not use it as the account key. service.account_idis opaque. It may be a database id or a handle. It must not encode SKUs, regions or resource state.
Verify your work
- The JWT header is
alg: EdDSA,typ: agent-signup-receipt+jwt, andkidequalsservice.key_id. - The signing key appears in the exact attestation and has
receiptin its purposes. - The payload has no
exp, andiat/signed_up_atare inside the attestation window. -
issequalsservice.originand the attestationrp.origin;audis the broker origin. -
service.attestation_jtiis the exact attestation that contained the key. -
context.urlandcontext.sha256match the guide you serve, and the domain matches the signup endpoint. - A resolve returns no credential and no duplicate mail.
Next
- Publish a context guide: the pointer target.
- Receipt and context: the full field reference.
- Production checklist: key custody and rotation.
- Abuse reporting: the other authenticated RP path.