Core EngineeringJuly 7, 2026

Specifying Json-Ld Contexts With Http Link Headers And A Deterministic Hash

M

Written by

Maximus Arc

The weird bug that sent me into JSON-LD debugging mode

I hit an annoying mismatch while building an API that returned JSON-LD (JSON for Linked Data). Everything looked correct when I inspected the payload in a browser—but downstream services kept treating parts of my JSON as different “identities” depending on whether they fetched the context via an HTTP Link header or embedded it directly.

The root cause ended up being not JSON itself, but how the remote system resolved the JSON-LD “context” (a document that tells JSON-LD how to interpret terms like "name" or "schema:Person"). In JSON-LD, the context resolution step affects how the final expanded graph is produced, and that means different context resolution can lead to different output.

I wanted something deterministic: given the same semantic data, I wanted the same expanded result—even when different clients fetch contexts differently.

So I built a pattern I now use: advertise a JSON-LD context via HTTP Link headers and also include a deterministic hash of that context in a dedicated response header, so clients can verify they’re using the exact context version.

Below is what I found and the small working implementation I used to make it reliable.


Key pieces: JSON-LD context, HTTP Link headers, and a context hash

JSON-LD context (plain-language)

A JSON-LD context maps short property names to full IRIs (Internationalized Resource Identifiers). For example, you might map "type" to "@type" or "name" to a schema.org property.

HTTP Link header

HTTP allows servers to attach “metadata links” using the Link header. A common pattern is:

  • rel="http://www.w3.org/ns/json-ld#context" to point to a JSON-LD context resource.
  • The client can then fetch the context from the URL.

Deterministic context hash (the reliability trick)

Different services sometimes fetch and resolve contexts in different ways, especially if you later change the context document.

So the trick is:

  1. Compute a stable hash of the context document content.
  2. Send that hash in a response header.
  3. Require (or at least detect) that the client resolved the same context content.

For hashing JSON documents deterministically, I used a canonicalization strategy based on “sort keys and encode consistently”. (The goal is to avoid “same JSON, different whitespace/order” producing different hashes.)


A working Node.js example: API response + Link header + context hash

This example:

  • Hosts a JSON-LD context at /contexts/person.jsonld
  • Returns JSON-LD data from /api/person/:id
  • Adds:
    • a Link header advertising the context URL
    • an X-JSONLD-Context-SHA256 header with the deterministic hash

Project setup

npm init -y npm i express

Server: server.js

// server.js import express from "express"; import crypto from "crypto"; const app = express(); // 1) Define a JSON-LD context document (served as-is) const personContext = { "@context": { "@version": 1.1, "name": "http://schema.org/name", "type": "@type", "knows": { "@id": "http://schema.org/knows", "@type": "@id" } } }; // 2) A deterministic JSON stringifier: // - sorts object keys // - produces stable UTF-8 encoding function stableStringify(value) { if (value === null) return "null"; if (typeof value === "number" || typeof value === "boolean") return String(value); if (typeof value === "string") return JSON.stringify(value); if (Array.isArray(value)) { return `[${value.map(stableStringify).join(",")}]`; } if (typeof value === "object") { const keys = Object.keys(value).sort(); return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`; } // Fallback (shouldn't happen for this example) return JSON.stringify(value); } // 3) Compute the deterministic SHA-256 hash of the context content const contextCanonical = stableStringify(personContext); const contextHash = crypto.createHash("sha256").update(contextCanonical, "utf8").digest("hex"); // 4) Serve the context at a stable URL app.get("/contexts/person.jsonld", (req, res) => { res.type("application/ld+json").json(personContext); }); // 5) Return JSON-LD data with Link header and hash header app.get("/api/person/:id", (req, res) => { const { id } = req.params; // JSON-LD data: note the compact terms that rely on the external context const doc = { "@context": "https://example.test/contexts/person.jsonld", "@id": `https://example.test/person/${id}`, "type": "http://schema.org/Person", "name": `Person ${id}`, "knows": [`https://example.test/person/${Number(id) + 1}`] }; res.setHeader( "Link", `<https://example.test/contexts/person.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"; anchor="{}"` ); res.setHeader("X-JSONLD-Context-SHA256", contextHash); res.setHeader("Content-Type", "application/ld+json"); res.status(200).json(doc); }); app.listen(3000, () => { console.log("Listening on http://localhost:3000"); console.log("Context is at http://localhost:3000/contexts/person.jsonld"); });

