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`. Requires // @adastracomputing/ink >= 0.1.3 (installed as `next` while // pre-1.0). From 0.1.7 onward 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.
Conformance test
Once the receiver is wired up, run the published test vectors against it:
git clone https://github.com/Ad-Astra-Computing/inkcd ink/test-vectorsnode run-vectors.mjs --target http://localhost:8787/ink/v1Every vector is a known-good or known-bad request with the expected response. A receiver that passes all of them is conformant for the envelope, signature, and replay surface. Policy gates and risk scoring are deployment choices; vectors do not test those.
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.