Integrating via the API
The Tricryption REST Gateway is the product's published HTTP contract. It fronts a Tricryption key server so that an integration can use key management, data cryptography, principal and role administration, certificate trust and audit reading without embedding a native agent.
If you are building an application that links the native library, use the SDK Reference instead. If you speak HTTP and JSON — a service, a worker, an automation, or a browser page — this is your path.
This guide covers the three things you must get right before your first call works: how you obtain a session, how you prove you hold your key, and how you read a failure. It then walks a complete worked arc.
Endpoint details live in the API Reference — 51 operations over 42 paths — generated from a committed OpenAPI 3.1 spec you can feed to your own tooling.
Three properties to know before you start
- Every route is under
/api/…. There is no unprefixed surface. - There is no cookie. This surface sets none on any response and reads none
on any request. Credentials travel in the
Authorizationheader, and only there. - Requests are sender-constrained. A bearer credential on its own is not enough to make a request on a bound session — you also send a proof that you hold the private key the session is bound to.
Obtaining a session
Every operation except five requires a session, presented as
Authorization: Bearer <session id>. There are three ways to obtain one, and
they issue credentials with different properties — pick by who is holding
the result.
| Route | Credential | Who holds it |
|---|---|---|
POST /api/session | Password (SRP) login. As wide as everything the principal can reach. | An operator, or a backend you trust with the password |
POST /api/session/certificate/challenge then POST /api/session/certificate | X.509 certificate login, in two legs. | A registered service |
POST /api/session/scoped | Narrowed to an explicit object list, minutes-long, bound to a browser-held key. | An end user's page |
POST /api/session/delegated also
mints a session, acting as an externally-authenticated (XAuth) principal — but
it is not a login and it is guarded: only an already authenticated,
registered service may call it.
Log out with DELETE /api/session. It is
idempotent and answers 204 for a credential that is already gone, for a dead
one, and for no credential at all.
Unlike a scoped credential it is narrowed to no object list. Anyone holding the string is that principal until it expires. Treat it exactly as you would treat the password you presented to obtain it, and never put it in a URL.
Proving you hold your key (DPoP)
Requests are sender-constrained using DPoP, RFC 9449. A stolen bearer credential on its own is not enough.
The constraint belongs to the session, not to the route. If your session is bound to a key, every call it makes must carry a proof — not only the call that minted it. No route is exempt for a bound session.
Which sessions are bound:
- a certificate login whose principal has a sender key registered with the Gateway;
- a scoped browser credential — bound at mint time to the RFC 7638 thumbprint of the key you sent, and it cannot be minted unbound;
- a delegated session — bound to the sender key of the service that minted it.
One class is not bound: a password login, because nobody proved possession of anything. A proof sent on an unbound session is ignored rather than refused, so always sending one is safe — and that is the simplest rule to implement.
A proof is a compact JWS sent in the DPoP header, minted fresh per
request, carrying htm, htu, ath, bth, iat and jti. The full claim
table, the accepted algorithms and every refusal code are in
Proving you hold your key.
bth is a local extensionRFC 9449 binds the method, the URL and the token; it defines no body claim.
bth — base64url(SHA-256(<the exact request body bytes>)) — exists because
binding method and URL alone leaves the body free. If you look bth up in the
RFC and cannot find it, that is why.
htu behind a proxyIf anything terminates TLS in front of the Gateway, your client signs
https://gw.example/api/… while the Gateway process only ever sees
http://…, and every honest request mismatches with
PROOF_BINDING_MISMATCH. The fix is on the operator's side — they set
TE_GATEWAY_PUBLIC_ORIGIN to the origin your client actually addresses.
Calling from a browser
Two deployment shapes are supported, and the Gateway prefers neither.
- Direct cross-origin. The operator puts your origin on an exact-match
allowlist. Matching has no wildcards and no subdomain patterns, so list every
origin you use — staging and preview hostnames included.
*is refused at boot, not merely discouraged. - You reverse-proxy the Gateway under your own origin. No origin is crossed
and CORS is not involved at all. Make sure your proxy forwards both
AuthorizationandDPoP.
credentials: "include"The Gateway deliberately never sends Access-Control-Allow-Credentials, so that
option turns a working request into a failing one. There is no cookie to carry.
Dropping DPoP from a proxy's forwarded headers makes the browser block your
request before it is sent — the credential is perfectly valid and the
Gateway never sees the call. A CORS refusal is close to invisible from the page:
fetch rejects with a bare TypeError. The Gateway logs every refused origin
server-side, by name, so ask the operator rather than guessing.
Reading a failure
Every failure answers JSON carrying at least code and detail.
code is the contractdetail is written for the person debugging the call and its wording is not
part of the contract. Match on code, never on detail.
Two conventions are worth knowing before you write any error handling:
- A write is verified by re-reading, not by the call returning. Most
key-server writes resolve without a result, so a resolved call proves only
that the key server did not refuse. Routes that write therefore re-read and
compare, and answer
409(NOT_SAVED,NOT_VERIFIED) when the read-back disagrees. A409means "nothing errored, and the state is not what you asked for" — a different fact from a500. - An empty result and a failed read are different answers. Routes that could return an empty list distinguish "there is nothing" from "nothing was read", and never report the second as the first.
| You receive | It means | What you do |
|---|---|---|
401 SESSION_NOT_FOUND / SESSION_EXPIRED (both carry reauth: true) | No live session | Obtain a new credential; do not retry the same one |
401 PROOF_* | The sender constraint refused | Fix the proof — the code names which check failed |
403 | Authenticated, and genuinely refused | Surface it. Retrying will not help |
409 NOT_VERIFIED / NOT_SAVED | The write was not refused, and the read-back disagrees | Re-read and decide; do not assume success |
502 READBACK_FAILED | The write was sent and the verifying read failed | State is unknown — re-read before retrying |
503 | A capacity ceiling; scope says whose, and Retry-After is set | Back off and retry |
401 into a 403A 401 is recoverable — re-authenticate, or fix the proof. A 403 is the
system working as designed: the caller genuinely lacks access. Treating a denial
as an expiry hides real authorization failures; treating an expiry as a denial
breaks otherwise-valid sessions.
Worked example — protect a field, share it, take it back
Alice protects a field, Bob is denied, Alice grants Bob, Bob succeeds, Alice revokes, Bob is denied again. Credentials and blobs are illustrative and redacted.
Every authenticated request below also carries a DPoP header. It is omitted
from the samples for brevity — see above for
when it is required and when it is merely ignored.
1. Alice signs in
POST /api/session
Content-Type: application/json
{ "user": "alice", "password": "********" }
200 OK
{
"user": "alice",
"session": "<session id>",
"ks": { "host": "ks.example", "port": 8888 },
"detail": "..."
}
Alice sends Authorization: Bearer <session id> from here on.
2. Alice protects a field
The Gateway can do the cryptography for her, so no key material ever leaves it.
Omitting ttag mints a new key in the same call:
POST /api/crypto/encrypt
Authorization: Bearer <alice session>
Content-Type: application/json
{ "data": "<base64 plaintext>" }
200 OK
{
"ciphertext": "<base64>",
"ttag": "<base64 hidden link>",
"minted": true,
"plaintextBytes": 32,
"ciphertextBytes": 48,
"detail": "..."
}
ttag is the only handle to the keyWhen minted is true, store the ttag before doing anything else.
Without it the ciphertext can never be decrypted. And never send ttag: "" to
mean "use no key" — an empty string reaches the wire as "mint a new key", so a
caller who sends one by accident silently mints instead of using theirs. Omit
the field entirely to mint deliberately.
3. Alice finds Bob's ids
The ACL routes identify a principal by the numeric pair (systemId, principalId) — not by name. Read both from the server; a hardcoded
systemId addresses a principal that does not exist and yields a misleading
"unknown principal".
GET /api/principals?type=password
Authorization: Bearer <alice session>
200 OK
{
"type": "password",
"className": "UserAndPasswordInfo",
"count": 2,
"principals": [
{ "id": "00000000000003e9", "principalId": "1001", "systemId": 1, "name": "alice" },
{ "id": "00000000000003ea", "principalId": "1002", "systemId": 1, "name": "bob" }
]
}
id (16 hex digits) is what the {id}, {gid} and {pid} path segments
take. principalId (a decimal string of the same eight bytes) together with
systemId is what the ACL and export routes take. Using the wrong one is
the commonest mistake in this family.
4. Bob tries to decrypt — DENIED
POST /api/crypto/decrypt
Authorization: Bearer <bob session>
Content-Type: application/json
{ "data": "<base64 ciphertext>", "ttag": "<base64 hidden link>" }
403 Forbidden
{ "code": "ACCESS_DENIED", "detail": "..." }
This is a 403, not a 401. Bob's session is fine and there is nothing to
re-authenticate; Bob simply is not on the key's ACL.
5. Alice grants Bob read access
POST /api/keys/acl/grant
Authorization: Bearer <alice session>
Content-Type: application/json
{
"ttag": "<base64 hidden link>",
"systemId": 1,
"principalId": "1002",
"rights": ["READ"]
}
200 OK
{
"operation": "ADD_ACLENTRY",
"verified": true,
"requestedRightNames": ["READ"],
"entry": {
"systemId": 1,
"principalId": "1002",
"rightNames": ["READ"],
"maxUsage": -1
},
"detail": "..."
}
Verification is a mask test — (observed & requested) === requested — never
equality. Requesting WRITE, for instance, reads back as WRITE|READ. Compare
requestedRights with entry.rights to see the widening. A 409 NOT_VERIFIED
means the key server did not refuse and the read-back does not show what you
asked for; the read-back is the verification.
A group id works here exactly as a user id does, and granting a key to a group is the usual reason to have made one.
6. Bob tries again — GRANTED
POST /api/crypto/decrypt
Authorization: Bearer <bob session>
{ "data": "<base64 ciphertext>", "ttag": "<base64 hidden link>" }
200 OK
{ "data": "<base64 plaintext>", "plaintextBytes": 32 }
The plaintext comes back as data, base64-encoded. Treat it as sensitive.
7. Alice revokes Bob
POST /api/keys/acl/revoke
Authorization: Bearer <alice session>
Content-Type: application/json
{ "ttag": "<base64 hidden link>", "systemId": 1, "principalId": "1002" }
200 OK
{ "operation": "REMOVE_ACLENTRY", "verified": true, "entry": null, "detail": "..." }
entry: null is the success case on revoke — the entry is verified gone by
its absence from a fresh read.
rights is not accepted on revoke, and sending it is a 400There is no partial revocation of individual bits: the entry is identified by
its compare key and removed entire. To reduce a principal to fewer rights, use
POST /api/keys/acl/update with the mask
you want it to end up with.
8. Bob is denied again, and Alice logs out
Bob's next decrypt returns 403 ACCESS_DENIED. The ACL is authoritative and is
enforced on every call.
DELETE /api/session
Authorization: Bearer <alice session>
204 No Content
When you need the key material itself
The arc above never handles a raw key — the Gateway does the cryptography. When
you genuinely need the material,
POST /api/keys/export is the only
route that returns it, and it has three modes selected by recipient:
- Omit
recipient— the blobs come back in the clear. Treat the whole response as key material. kind: "cert-principal"— wrapped by the key server to an enrolled transport principal's certificate.kind: "ephemeral-ecdh"— re-wrapped by the Gateway to a P-256 public key you send, ECDH → HKDF-SHA256 → AES-256-GCM, with a fresh gateway keypair, salt and IV per key.
The raw key is in the Gateway's memory for the duration of the wrap. The wrap buys confidentiality against a passive observer on the network or at a terminating proxy, and against the service that brokered your session. It is not a property against the Gateway's operator, and it is not protection against an active attacker — that comes from your session's authentication and its DPoP proof.
keys[i].keyIv is the exported key's own CBC IV, which you need to use the
key. envelopes[i].iv is the GCM IV of the wrap, which you need to unwrap
it. Confusing them fails the tag. keys[i] and envelopes[i] are parallel
arrays keyed by the same index, and keyBlob is an explicit null — the
ciphertext is in the envelope and nowhere else.
Next steps
- API Reference — all 51 operations, grouped by family.
- Authenticating — the session models in reference form.
- Error model — the full contract.
openapi.yaml— the machine-readable contract, gated against the Gateway's route table by a conformance test.