Skip to main content

How encryption works

A guide for planning the work. It covers what changes when encryption is switched on for an account, how to fit it into an existing integration, and how to roll it out without a gap in service. For the envelope's fields and ready-made decryption code, see Response encryption.

What this is​

When encryption is enabled for an account, Speechlytics stops returning plain JSON and returns sealed payloads instead. Each one is a small JSON envelope containing the ciphertext; you open it with the account's key and get back exactly the JSON you would have received otherwise.

It applies on top of TLS, and it protects payloads from everything that sits between us and you: reverse proxies, load balancers, request logs, monitoring tools, and anyone who later reads a captured response body.

The trust boundary

Speechlytics generates and holds the key, so we can still decrypt your data. This is not end-to-end encryption, and it is separate from at-rest storage encryption, which applies to stored recordings and transcripts regardless of this setting.

What changes, and what does not​

Encrypted?What you do
Requests you sendNoNothing. Send audio and parameters exactly as before.
API response bodiesYesOpen the envelope, then deserialize as usual.
Call-events WebSocket framesYesOpen every frame.
Webhook payloads we POST to youYesOpen the body when the request says it is encrypted.
Errors — any status 400 or aboveNeverRead them directly. A failure is always legible.
POST /api/v1/auth/token, health checksNeverUnchanged. You must be able to authenticate before you can decrypt.
Audio returned inside a payloadYesIt is base64 inside the JSON body, so it is sealed with the rest of it and comes back when you decrypt.

Your data models do not change. Once decrypted, the JSON is byte-for-byte what the endpoint returns for an unencrypted account, so the types you already generated stay valid.

The four moving parts​

PartWhat it is
The account key32 random bytes, base64 encoded. One per main account, covering every sub-account under it. Shown once when it is generated, then stored encrypted — we cannot show it to you again.
The key id (kid)Eight hex characters derived from the key. Not a secret. It travels with every payload so you can tell "my key was changed" apart from "this payload is corrupt".
The envelopeThe JSON wrapper carrying the nonce, ciphertext and authentication tag. See The envelope.
The headersHow a response announces whether it was sealed. See Telling encrypted from plain responses.

The algorithm is AES-256-GCM with a 12-byte nonce and a 16-byte authentication tag, freshly generated for every single message. Every mainstream language has it in its standard library or in one well-known package.

One request, end to end​

Nothing about the request changed, and nothing about the model changed. The only new step is between receiving the response and parsing it.

Fitting it into your integration​

Wrap it once, at your HTTP layer. The most useful thing you can do is put this branch in a single place — the function that already turns a response into an object — rather than at each endpoint you call:

  1. Make the call exactly as you do today.
  2. If the status is 400 or above, handle the error as you do today. Errors are never sealed, so this branch comes first.
  3. Read the Encryption-Data header. Branch on that, never on the shape of the body.
  4. When it says true, compare the X-Encryption-Key-Id header with the id of the key you hold, and log a warning if they differ — that is a key rotation announcing itself.
  5. Open the envelope and use the plaintext.
  6. When it says false, use the body unchanged.
  7. Deserialize, as before.

The other two channels need the same treatment, with different recognition rules:

ChannelHow you know it is sealed
API responsesThe Encryption-Data header, always present, true or false.
Call-events WebSocket (/api/v1/calls/events)There are no headers on a frame. While the feature is on, every text frame is an envelope — decrypt unconditionally, and treat a frame that is not one as plaintext rather than letting your reader fail: as with webhooks, an event that could not be sealed is delivered in the clear rather than dropped.
WebhooksThe X-Speechlytics-Encryption header, which is omitted entirely when the event went out in the clear. Treat its absence as plaintext and handle both cases: an event that could not be sealed is still delivered rather than dropped.

If you verify a signature over a raw webhook body, verify before decrypting — the signature covers the bytes we sent, which are the sealed ones.

The key's life​

Store the key the way you store your API credentials: in a secret store or an environment variable, not in source control. Treat a rotation as a coordinated deployment — have the new key ready to ship in the same window it is changed.

Rolling it out without downtime​

A client that branches on the header is correct before encryption is switched on, which is what makes a clean cutover possible. Do it in this order:

  1. Build the branch — read the header, decrypt or pass through, deserialize. Get the key from your configuration; it is unused while the feature is off.
  2. Deploy it while encryption is still off. Every response says Encryption-Data: false, so your new code takes the pass-through path. Confirm nothing changed.
  3. Get the key from the main account's encryption settings in the Speechlytics web app and store it; it is shown once. This changes nothing while encryption is off. Then switch encryption on in the same settings, once your deployed client holds the key. Both are yours to do; nothing has to be requested.
  4. Verify on a single call: the response now carries Encryption-Data: true, the X-Encryption-Key-Id matches the key you hold, and the decrypted payload deserializes into your model.
  5. Check the other channels — a webhook delivery and, if you use it, a WebSocket frame.

Turning it off runs the same way in reverse: switch it off in the same settings, your header branch starts taking the pass-through path on its own, and you can remove the code later at your convenience.

Before you go live​

  • You branch on the header, not on whether the body "looks encrypted".
  • Error responses are handled before any decryption is attempted.
  • The kid on incoming payloads is compared with the key you hold, and a mismatch is logged loudly rather than swallowed.
  • data and tag are assembled the way your language expects — .NET takes them separately, Python and WebCrypto want them concatenated. Getting this wrong looks exactly like a wrong key.
  • WebSocket frames are decrypted unconditionally, and a frame that is not an envelope does not take the reader down.
  • A webhook with no X-Speechlytics-Encryption header is treated as plaintext.
  • The key lives in a secret store, not in source control.
  • Someone owns the rotation runbook, and knows the change takes effect immediately.

When something goes wrong​

SymptomCause
Your model deserializes into an empty objectThe envelope was parsed as if it were the payload. Branch on the header.
Authentication tag mismatch, or "decryption failed"Wrong key, or data and tag not assembled correctly for your language. Check the kid first — it tells the two apart.
The kid does not match your keyThe account's key was changed. Deploy the new one; the payload is not corrupt.
Responses arrive with Encryption-Data: falseEncryption is not in force for this account, or the endpoint is one of the exemptions above.
500 with "Response encryption failed"The account is marked as encrypted but the key could not be used. Nothing is served in the clear in that case, by design — report it to us.
Base64 or JSON parse errors on the bodySomething between us and you rewrote the response. Compare the raw bytes against what your proxy received.

When you report a problem, send us the key id, the endpoint, the timestamp and the status code. Never send us the key itself — we do not need it, and it should not travel in a support ticket.

Next steps​