# handed2me One-time handover with a signed delivery receipt. The client does all the cryptography; this service stores an envelope it cannot open, and hands it over exactly once. MCP endpoint: POST https://api.handed2me.com/mcp (streamable HTTP, JSON-RPC 2.0) Discovery: /.well-known/mcp.json Auth: Bearer, OAuth 2.1 resource server; /.well-known/oauth-protected-resource Scopes: secret:create secret:claim secret:destroy receipt:read recipient:enroll recipient:read usage:read audit:read ## Creating a handover takes TWO calls, in this order The secret id is the key-derivation salt AND part of the authenticated header, so an envelope sealed before the id exists can never be decrypted. This is the one thing that cannot be guessed from the schemas. 1. reserve_secret_id { ttl_seconds } -> { url_id, tenant_uuid, expires_at } 2. seal locally, binding all three: id_bytes = first 22 chars of url_id, base64url-decoded to 16 bytes tenant_uuid = the returned uuid, as 16 raw bytes expires_at = the returned value, as u64 big-endian k_frag = 32 random bytes K = HKDF-SHA256(k_frag, salt=id_bytes, info="ex1/modeA/aead-key/v1", L=32) ciphertext = AES-256-GCM(K, iv=12 random bytes, aad=, plaintext) 3. create_sealed_secret { url_id, envelope, ttl_seconds, ctx? } -> { secret_ref, claim_path, expires_at, receipt_token } 4. the claim URL is #k= The fragment never reaches this server. Assemble it yourself; we never see it, and no response of ours ever contains it. ## The receipt is the product get_receipt { secret_ref } returns a compact JWS. Verify it offline against the key bundle at /.well-known/ex1-keys.json — by signature, never by recognising a domain. A receipt authorises nothing, so it is safe to store and to replay. ## Joining a handover to a run of your own (optional) Pass ctx on create_sealed_secret — or on POST /api/v1/secrets — and every receipt in that handover's chain carries ctx_hash = base64url(SHA-256("ex1/ctx/v1" | 0x7c | ctx)). We hash it and discard the input in the same request: ctx reaches no column, log line, metric or audit row, and appears in no response. Omit it and every receipt carries ctx_hash null, which is a fact rather than a gap — nothing here is ever invented. ctx must be 22-24 characters of [A-Za-z0-9_-], at least 16 distinct, with Shannon entropy >= 0.9 * log2(length). Refusal is 422 ctx_too_weak and it names which of the three clauses failed. It is checked BEFORE the id is looked at, so a refusal does not spend your reservation: retry against the same url_id with a better value. READ THIS BEFORE YOU GENERATE ONE. The rule is stricter than it reads, and the two things an orchestrator reaches for first are both refused: - A UUID IS ALWAYS REFUSED, AND SO IS A ULID. Both are longer than the 24-character ceiling — 36 and 26 — so both fail on the charset-and-length clause, every time. Not because either is guessable: a v4 UUID carries 122 bits. Raising the ceiling would not help, and that is arithmetic rather than a sample: a 36-character hex UUID draws on 17 symbols, so its empirical entropy cannot exceed log2(17) = 4.088, while the floor at that length is 0.9 * log2(36) = 4.653. No hex UUID of any content can clear it. - ONE CSPRNG DRAW IS NOT ENOUGH. base64url of 16 random bytes — the obvious reading of the rule — is refused 19.6% of the time (1,000,000 draws, 2026-08-31). REDRAW UNTIL IT PASSES: 1.25 draws on average, 9 at worst over 200,000. generateCtx() in @handed2me/crypto and generate_ctx() in the Python SDK do exactly that, and are the shortest correct answer. - 24 CHARACTERS IS THE CHEAP END. Within the range, refusal FALLS as you get longer: 19.7% at 22, 18.4% at 23, 16.2% at 24 (200,000 draws per length). 24 characters is 18 random bytes, and a redrawn value there carries 143.75 bits against the 128-bit floor, where 22 characters lands at 127.68. - THE CEILING IS 24 BECAUSE ABOVE IT THE RULE REFUSES ALMOST EVERYTHING. Same draws, with the length bound lifted: 29.8% refused at 32 characters, 52.5% at 40, 98.5% at 64. Until 2026-08-31 the declared range was 22-64, which advertised capacity the rule could not honour; ADR-028 narrowed it. If you hold a longer correlation id, hash it and send 24 characters of the digest. A value that means something to a person — a run name, a ticket, a timestamped job id — is refused, and that is the point rather than an inconvenience: the digest is signed, and a guessable pre-image would let anyone holding the receipt test guesses against it. Keep your own mapping from the random ctx to the run it names. That mapping is yours; we never see it. What this gives you: the receipt states, under our signature, that whoever deposited this handover supplied this exact string. Recompute the digest from your own copy of ctx and compare — verifyCtxBinding (@handed2me/crypto) and verify_ctx_binding (Python SDK) do it for you. What it does not give you: any statement about what the string names — we never see your run and have nothing to check it against — and no statement about whether the value stayed out of your own trace. That second half is yours to establish from your side, and both SDKs return it as a field fixed at false so it cannot be skimmed past. ## Envelope format EX1 wire format, base64url, single field. Header is 53+n+m bytes and IS the AAD: magic "EX1\0"(4) format_version(1) mode(1) suite(2) kdf_id(1) payload_kind(1) hdr_ext_len(2 BE) hdr_ext(n) nonce_len(1) nonce(m) id_bytes(16) tenant_uuid(16) expires_at(8 BE) then ciphertext+tag. A deposit whose header disagrees with its reservation is refused with 400. Sending anything that is not an envelope is refused too: this service will not store your plaintext even if you ask it to. ## Recipe: replace a credential in an email with a one-time link For a mail server, or an agent attached to one, that today writes a password into the body of a message. Runnable reference, which does the whole round trip and prints it: node --experimental-strip-types examples/src/email-one-time-link.ts demo \ --base-url --api-key What this changes, exactly: - the credential leaves the mailbox after one collection. A plain password stays in the inbox, in Sent Items, and in every backup of both, for as long as either lives. - collection becomes DETECTABLE. A signed delivery certificate records that the envelope was collected exactly once and that a client reported successful decryption, so "I never got it" becomes a checkable statement. - it expires. What it does NOT change, and do not imply otherwise: a link whose fragment sits in the mail body is readable by anyone who can read that mailbox, until it is claimed. This is a control against PERSISTENCE and an instrument for DETECTION. It is not a control against a mailbox compromise. For that, see Mode A or Mode B below. REST surface, which is usually what a mail server wants. MCP tool names in brackets. 1. POST /api/v1/secrets/ids { ttl_seconds } [reserve_secret_id] Bearer , scope secret:create -> 201 { url_id, tenant_uuid, expires_at, ttl_seconds } 2. Seal locally against exactly that reservation — the arithmetic is under "Creating a handover takes TWO calls" above. The order is not negotiable: the id is the KDF salt AND the id, tenant and expiry are all inside the authenticated header, so an envelope sealed before the id exists cannot be decrypted by anyone, ever. The deposit boundary refuses a mismatch with 400 rather than handing you a link that breaks hours later at the far end. 3. POST /api/v1/secrets { url_id, envelope } [create_sealed_secret] -> 201 { secret_ref, receipt_token } 4. ASSEMBLE THE URL YOURSELF: /s/#k= No response from this service contains a complete link, and none ever will. Over MCP the field is claim_path and it is a PATH. If you are waiting for a finished URL to come back you will wait forever: everything after the # is kept client-side by RFC 3986 section 3.5, so we never receive it, never log it and never store it — which is the entire property this product rests on. Put that URL in the mail and put nothing else in it. 5. Keep receipt_token. It is shown ONCE, it is the sender's capability for the proof, and for this use case it is the point: it is how the mail server later learns the credential was collected, or that somebody else collected it. GET /api/v1/r/ -> { demo, receipts: [ , ... ] } demo is true when the handover was made anonymously on the demo surface, false when it came from a registered company. No API key on that call: the token IS the capability. It authorises nothing but reading, so it is safe to store beside the sent message. Read ev and delivery.resolution off the last receipt; every resolution below travels with ev "claimed": ev "created" and nothing more nobody has touched it, and it has not expired yet ev "expired" the TTL passed and nobody ever collected it. Final resolution "leased" fetched, outcome unsettled. NOT a successful handover — a failed decryption and an abandoned tab look exactly like this. NOT final, and it does not stay this way: within ~2 minutes it becomes one of the two below resolution "acked" fetched once, and a client reported it opened resolution "expired_unacked" released to a claimant, exactly once, and never confirmed. Final. This is NOT "expired": somebody did collect it Those three are every resolution this service sends today. A fourth is declared in the same type and nothing emits it: resume_exhausted, reserved for the POST-only resume path, which is not built in this tree. Do not wait for it and do not branch on it — and do not treat a response as corrupt if a later release starts sending it. The four together are a closed set; a fifth value would be a format change. Every handover reaches exactly one of "expired", "acked" or "expired_unacked", and the receipt carrying it has final: true. FINAL: TRUE DOES NOT MEAN THE STORY IS OVER. It means that receipt's own statement is settled — no lease is open and it will not be superseded — and the "created" receipt is settled from the moment it is written. Branch on final alone and you select the receipt that OPENS the handover, the FIRST element of the chain. The terminal receipt is the one whose ev is "claimed", "expired" or "destroyed" AND whose final is true. Nothing else is needed: delivery.resolution says WHICH of the three ways a "claimed" handover ended, not whether it ended — "created" and "expired" both carry delivery: null. A final: false receipt is never the end of the story; a final: true one is not necessarily the end either. Both SDKs ship this as a function rather than a rule to remember: isTerminal / terminalReceipt in @handed2me/crypto, is_terminal / terminal_receipt in the Python SDK. get_receipt returns it as terminal. Verify the JWS against /.well-known/ex1-keys.json before believing any of it. 6. The recipient's three calls, which is precisely what the claim page performs: POST /b/s//claim -> { envelope, lease_id, lease_expires_in } decrypt locally with the fragment POST /b/s//ack { lease_id } -> { receipt } The ack is not bookkeeping. Without it the lease expires and the sender can never distinguish a successful collection from a network failure. GET never burns. The mailed link resolves to a confirmation page and only a POST moves the envelope, so a mail scanner, a link prefetcher or a preview pane cannot destroy the handover by looking at it. TTL. Set it by what the credential costs to replace, not by taste: 86400 s a key or app password the sender can revoke and reissue in a minute; a day survives a Friday-afternoon handover without a second round trip 3600 s a shared-account password, where rotation means telling other people 900 s a root credential or a recovery code, where replacement is an incident and an unclaimed link should be noticed the same morning API bounds are 60 and 2592000 seconds. Mode A or Mode B. Everything above is Mode A: one key, minted by the sender, carried in the fragment, so whoever holds the link holds the credential. Mode B splits it — link by mail, Argon2id passphrase by another channel — and is the only variant where someone who can read the mailbox gets nothing at all. The honest limit, true in this tree on 2026-08-29: MODE B HAS NO BROWSER IMPLEMENTATION. There is no Mode B chunk in the web bundle, and the served Content-Security-Policy does not carry 'wasm-unsafe-eval', which hash-wasm's Argon2id cannot compile without. So Mode B is API-only today: two agents can use it; a person handed a Mode B link cannot open it in a browser. Recipients who are people get Mode A. Push notification. There is none — poll GET /api/v1/r/. Once a minute for the first ten minutes, then every fifteen until expiry: the interesting event is nearly always in the first minutes. Said plainly, because an integrator who reads no statement about it will go looking anyway: this service delivers no webhook, no plan sells one, and none is scheduled. Polling is the mechanism, not a stopgap for something already on the way. To react to a collection, poll the receipt route above — there is nothing else to subscribe to. Claim language. The evidence level is E2. You may say the envelope was collected exactly once and that a client reported successful decryption. You may NOT say "read receipt", "the recipient acknowledged", "non-repudiation of receipt", "legally registered delivery" or "proof of identity": we observe a collection, not a person reading anything. ## Reference implementations TypeScript: packages/crypto Python: packages/sdk-py Both reproduce the same golden vectors byte for byte.