Response encryption
When encryption is enabled for your account, Speechlytics returns encrypted payloads instead of plain JSON. You decrypt them with your account's key.
Encryption is switched on and off by the main account itself, in the account's security settings in the Speechlytics web app, where the key is also managed. Nothing has to be requested from Speechlytics, provided response encryption is available on your deployment.
Getting the key
The key is issued from the account's encryption settings in the web app and is shown once, at the moment it is generated or changed. Store it in your secret store straight away; it cannot be displayed again.
Get the key before encryption is switched on. Encryption can be switched on before anyone has copied the key, and then no client can read the responses.
This is applied on top of TLS. It protects payloads from anything sitting between Speechlytics and you — reverse proxies, load balancers, request logs, monitoring tools — and from anyone who can read a captured response body afterwards. Speechlytics holds the key and can still decrypt your data; this is not end-to-end encryption.
This is separate from at-rest storage encryption, which applies regardless of this setting. This page is only about what travels over the wire to you.
Sub-accounts
Encryption is set on your main account and covers every sub-account under it. There is
one key for the whole account, so a sub-account's responses, WebSocket frames and
webhook payloads arrive sealed under that same key and carry its kid. Sub-accounts
have no separate key and no switch of their own — you do not need to enable anything
per sub-account, and one key is all you have to store.
What is encrypted
- API response bodies
- Call-events WebSocket frames (
/api/v1/calls/events) - Outbound webhook payloads
Request bodies are not encrypted — send them exactly as before. Error responses (any status of 400 or above) are never encrypted, so a failure is always readable.
Telling encrypted from plain responses
Every authenticated API response carries a header:
Encryption-Data: true
X-Encryption-Key-Id: 3f9a2c1b
Encryption-Data is always present and is false on responses that are not encrypted.
Branch on it rather than inspecting the body.
Webhook requests carry the equivalent headers, but only when the event was actually encrypted:
X-Speechlytics-Encryption: true
X-Speechlytics-Key-Id: 3f9a2c1b
Unlike the API response header, these are omitted entirely on an unencrypted
webhook call — there is no X-Speechlytics-Encryption: false. Treat their absence as
meaning the event was sent in the clear.
The envelope
An encrypted body is JSON:
{
"v": 1,
"alg": "A256GCM",
"kid": "3f9a2c1b",
"iv": "5vJ8t1KpQ0mXbN2r",
"data": "…base64 ciphertext…",
"tag": "…base64 authentication tag…"
}
| Field | Meaning |
|---|---|
v | Envelope version. Currently 1. |
alg | Always A256GCM — AES-256 in GCM mode. |
kid | The id of the key this payload was encrypted with. |
iv | The 12-byte GCM nonce, base64. Different on every message. |
data | The ciphertext, base64. |
tag | The 16-byte GCM authentication tag, base64. |
data and tag are separate base64 fields — the tag is not appended to the
ciphertext in the envelope itself. Some decryption libraries (see below) expect the
tag concatenated onto the ciphertext, so you assemble that yourself when needed.
If kid does not match the key you hold, the account's key has been changed — check
with whoever manages your main account rather than treating it as a corrupt payload.
Decrypting
C#
System.Security.Cryptography.AesGcm takes the tag as a separate argument, which
matches the envelope directly:
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
static string Decrypt(string envelopeJson, string base64Key)
{
using var document = JsonDocument.Parse(envelopeJson);
var root = document.RootElement;
var nonce = Convert.FromBase64String(root.GetProperty("iv").GetString()!);
var data = Convert.FromBase64String(root.GetProperty("data").GetString()!);
var tag = Convert.FromBase64String(root.GetProperty("tag").GetString()!);
var plain = new byte[data.Length];
using var aes = new AesGcm(Convert.FromBase64String(base64Key), 16);
aes.Decrypt(nonce, data, tag, plain);
return Encoding.UTF8.GetString(plain);
}
Python
import base64, json
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def decrypt(envelope_json: str, base64_key: str) -> str:
envelope = json.loads(envelope_json)
key = base64.b64decode(base64_key)
nonce = base64.b64decode(envelope["iv"])
ciphertext = base64.b64decode(envelope["data"]) + base64.b64decode(envelope["tag"])
return AESGCM(key).decrypt(nonce, ciphertext, None).decode("utf-8")
Python's AES-GCM expects the tag appended to the ciphertext, which is why the two are concatenated above.
JavaScript
async function decrypt(envelopeJson, base64Key) {
const envelope = JSON.parse(envelopeJson);
const b64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
const key = await crypto.subtle.importKey(
"raw", b64(base64Key), "AES-GCM", false, ["decrypt"]);
// WebCrypto also expects the tag appended to the ciphertext.
const payload = new Uint8Array([...b64(envelope.data), ...b64(envelope.tag)]);
const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: b64(envelope.iv) }, key, payload);
return new TextDecoder().decode(plain);
}
When your key changes
There is no overlap period. The moment the key is changed, the previous one stops working and every payload carries the new kid. Update your
integration in the same window, and keep the new key somewhere you can retrieve it — it
is shown once and stored encrypted thereafter.
Rotating while encryption is off costs nothing, which is the moment to do it if you have a choice: the key changes, but no payload is sealed with either version.
To stop using encryption entirely, switch it off in the same settings; responses
return to plain JSON with Encryption-Data: false on the very next one.