Verify requests from twins
What your agent must check before it accepts a signed call from an AY twin, what the signed scope means, and why the sender check is yours to make.
When a twin calls your agent, it signs the request with the AY profile and the tag ay-t2a. Your agent verifies the signature itself, without calling AY. Follow the steps in order; the first failure rejects the request with 401.
Before you begin: pin the twin you accept
Your agent accepts calls only from the twin of its own business. That DID is configuration, never something read from the request.
- At start-up, resolve your own agent DID:
https://api.aytwin.ai/agents/<id>/did.json, orGET /api/v1/identity/resolve?did=<agent did>, or thedid_documentinGET /api/v1/agents/me. - Take
controllerfrom that document. This is the DID of the business twin that owns the agent. Store it as the expected sender. - Cross-check it with the Agent Passport: the
ownerclaim must be the same DID, andPOST /api/v1/identity/credentials/verifywith the passport (body{ "jwt": "<passport>" }) must returnvalid. Ifcontrollerandownerdiffer, do not start. The passport is signed by the platform and revocable; thecontrollerfield alone is not part of the signed log. - Repeat hourly. If the value changes or the passport is no longer
valid, stop accepting calls until a human has looked at it.
Verify each request
Content digest. Compute sha-256 over the received body bytes and compare it with the Content-Digest header. Reject on mismatch.
Sender. keyid is a DID URL such as did:webvh:<scid>:api.aytwin.ai:twins:<id>#key-1. The part before # must equal the expected sender you pinned, and it must equal scope.business_did in the signed body. Reject otherwise with reason signer_not_expected. Never trust scope.business_did alone; the sender wrote it.
Key. Fetch https://<host>/twins/<id>/did.json. The verification method whose id equals keyid must be listed under authentication. For a Multikey, drop the leading z, decode base58btc, drop the two prefix bytes 0xed01; the remaining 32 bytes are the Ed25519 public key.
Signature. Verify with a maximum age of 300 seconds and 60 seconds of clock skew. Build @authority and @path from your own configured origin and the path you serve, not from the Host header of the incoming request.
Profile. Accept only tag="ay-t2a" and exactly the components ("@method" "@authority" "@path" "content-digest"). Any other tag, including ay-t2t, is invalid.
Nonce. Remember each nonce per keyid for 360 seconds, for example with SET <keyid>:<nonce> 1 NX EX 360 in Redis. A nonce seen before is a replay. If the store is unreachable, reject with 503. Never let a request through unchecked.
A signed request carries no second credential. Reject requests that also carry Authorization.
Query parameters are not signed. Ignore them; everything that matters is in the body.
Read the signed body
The body is { capability, input, scope }. The twin sets scope in code; the language model never writes it. The signature covers the whole body through the content digest, so every field in scope is as trustworthy as the signature itself. input is model text and proves nothing.
| Field | Meaning |
|---|---|
scope.business_did | The business twin that asks. Must equal the DID in keyid (step 2). |
scope.requester_did | The verified customer the twin asks for, or null. |
scope.purpose | Why the twin asks. One of the values below. |
scope.link_id | The confirmed link between that customer and the business, or null. |
scope.external_ref | The case or customer number the business stored when it invited the customer. Set only together with link_id, otherwise null. |
requester_did is set only when the twin acts for a person whose message it has verified: the customer who just wrote to the business, or whose open question the business answers. null means no verified customer. It never means the owner or an employee, and it grants no extra rights. The business itself never appears here.
purpose takes one of these values:
| Value | Meaning | Needs a confirmed link |
|---|---|---|
general_info | Services, opening hours, contact | no |
intake_request | Record a request or a callback wish | no |
appointment_propose | Propose an appointment; accepting one is a separate approval | no |
matter_status | Status of the customer's own case | yes |
link_id is set only when the customer confirmed the link, the link is neither revoked nor expired (the twin checks this on every call), and the link's scope types include purpose. Return case data only when purpose is matter_status and link_id is set, and only for that link.
external_ref is the business's own identifier for the case. Read it only from scope, never from input. The twin strips the keys requester_did, purpose, business_did, link_id, external_ref, link_ref and scope from input at every depth and in every spelling, so your agent cannot receive its own fields under these names there.
Answer
Respond with { status, type, disclosure, answer }.
| Field | Values |
|---|---|
status | completed (answered), input_required (you need one more detail from the customer), link_required (this needs a confirmed link), rejected |
disclosure | general or matter. Set matter whenever the answer contains data about this customer's case. Any value other than general is treated as matter. |
Do not echo external_ref or other scope fields in the answer. The twin removes the reserved keys listed above from your answer before the language model sees it. With disclosure: matter the twin speaks only to that customer in this conversation, keeps the answer out of its long-term memory, stores only metadata for the task, and does not mirror the answer into the business's agent chat.
Why the sender check is yours
A valid ay-t2a signature proves only that some AY twin signed the request. The AY twin service may sign as any twin, and a different business also gets valid ay-t2a signatures for its own twin. Step 2 turns "some twin" into "the twin of my business". The test vector case foreign_twin covers exactly this.
Test your implementation
The test vectors include rfc9421-ay-t2a.json with eight cases (valid, tampered body, expired, replay, wrong tag, foreign twin, and more) and a Python reference verifier:
pip install http-message-signatures==2.0.1 base58
python verify_example.py rfc9421-ay-t2a.json ay-t2a # 8× PASS, exit 0Learn about the caller
To show what you know about the calling business, resolve its twin DID with GET /api/v1/identity/resolve and verify the Business Verification Credential in the credentials array with POST /api/v1/identity/credentials/verify. See Credentials.