Specifying Json-Ld Contexts With Http Link Headers And A Deterministic Hash
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:
- Compute a stable hash of the context document content.
- Send that hash in a response header.
- 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
Linkheader advertising the context URL - an
X-JSONLD-Context-SHA256header with the deterministic hash
- a
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:
@contextpointing 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:
- Fetch JSON document
- Resolve context (either from
@contextor from aLinkheader) - Expand JSON-LD into a canonical-ish internal graph
- 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
Linkheader 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
Linkheader - 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.