# Decryption

Omni Partner API protects sensitive data using AES-256-GCM with a per-partner key.

Any field documented as “encrypted” is returned as a string that always starts with:

ENCRYPTED:

This page defines the encryption envelope format and how partners MUST decrypt these values.


# 1. Envelope format

All partner-facing encrypted values use a single, unified envelope format.

After the ENCRYPTED: prefix, the value is:

base64url( JSON(EncryptionEnvelope) )

where the JSON structure is:

jsonc
{
  "v": 1,
  "alg": "A256GCM",
  "kid": "<key-id>",
  "iv": "<base64url-12-byte-iv>",
  "tag": "<base64url-16-byte-tag>",
  "data": "<base64url-ciphertext>"
}

Details:

  • Algorithm: AES-256-GCM (alg = "A256GCM")
  • Key: 32-byte AES key (see section 3)
  • IV (nonce): 12 bytes (Base64URL)
  • Auth tag: 16 bytes (Base64URL)
  • Ciphertext: UTF-8 plaintext encrypted with AES-256-GCM
  • KID: Key ID (used for rotation and debugging; not required to decrypt)

Base64URL uses - and _ instead of + and / and may omit padding.


# 2. Decryption procedure

  1. Verify prefix

    • The value MUST start with ENCRYPTED:.
    • If an API is documented to has response which contain encrypted fields but does not have thisprefix, treat the response as invalid, log the error, and fail fast.
  2. Decode envelope

    • Strip the prefix:

      encoded = value.substring("ENCRYPTED:".length)
    • Base64URL-decode encoded to a JSON string.
    • Parse JSON into { v, alg, kid, iv, tag, data }.
    • Validate:
      • v === 1
      • alg === "A256GCM"
  3. Prepare AES parameters

    • Base64URL-decode iv, tag, and data to bytes.
    • Base64-decode your partner encryption key to a 32-byte buffer.
  4. AES-256-GCM decrypt

    • Create an AES-256-GCM decipher with:
      • key: 32-byte key from step 3
      • IV: 12-byte iv
    • Set the auth tag to tag.
    • Decrypt data to a UTF-8 string.
  5. Interpret plaintext

    • For scalar fields (email, phone, etc.) the plaintext is the final value.

    • For structured payloads (e.g. storeAccess), the plaintext is a JSON string which SHOULD be parsed into an object.

If decryption fails (bad key, tampered data, parse error), Partners SHOULD treat this as a hard error and log the failure (including any Toco requestId they have and the envelope kid) in their backend logs for troubleshooting and support.


# 3. Partner key material

During onboarding, Toco shares:

  • A HMAC secret for request signing (see Authentication).
  • An encryption key used to decrypt all ENCRYPTED: values.

From the partner’s perspective:

  • The encryption key is a Base64 string that decodes to 32 bytes.
  • The same key is used for all encrypted values across:
    • API responses
    • Webhook payloads
    • Any future encrypted fields

Partners MUST:

  • Store the Base64 string of this AES key securely.
  • Treat the kid field in the envelope as metadata only (for logging/debugging).
    You do not need any kid → key mapping to decrypt; always use the single encryption key we shared for this environment.

This key MUST NOT be exposed to browsers or mobile apps. It should be stored only in server-side secret storage.


# 4. Reference implementation (Node.js)

The following Node.js helper illustrates how to decrypt any ENCRYPTED: value. The same logic can be ported to other languages.

import crypto from "crypto";

const PII_KEY_B64 = process.env.PII_ENCRYPTION_KEY!;
const key = Buffer.from(PII_KEY_B64, "base64");

function base64urlToBuffer(b64u: string): Buffer {
  const pad = "=".repeat((4 - (b64u.length % 4)) % 4);
  const b64 = (b64u + pad).replace(/-/g, "+").replace(/_/g, "/");
  return Buffer.from(b64, "base64");
}

export function decryptEncryptedValue(value: string): string {
  if (!value) {
    return value;
  }

  if (!value.startsWith("ENCRYPTED:")) {
    throw new Error("Expected ENCRYPTED: prefix for encrypted field");
  }

  const encoded = value.slice("ENCRYPTED:".length);
  const json = base64urlToBuffer(encoded).toString("utf8");

  const env = JSON.parse(json) as {
    v: number;
    alg: string;
    kid: string;
    iv: string;
    tag: string;
    data: string;
  };

  if (env.v !== 1 || env.alg !== "A256GCM") {
    throw new Error("Unsupported encryption envelope");
  }

  const iv = base64urlToBuffer(env.iv);
  const tag = base64urlToBuffer(env.tag);
  const ct = base64urlToBuffer(env.data);

  const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
  decipher.setAuthTag(tag);

  const decrypted = Buffer.concat([decipher.update(ct), decipher.final()]);
  return decrypted.toString("utf8");
}

Usage examples:

  • Scalar field:

    const email = decryptEncryptedValue(order.buyer.email);
  • JSON payload:

    const storeAccessJson = decryptEncryptedValue(response.storeAccess);
    const storeAccess = JSON.parse(storeAccessJson);

# 5. Summary

  • Fields marked as encrypted in the API reference always use the ENCRYPTED: + envelope format described above.

  • Partners MUST verify the prefix, decode the envelope, and decrypt using their 32-byte AES key.

  • A single helper, such as decryptEncryptedValue, is sufficient for all current and future encrypted fields.

  • API Request Doc which has label PII means the response contain encrypted fields