Echo for Debugging
Status:
stableCategory:debugAdded:2026-04-20Last reviewed:2026-04-21
Summary
Echoes back a safe view of what Trinity received: method, path, query parameters, request body, an allowlisted subset of headers, and the consumer identity resolved from your API key. Intended for integration testing and client verification.
All /api/* endpoints run the de-identification check on their
request bodies; this one is no different. A body containing an
identifier-shaped value fails with 400 DeIdentificationViolation
before any echo happens — which makes this the cleanest place to
exercise the de-identification contract, because no scoring semantics
are in the way.
Endpoint
| Field | Value |
|---|---|
| Method | POST |
| Path | /api/debug/echo |
| Full URL | https://engine.tripod-health.com/api/debug/echo |
| Authentication | API key (required) |
| Request body | application/json (any shape, must pass de-identification) |
| Response body | application/json |
| Idempotent | No (safe to repeat — no side effects — but each call gets its own request_id) |
| Rate-limited | Yes |
Purpose
Three concrete use cases:
- Client verification. You send the request you think you are sending. The echo confirms what actually arrived — useful when suspecting a proxy, middleware, or SDK is stripping or mangling something on your side.
- Consumer-identity sanity check. The response includes the consumer block resolved from your key. Confirms you are authenticating as who you think you are.
- De-identification contract probe. The cleanest way to see the
400 DeIdentificationViolationpath in action, without any interaction with scoring logic.
Authentication
Send a valid API key in the apikey header. See
authentication.md.
Request
Headers
| Header | Required | Description |
|---|---|---|
apikey | Yes | Consumer API key. Not echoed in the response. |
Content-Type | Yes (for the body) | Must be application/json. |
X-Request-ID | No | Optional correlation ID, up to 128 characters. Echoed in the X-Request-ID response header, the response body (request_id), and the received.headers object (x-request-id). |
Path / query parameters
None are required. Any query parameters sent are echoed back in
received.query verbatim.
Body
Any JSON object. The body is echoed verbatim in received.body —
subject to the de-identification check running first.
De-identification requirement
Even the debug endpoint enforces Trinity's de-identification
contract. The following cause a 400 DeIdentificationViolation with
field naming the offending path — the body is never echoed in
that case:
- A blocked field name at any depth:
name,first_name,last_name,full_name,ssn,dob,date_of_birth,email,phone,mrn,patient_id,member_id. - A value matching an identifier pattern: SSN shape
(
\d{3}-\d{2}-\d{4}), email, phone-number shape, past-dated ISO date (day-resolution date of birth).
See conventions.md for the full list and the replacement suggestions.
Response
Success — 200 OK
{
"received": {
"method": "POST",
"path": "/api/debug/echo",
"query": {
"trace": "client-smoke"
},
"body": {
"example": "value"
},
"headers": {
"x-request-id": "client-supplied-id-42",
"content-type": "application/json",
"user-agent": "integration-tests/1.2.0"
}
},
"consumer": {
"id": "12345678-90ab-cdef-1234-567890abcdef",
"username": "example-consumer",
"credential_id": "2f6d1e0a-4c3b-4f9b-a1a2-3b7c8d9e0f11"
},
"engine_timestamp": "2026-04-20T17:32:10.812Z",
"request_id": "client-supplied-id-42"
}
Response fields
| Field | Type | Always present | Description |
|---|---|---|---|
received.method | string | Yes | HTTP method received. Always "POST" for this endpoint. |
received.path | string | Yes | URL path (without the query string). Always "/api/debug/echo". |
received.query | object | Yes | Query-string parameters as parsed. {} if none sent. Keys and values are strings (or arrays of strings, when a key is repeated). |
received.body | object | array | primitive | null | Yes | The request body as parsed. {} when no body was sent but a Content-Type: application/json header was. |
received.headers | object | Yes | Allowlisted request headers only. A header is included only when the client actually sent a non-empty value. |
consumer.id | string | null | Yes | Consumer identifier. null if not resolved — which should not happen for a valid apikey. |
consumer.username | string | null | Yes | Human-readable consumer name. null if unresolved. |
consumer.credential_id | string | null | Yes | The specific credential used for this call. Distinct from consumer.id when a single consumer has multiple keys. null if unresolved. |
engine_timestamp | string (ISO 8601 UTC) | Yes | Server time when the response was emitted. |
request_id | string | Yes | Correlation ID. Matches the X-Request-ID response header and, when you supplied one, the X-Request-ID you sent. |
Headers echoed in received.headers
Only these request headers are echoed, and only when present and non-empty:
| Header | Purpose |
|---|---|
x-request-id | Correlation ID (yours, or the one Trinity generated). |
x-consumer-id | Injected upstream, consumer identifier. |
x-consumer-username | Injected upstream, consumer username. |
x-credential-identifier | Injected upstream, credential identifier. |
content-type | Expected "application/json". |
user-agent | Whatever your client sent. Useful for proving which SDK version hit Trinity. |
The x-consumer-* / x-credential-identifier headers are set by the
injected upstream; your client does not need to send
them. They appear in received.headers only when Trinity observed
them on the incoming request — so this is a way to confirm that
that injection is happening as expected.
Headers not echoed
apikey— stripped before reaching business logic. Even if it were forwarded, the endpoint would not echo it.- Any internal shared-secret header — never a caller-supplied value and deliberately excluded.
- Every other header your client might send (
accept,host,cookie, customx-*headers, etc.). The allowlist is deliberate; echoing arbitrary headers would expand the attack surface.
Status codes
| Code | Meaning | When |
|---|---|---|
200 | OK | Echo returned. |
400 | ValidationError | Body is malformed JSON (cannot be parsed when Content-Type: application/json is sent). |
400 | DeIdentificationViolation | Body contained an identifier. field names the offending path. |
401 | Unauthenticated | Missing or invalid API key. |
413 | PayloadTooLarge | Body exceeds the 512 KB limit. |
429 | RateLimited | Per-consumer rate limit exceeded. |
The endpoint does not return 415 UnsupportedMediaType today. A
non-application/json content type produces a 200 with
received.body: {} — the body parser treats the request as if no body
was sent. Always set Content-Type: application/json on POST bodies
so Trinity parses them; relying on this tolerant behavior will break
on endpoints that do validate structure.
Examples
Every example below is a real request/response pair captured from
https://engine.tripod-health.com. The consumer.id and
consumer.credential_id values are placeholders — Trinity returns
the consumer block for whichever key made the call, so yours will be
different.
The language-specific invocations assume you already have the call()
helper (or its equivalent) from quickstart.md. If
you do not, start there — the quickstart carries the full scaffolding
(auth header, retries, Retry-After, and error-envelope parsing) that
these examples reuse.
1. Canonical request and response
Send:
POST /api/debug/echo HTTP/1.1
Host: engine.tripod-health.com
apikey: $ENGINE_API_KEY
Content-Type: application/json
User-Agent: integration-tests/1.2.0
X-Request-ID: docs-debug-echo-canonical
{"probe":"quickstart","nested":{"age_bracket":"35-44"}}
Receive (200 OK):
{
"received": {
"method": "POST",
"path": "/api/debug/echo",
"query": {},
"body": {
"probe": "quickstart",
"nested": { "age_bracket": "35-44" }
},
"headers": {
"x-request-id": "docs-debug-echo-canonical",
"x-consumer-id": "12345678-90ab-cdef-1234-567890abcdef",
"x-consumer-username": "example-consumer",
"x-credential-identifier": "2f6d1e0a-4c3b-4f9b-a1a2-3b7c8d9e0f11",
"content-type": "application/json",
"user-agent": "integration-tests/1.2.0"
}
},
"consumer": {
"id": "12345678-90ab-cdef-1234-567890abcdef",
"username": "example-consumer",
"credential_id": "2f6d1e0a-4c3b-4f9b-a1a2-3b7c8d9e0f11"
},
"engine_timestamp": "2026-04-21T22:30:51.241Z",
"request_id": "docs-debug-echo-canonical"
}
2. Calling the endpoint in your language
Primary invocations. Adapt the body to your needs; the call signature stays the same.
curl
curl -sS -X POST "https://engine.tripod-health.com/api/debug/echo" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-H "User-Agent: integration-tests/1.2.0" \
-H "X-Request-ID: docs-debug-echo-canonical" \
-d '{"probe":"quickstart","nested":{"age_bracket":"35-44"}}'
Python (requests)
# Uses the call() helper from quickstart.md.
result = call("POST", "/api/debug/echo", {
"probe": "quickstart",
"nested": {"age_bracket": "35-44"},
})
print(f"authenticated as {result['consumer']['username']}")
print(f"engine saw body = {result['received']['body']}")
Node.js (native fetch)
// Uses the call() helper from quickstart.md.
const result = await call("POST", "/api/debug/echo", {
probe: "quickstart",
nested: { age_bracket: "35-44" },
});
console.log(`authenticated as ${result.consumer.username}`);
console.log("engine saw body:", result.received.body);
TypeScript
// Uses the typed call<T>() helper from quickstart.md.
interface EchoResponse {
received: {
method: string;
path: string;
query: Record<string, string | string[]>;
body: unknown;
headers: Record<string, string>;
};
consumer: {
id: string | null;
username: string | null;
credential_id: string | null;
};
engine_timestamp: string;
request_id: string;
}
const result = await call<EchoResponse>("POST", "/api/debug/echo", {
probe: "quickstart",
nested: { age_bracket: "35-44" },
});
console.log(result.consumer.username);
Go
// Uses the call() helper from quickstart.md.
body := map[string]any{
"probe": "quickstart",
"nested": map[string]any{"age_bracket": "35-44"},
}
result, err := call("POST", "/api/debug/echo", body, 3)
if err != nil {
panic(err)
}
consumer := result["consumer"].(map[string]any)
fmt.Printf("authenticated as %v\n", consumer["username"])
Java
// Uses the call() helper from quickstart.md.
// For real request/response shapes, deserialize with Jackson or Gson
// rather than the quickstart's minimal extract() helper.
String jsonBody = "{\"probe\":\"quickstart\",\"nested\":{\"age_bracket\":\"35-44\"}}";
String result = call("POST", "/api/debug/echo", jsonBody, 3);
System.out.println(result);
C# (.NET HttpClient)
// Uses the Call() helper from quickstart.md.
var result = await Call(HttpMethod.Post, "/api/debug/echo", new
{
probe = "quickstart",
nested = new { age_bracket = "35-44" },
});
var username = result.GetProperty("consumer").GetProperty("username").GetString();
Console.WriteLine($"authenticated as {username}");
PowerShell
# Uses the Invoke-Engine helper from quickstart.md.
$result = Invoke-Engine -Method Post -Path "/api/debug/echo" -Body @{
probe = "quickstart"
nested = @{ age_bracket = "35-44" }
}
"authenticated as $($result.consumer.username)"
3. Error scenarios
The de-identification filter is this endpoint's main failure mode.
Every violation returns 400 with field naming the offending path —
the offending value itself is never echoed.
Phone number in a free-text field
Send:
curl -sS -X POST "https://engine.tripod-health.com/api/debug/echo" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Request-ID: docs-debug-echo-phone" \
-d '{"note":"call me on 555-123-4567"}'
Receive (400):
{
"timestamp": "2026-04-21T22:30:52.686Z",
"error": "DeIdentificationViolation",
"message": "note appears to contain a phone number",
"field": "note",
"request_id": "docs-debug-echo-phone"
}
Blocked field name at any depth
Send:
curl -sS -X POST "https://engine.tripod-health.com/api/debug/echo" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Request-ID: docs-debug-echo-name" \
-d '{"demographics":{"name":"Alex"}}'
Receive (400):
{
"timestamp": "2026-04-21T22:30:53.123Z",
"error": "DeIdentificationViolation",
"message": "demographics.name is not accepted (Trinity only receives de-identified inputs)",
"field": "demographics.name",
"request_id": "docs-debug-echo-name"
}
SSN-shaped value
Send:
curl -sS -X POST "https://engine.tripod-health.com/api/debug/echo" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Request-ID: docs-debug-echo-ssn" \
-d '{"identifier":"123-45-6789"}'
Receive (400):
{
"timestamp": "2026-04-21T22:30:53.584Z",
"error": "DeIdentificationViolation",
"message": "identifier appears to contain a Social Security number",
"field": "identifier",
"request_id": "docs-debug-echo-ssn"
}
Email address
Send:
curl -sS -X POST "https://engine.tripod-health.com/api/debug/echo" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Request-ID: docs-debug-echo-email" \
-d '{"contact":"reach me at alex@example.com"}'
Receive (400):
{
"timestamp": "2026-04-21T22:30:53.973Z",
"error": "DeIdentificationViolation",
"message": "contact appears to contain an email address",
"field": "contact",
"request_id": "docs-debug-echo-email"
}
Catching the error envelope in code
# Uses the call() + EngineError from quickstart.md.
try:
call("POST", "/api/debug/echo", {"note": "call me on 555-123-4567"})
except EngineError as e:
if e.envelope.get("error") == "DeIdentificationViolation":
offending_field = e.envelope["field"]
print(f"client bug: {offending_field} must be structural, not free text")
else:
raise
4. Worked use cases
Confirm which consumer a key belongs to
# Uses the call() helper from quickstart.md.
result = call("POST", "/api/debug/echo", {"ping": True})
print(f"authenticated as {result['consumer']['username']} ({result['consumer']['id']})")
print(f"credential: {result['consumer']['credential_id']}")
Smoke-test a body before sending it to a scoring endpoint
// Uses the call() helper from quickstart.md.
const outbound = {
category: "pain",
resource: "orebro",
note: "pre-check",
};
const echo = await call("POST", "/api/debug/echo", outbound);
if (JSON.stringify(echo.received.body) !== JSON.stringify(outbound)) {
throw new Error("engine saw a different body than we sent");
}
console.log("body round-trips unchanged -- safe to send to /score");
Echo query parameters
Query parameters are reflected in received.query. Strings are
preserved as strings; a repeated key becomes an array.
Send:
curl -sS -X POST "https://engine.tripod-health.com/api/debug/echo?trace=foo&run=42" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Request-ID: docs-debug-echo-query" \
-d '{}'
Receive (200 OK):
{
"received": {
"method": "POST",
"path": "/api/debug/echo",
"query": { "trace": "foo", "run": "42" },
"body": {},
"headers": {
"x-request-id": "docs-debug-echo-query",
"x-consumer-id": "12345678-90ab-cdef-1234-567890abcdef",
"x-consumer-username": "example-consumer",
"x-credential-identifier": "2f6d1e0a-4c3b-4f9b-a1a2-3b7c8d9e0f11",
"content-type": "application/json",
"user-agent": "curl/8.15.0"
}
},
"consumer": {
"id": "12345678-90ab-cdef-1234-567890abcdef",
"username": "example-consumer",
"credential_id": "2f6d1e0a-4c3b-4f9b-a1a2-3b7c8d9e0f11"
},
"engine_timestamp": "2026-04-21T22:30:52.220Z",
"request_id": "docs-debug-echo-query"
}
Intended use
- Client smoke tests. A CI step posting a tiny body against this endpoint confirms the credential is live, the network path is working, and the client library is serializing correctly.
- Investigating "my call doesn't seem to be reaching Trinity" bugs. If the echo shows the body and headers you expected, everything between your client and Trinity is fine — look at whatever handler you were actually calling. If the echo shows a different body, a proxy or SDK on your side is mutating it.
- Verifying which consumer a key belongs to. The
consumerblock is the definitive answer; compare against the username you expected to be provisioned against. - Documenting the de-identification contract in onboarding
tutorials. A live call showing the
400 DeIdentificationViolationenvelope is easier to teach with than a paragraph of prose.
Mistaken use
- Do not use this as a keep-alive or warming probe. Use
GET /api/health/checkfor that. APOSTwith a body counts against your rate limit and adds no information a health check doesn't. - Do not expect an arbitrary header to be echoed. Only the allowlist is. If the header you want to verify is not in the list, this endpoint cannot confirm it arrived.
- Do not send bodies designed to exercise scoring logic here.
This endpoint will happily echo them, but scoring is not
implicated. Exercise
/scoreand/cdri/computeagainst real scoring paths instead. - Do not send PII to verify the filter works. The filter is documented; its behavior is deterministic. Send a phone-shaped or SSN-shaped test string if you want to see a rejection — do not paste a real person's contact details into a test call.
Debugging
- Capture the
request_id. It is in the response body and theX-Request-IDresponse header. 400 DeIdentificationViolation— thefieldextra names the offending path. Remove or restructure that field; see conventions.md for alternatives.- Body comes back empty (
{}) when you sent content. Confirm you sentContent-Type: application/jsonand that the body was actually included in the request.curl -d '{}'without-H 'Content-Type: application/json'hits415 UnsupportedMediaType. consumer.usernameisnull. Your key did not resolve to your key to a known consumer. Likely causes: key was rotated, was never provisioned, or was sent in the wrong header. See authentication.md.received.headersis missing a header you set. Confirm the header is on the allowlist. If it is but still missing, it may have been stripped by an intermediate proxy — check your client's outgoing request on the wire.
Rate limits
See rate-limits.md.
Related endpoints
GET /api/health/check— when you want liveness, not a request echo.GET /api/version/get— when you want to pin which build you're hitting.- authentication.md — how the
consumerblock is resolved. - conventions.md — the de-identification contract.
Change history
| Date | Change |
|---|---|
| 2026-04-20 | Initial publication. |