Skip to main content

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

  1. Every route is under /api/…. There is no unprefixed surface.
  2. There is no cookie. This surface sets none on any response and reads none on any request. Credentials travel in the Authorization header, and only there.
  3. 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.

RouteCredentialWho holds it
POST /api/sessionPassword (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/certificateX.509 certificate login, in two legs.A registered service
POST /api/session/scopedNarrowed 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.

A password-login session id is as wide as the principal

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.

warning
bth is a local extension

RFC 9449 binds the method, the URL and the token; it defines no body claim. bthbase64url(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.

info
The failure that will actually bite you is htu behind a proxy

If 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 Authorization and DPoP.
warning
Do not set 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.

info
code is the contract

detail 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. A 409 means "nothing errored, and the state is not what you asked for" — a different fact from a 500.
  • 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 receiveIt meansWhat you do
401 SESSION_NOT_FOUND / SESSION_EXPIRED (both carry reauth: true)No live sessionObtain a new credential; do not retry the same one
401 PROOF_*The sender constraint refusedFix the proof — the code names which check failed
403Authenticated, and genuinely refusedSurface it. Retrying will not help
409 NOT_VERIFIED / NOT_SAVEDThe write was not refused, and the read-back disagreesRe-read and decide; do not assume success
502 READBACK_FAILEDThe write was sent and the verifying read failedState is unknown — re-read before retrying
503A capacity ceiling; scope says whose, and Retry-After is setBack off and retry
warning
Do not collapse a 401 into a 403

A 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": "..."
}
warning
The ttag is the only handle to the key

When 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" }
]
}
Two id spellings, and they are not interchangeable

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": "..."
}
The key server widens rights, and the response shows it

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.

warning
rights is not accepted on revoke, and sending it is a 400

There 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.
Be precise about what the ephemeral mode protects you from

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.

In ephemeral mode there are two IVs and they are different values

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.