Implementing a Receiver
This guide walks through the receive side of INK end to end. By the time you finish you have a working /ink/v1/<did>/intent endpoint that accepts a signed envelope, verifies it, applies the policy gates a foreign-sender receiver should apply, and returns the canonical responses senders expect. Pair this with Sending Your First Envelope on the sender side and Accepting Foreign Senders for the deeper policy reference.
We use TypeScript and @adastracomputing/ink for the canonical primitives. The same shape applies in any language that can verify Ed25519, decode JCS, and read HTTP headers.
The contract you are implementing
A conforming INK receiver exposes POST /ink/v1/<recipientDid>/intent and accepts the canonical envelope shape. On every request it:
- Validates the envelope schema. Rejects malformed envelopes with
400 invalid_envelope. - Verifies the HTTP §3.3 transport signature in the
Authorization: INK-Ed25519 <sig>header against the sender’s public key. Rejects mismatches with401 unauthorized. - Verifies the body-level Ed25519 signature inside the envelope using the same key. Rejects with
400 invalid_signature. - Checks the
expiresAtfield is in the future and the bodytimestampis within the freshness window. Rejects expired or future-skewed envelopes. - Records the body
nonceagainst a per-(sender, recipient) replay store. Rejects duplicate nonces with400 nonce_replay. - Confirms
envelope.to === recipientDid. Rejects mismatches with400 envelope_mismatch. - Applies recipient policy (the gate appropriate to your service: per-user acceptance flags, allow-lists, optional risk scoring, etc).
- Returns
200 { "accepted": true, "pendingActionId": "<ulid>" }on success, or one of the documented error responses.
The shape, status codes, and error names are part of the protocol surface, not implementation choices. A sender catches unknown_sender and changes its first-message intent; a sender catches nonce_replay and regenerates a nonce. Diverging from these names breaks every conformant client.
Minimum dependencies
npm install @adastracomputing/ink honoThe library gives you the signing/verifying primitives, JCS canonicalization, multibase encoding, and the verifyInkAuth middleware. Hono is just a small HTTP router; substitute Express, Fastify, or your worker runtime.
Sketch
import { Hono } from "hono";import { verifyInkAuth, verifyMessage, validateMessage, decodePublicKeyMultibase,} from "@adastracomputing/ink";
const app = new Hono();
app.post("/ink/v1/:recipientDid/intent", async (c) => { const recipientDid = c.req.param("recipientDid"); const body = await c.req.json();
// 1. Schema. `validateMessage` runs the canonical envelope shape // plus the intent-specific payload schema in one call. It throws // on any drift; surface as `invalid_envelope`. A bare // `npm install @adastracomputing/ink` resolves to the `latest` // dist-tag; `@adastracomputing/ink@next` resolves to the `next` // line. The envelope and per-intent payload // schemas are `.strict()` by default — receivers reject unknown // top-level and payload fields, and every string field is // explicitly capped so a malformed sender cannot DoS the // canonicalizer. let envelope; try { envelope = validateMessage(body); } catch (err) { return c.json({ error: "invalid_envelope", details: (err as Error).message }, 400); }
// 2. Identity binding. The envelope's `to` field must equal the // recipientDid in the URL. Without this check a sender could // address an envelope to user A and POST it to user B's endpoint. if (envelope.to !== recipientDid) { return c.json({ error: "envelope_mismatch" }, 400); }
// 3. Sender key resolution. For did:key the public key IS the // identifier (no network fetch). For did:web fetch the DID // document. For tulpa: fetch the Agent Card. Each receive path // has its own resolver — the snippet below covers did:key. let senderPubKey: Uint8Array; if (envelope.from.startsWith("did:key:")) { try { senderPubKey = decodePublicKeyMultibase(envelope.from.slice("did:key:".length)); } catch { return c.json({ error: "unresolvable_sender_key" }, 401); } } else { // Use your own card-fetch + caching here. See the // accepting-foreign-senders guide for the SSRF floor every // receiver must enforce on document fetches. senderPubKey = await fetchSenderCardKey(envelope.from); }
// 4. HTTP §3.3 transport signature. verifyInkAuth handles the // signature base assembly, base64url decode, freshness window, // and nonce peek. Pass it a nonceStore so single-use is enforced. const authResult = await verifyInkAuth({ authHeader: c.req.header("authorization"), method: "POST", path: `/ink/v1/${recipientDid}/intent`, recipientAgentId: recipientDid, body, resolvePublicKey: () => senderPubKey, nonceStore: yourNonceStore, }); if (!authResult.valid) { return c.json({ error: "unauthorized", details: authResult.error }, 401); }
// 5. Body-level signature. The HTTP signature binds the request; // the body signature binds the sender to the envelope content. // Without this an attacker who captures a valid transport // request could re-sign the wrapper for a different recipient. const bodyValid = await verifyMessage(envelope, senderPubKey); if (!bodyValid) { return c.json({ error: "invalid_signature" }, 400); }
// 6. Recipient policy. This is where your service decides whether // the user wants to receive from this sender at all. See the // accepting-foreign-senders guide for the full policy surface; // the minimum is "the recipient has opted in to senders of // this DID method." For did:tulpa senders policy is usually // "they are an existing connection or have a valid handshake." const policy = await getRecipientPolicy(recipientDid); if (!policy.allows(envelope.from)) { return c.json( { error: "recipient_rejected_foreign_sender", reason: policy.reason }, 403, ); }
// 7. Optional risk scoring. Skip if you do not run a risk-policy // layer; the protocol does not require it. If you do, you define // your own verdict shape, bands, and reason codes. See Receiver // Risk Policy (/shield/) for the protocol-level view. const verdict = await yourRiskScorer.score(envelope).catch(() => null); if (verdict?.risk === "high") { return c.json({ error: "rejected_by_policy", reason: "high_risk" }, 403); }
// 8. Persist + return. The pending action id is the handle the // sender uses in correlated follow-ups. ULID is the convention; // any monotonically-orderable id works. const pendingActionId = await persistInboundEnvelope(envelope, verdict); return c.json({ accepted: true, pendingActionId });});
export default app;What you can skip on day one
Before you have your first conformance test in place, three things are worth deferring to keep the receiver minimal:
- Risk scoring. The protocol does not require it. Add it later when you have inbound traffic and a reason to care.
- Encryption. The
schedule_meeting,context_share, andmulti_party_syncintents MUST be encrypted, but you can returnencryption_requiredfor them and add the decrypt path when you actually need to accept them. See the Encrypted Intents guide. - Audit log. You will want one eventually for operator visibility, but the receive path does not depend on it. Add the storage hook once you know what shape you want.
What you cannot skip
These are protocol-conformance requirements; skipping any of them means you are not running INK:
- The Authorization header check. A receiver that accepts envelopes without checking the HTTP signature is not authenticating senders. Any attacker can claim any identity.
- The body signature check. The HTTP signature only binds the request; it does not bind the envelope content to the claimed sender. Skipping the body check means an attacker who captures any signed request can re-sign the wrapper with their own key and submit it.
- The nonce store. Without per-(sender, recipient) replay protection a single envelope replays forever. The freshness window alone is not enough.
- The
tocheck. An envelope addressed to user A delivered to user B’s endpoint must be rejected. Without this every endpoint is a multi-user delivery oracle. - The expiresAt + freshness window. An old envelope is meaningfully different from a fresh one; treating them the same lets stale state leak across time boundaries.
- The raw-body byte gate. A signed body must be valid UTF-8 in its raw bytes and must carry no lone surrogate escape, both checked before the JSON parse (see Canonicalization). The
c.req.json()call in the sketch above decodes leniently, which substitutesU+FFFDfor an invalid byte sequence and loses the original bytes, so a receiver that holds the raw body bytes should run them throughparseSignedBodyBytesbefore verifying rather than parsing them first.
Parsing raw body bytes
parseSignedBodyBytes(bytes) is the entry point for a receiver that holds the raw request body as a Uint8Array. It decodes the bytes with a fatal UTF-8 decoder, runs the lone-surrogate scan on the decoded text, then parses the JSON, so the byte gate and the surrogate scan run before the parse in the order the spec requires.
import { parseSignedBodyBytes, ParseSignedBodyError } from "@adastracomputing/ink";
const raw = new Uint8Array(await c.req.arrayBuffer());let body;try { body = parseSignedBodyBytes(raw);} catch (err) { if (err instanceof ParseSignedBodyError) { // err.reason is "utf8" or "surrogate" return c.json({ error: "invalid_envelope", details: err.reason }, 400); } // A malformed body surfaces the native SyntaxError from JSON.parse. return c.json({ error: "invalid_envelope" }, 400);}ParseSignedBodyError carries a reason of "utf8" or "surrogate" so a receiver can map each byte-gate rejection to its own response without matching on the error message. A structurally malformed body still surfaces the native SyntaxError from JSON.parse.
Conformance test
Once the receiver is wired up, check it against the packaged conformance/v1 corpus. It ships in the @adastracomputing/ink tarball and resolves through the ./conformance/* subpath export, indexed by conformance/v1/manifest.json. Read the manifest to enumerate the categories and their vector files, then run each vector and compare your accept or reject decision against the expected outcome.
Conformance is category-based accept or reject agreement across the TypeScript reference implementation and the independent Go implementation (under go/): both run this same corpus and MUST decide every vector the same way. A receiver that matches on every applicable category is conformant for the envelope, signature, and replay surface. Policy gates and risk scoring are deployment choices; the corpus does not test those. See Test Vectors for the category list.
You can also send live envelopes from the ink-interop Python CLI:
ink-interop send \ --from-did did:key:zAlice \ --to-did did:key:zReceiver \ --target-url http://localhost:8787/ink/v1/did:key:zReceiver/intent \ --intent-type connection_request \ --purpose "first end-to-end test" \ --seed alice.seedA receiver that round-trips with both @adastracomputing/ink (the library) and ink-interop (the from-scratch Python sender) is interoperating at the wire level.
Where to go next
- Accepting Foreign Senders. The policy and security layer this guide leaves abstract (the
getRecipientPolicyand SSRF floor calls). Required reading before you accept envelopes from foreign DID methods. - Sending Your First Envelope. The sender-side counterpart. Run the receiver you just built against the CLI walkthrough there for a full round trip.
examples/foreign-sender-receiver/. The policy + SSRF-defense modules in TypeScript. Read it side by side with the code you wrote to spot anything you missed.examples/reference-receiver/. A complete, deployable receiver built only on the published package — agent card,did:webdocument, envelope verification, rate limit, and audit log in one small worker. It runs live at ink-echo.tulpa.network, so you can point your sender at a known-good endpoint before standing up your own.