Run it

node server.js

What happens when you call the API

Fetch the context

curl -s http://localhost:3000/contexts/person.jsonld | jq

You’ll see the JSON-LD context structure.

Fetch the person JSON-LD document

curl -i http://localhost:3000/api/person/1

You should see headers like:

  • Link: <.../contexts/person.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; ...
  • X-JSONLD-Context-SHA256: <hex>

And a JSON-LD body containing:

  • @context pointing to the external context URL
  • compact terms like "name" and "knows" that depend on that context

Why the context hash matters (and where it actually saved me)

The downstream systems I was integrating with did something like:

  1. Fetch JSON document
  2. Resolve context (either from @context or from a Link header)
  3. Expand JSON-LD into a canonical-ish internal graph
  4. Normalize or hash the expanded graph for deduplication

If the remote service fetched a different version of the context (even if it was “close enough” semantically), the expanded representation could differ, and deduplication broke.

By sending:

  • a clear Link header for discovery
  • a hash for verification

…I could detect when resolution didn’t match.


Client-side verification: compare the received context hash

Here’s a small Node snippet that:

  • downloads the context from the Link header
  • computes the deterministic hash
  • checks it matches X-JSONLD-Context-SHA256

verify.js

// verify.js import crypto from "crypto"; function stableStringify(value) { if (value === null) return "null"; if (typeof value === "number" || typeof value === "boolean") return String(value); if (typeof value === "string") return JSON.stringify(value); if (Array.isArray(value)) { return `[${value.map(stableStringify).join(",")}]`; } if (typeof value === "object") { const keys = Object.keys(value).sort(); return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`; } return JSON.stringify(value); } function sha256Hex(s) { return crypto.createHash("sha256").update(s, "utf8").digest("hex"); } function extractContextUrlFromLinkHeader(linkHeader) { // Very small parser for this specific pattern: // <URL>; rel="http://www.w3.org/ns/json-ld#context"; const match = linkHeader.match(/<([^>]+)>;\s*rel="http:\/\/www\.w3\.org\/ns\/json-ld#context"/); return match ? match[1] : null; } async function run() { const personUrl = "http://localhost:3000/api/person/1"; const resp = await fetch(personUrl); const headers = resp.headers; const link = headers.get("Link") || ""; const expectedHash = headers.get("X-JSONLD-Context-SHA256"); const contextUrl = extractContextUrlFromLinkHeader(link); if (!contextUrl) throw new Error("Could not find JSON-LD context URL in Link header"); const contextResp = await fetch(contextUrl); const contextJson = await contextResp.json(); const canonical = stableStringify(contextJson); const actualHash = sha256Hex(canonical); console.log("Context URL:", contextUrl); console.log("Expected hash:", expectedHash); console.log("Actual hash: ", actualHash); if (expectedHash !== actualHash) { throw new Error("Context hash mismatch: client resolved different context content"); } console.log("Context hash matches. Resolution is deterministic for this context version."); } run().catch((e) => { console.error(e); process.exit(1); });

Run it

node verify.js

This confirms that the context content the client resolved is exactly what the server intended to advertise.


Practical notes I learned the hard way

Hashing JSON needs a stable canonicalization rule

If I simply did JSON.stringify(personContext), the hash could change if the object key insertion order changed (or if different languages serialized differently). Sorting keys fixed that for this example.

In production, I’d typically standardize on a well-defined JSON canonicalization approach (or a canonical RDF dataset hashing approach) depending on how strict I needed to be. The point is: hash what you mean, consistently.

Separate “discovery” from “verification”

The Link header answers: where can I find the context?
The X-JSONLD-Context-SHA256 answers: is it the exact one you expect?

Together, they prevent silent drift.


Conclusion

I learned that JSON-LD bugs often aren’t about the JSON payload—they’re about how the JSON-LD context gets resolved. By pairing a JSON-LD context Link header (for discovery) with a deterministic SHA-256 hash of the context document (for verification), I made context resolution deterministic enough for downstream deduplication and normalization. The result was a much more stable API/data contract when multiple systems interpreted my data.