Skip to content

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:

  1. Validates the envelope schema. Rejects malformed envelopes with 400 invalid_envelope.
  2. Verifies the HTTP §3.3 transport signature in the Authorization: INK-Ed25519 <sig> header against the sender’s public key. Rejects mismatches with 401 unauthorized.
  3. Verifies the body-level Ed25519 signature inside the envelope using the same key. Rejects with 400 invalid_signature.
  4. Checks the expiresAt field is in the future and the body timestamp is within the freshness window. Rejects expired or future-skewed envelopes.
  5. Records the body nonce against a per-(sender, recipient) replay store. Rejects duplicate nonces with 400 nonce_replay.
  6. Confirms envelope.to === recipientDid. Rejects mismatches with 400 envelope_mismatch.
  7. Applies recipient policy (the gate appropriate to your service: per-user acceptance flags, allow-lists, optional risk scoring, etc).
  8. 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

Terminal window
npm install @adastracomputing/ink hono

The 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, and multi_party_sync intents MUST be encrypted, but you can return encryption_required for 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 to check. 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:

Terminal window
git clone https://github.com/Ad-Astra-Computing/ink
cd ink/test-vectors
node run-vectors.mjs --target http://localhost:8787/ink/v1

Every 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:

Terminal window
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.seed

A 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 getRecipientPolicy and 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:web document, 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.