Handling gRPC-Web Requests

Some Ema APIs are not accessible via standard REST and must be called using gRPC-Web over HTTP. This guide provides step-by-step instructions for making gRPC-Web requests using curl and command-line tools.

Prerequisites

  • protoc (Protocol Buffer compiler)
  • python3
  • curl
  • Access to the .proto files defining the service (e.g., service/auth/v1/auth.proto)

Step 1: Encode the Request Message

Use protoc to encode your request message into protobuf binary format.

# Option A: Encode from inline text
cat <<EOF | protoc -I=. --encode=auth.v1.GenerateApiKeyRequest \
 service/auth/v1/auth.proto > request.bin
email: "[email protected]"
EOF

# Option B: Encode from an input file
protoc -I=. --encode=auth.v1.GenerateApiKeyRequest \
 service/auth/v1/auth.proto < input.txt > request.bin

Replace auth.v1.GenerateApiKeyRequest and the proto file path with the appropriate message type and file for your target endpoint.

Step 2: Frame the Request

gRPC-Web requires a 5-byte frame header before the protobuf payload:

+---------------+-----------------------------------+
| 1 byte flag | 4 byte payload length (big endian)|
+---------------+-----------------------------------+
| <protobuf-encoded message bytes> |
+---------------------------------------------------+
  • Flag byte: 0x00 for uncompressed messages
  • Length bytes: 4-byte big-endian integer representing the payload size

Use this Python one-liner to add the frame header:

python3 -c 'import sys,struct; data=sys.stdin.buffer.read(); \
 sys.stdout.buffer.write(b"\x00"+struct.pack(">I",len(data))+data)' \
 < request.bin > framed_request.bin

Example: If the protobuf payload is 18 bytes, the frame header is 00 00 00 00 12 (where 0x12 = 18 in decimal).

Step 3: Send the Request

Make the HTTP request with the required gRPC-Web headers:

curl -X POST https://your-instance.ema.co/<service-path> \
 -H "Authorization: Bearer <access_token>" \
 -H "Content-Type: application/grpc-web+proto" \
 -H "x-grpc-web: 1" \
 -H "x-user-agent: grpc-web-javascript/0.1" \
 --data-binary @framed_request.bin -v \
 --output response.bin

Required Headers

HeaderValueDescription
AuthorizationBearer <token>Authentication token
Content-Typeapplication/grpc-web+protogRPC-Web content type
x-grpc-web1gRPC-Web protocol indicator

Optional Headers

HeaderValueDescription
x-user-agentgrpc-web-javascript/0.1Client user agent identifier

Step 4: Strip the Response Frame

The response contains both message frames and trailer frames. Use this Python script to extract the protobuf message:

#!/usr/bin/env python3
import struct
import sys

"""
Usage:
 python strip_grpc_web.py response.bin > clean_payload.bin

- response.bin: full gRPC-Web response captured from curl (--output file)
- clean_payload.bin: the extracted raw protobuf message body
"""

def extract_messages(filename: str):
 with open(filename, "rb") as f:
 data = f.read()

 idx = 0
 messages = []
 while idx < len(data):
 if idx + 5 > len(data):
 raise ValueError("Truncated frame header")

 # Read gRPC-Web frame header
 flag = data[idx]
 length = struct.unpack(">I", data[idx + 1 : idx + 5])[0]
 idx += 5

 if idx + length > len(data):
 raise ValueError("Truncated frame payload")

 payload = data[idx : idx + length]
 idx += length

 # Flag 0x80 = trailers frame (not protobuf)
 if flag == 0x80:
 try:
 print(payload.decode("utf-8"), file=sys.stderr)
 except Exception:
 print(f"[Trailers: {payload!r}]", file=sys.stderr)
 continue
 elif flag == 0x00:
 messages.append(payload)
 else:
 print(f"Unknown frame flag {flag:#x} (skipping)", file=sys.stderr)

 return messages


def main():
 if len(sys.argv) != 2:
 print("Usage: python strip_grpc_web.py response.bin > clean_payload.bin")
 sys.exit(1)

 messages = extract_messages(sys.argv[1])

 if not messages:
 print("No proto messages found!", file=sys.stderr)
 sys.exit(2)

 # Output the first proto message to stdout
 sys.stdout.buffer.write(messages[0])


if __name__ == "__main__":
 main()

Save this script as strip_grpc_web.py and run:

python3 strip_grpc_web.py response.bin > clean_payload.bin

The trailer frames (printed to stderr) contain gRPC status information such as grpc-status and grpc-message.

Step 5: Decode the Response

Decode the clean protobuf payload:

protoc -I=. --decode=auth.v1.GenerateApiKeyResponse \
 service/auth/v1/auth.proto < clean_payload.bin

Replace the message type and proto file path with the appropriate response type for your endpoint.

Frame Format Reference

Flag ValueMeaning
0x00Uncompressed message frame
0x01Compressed message frame
0x80Trailers frame (gRPC status)

Complete Example

Generating an API key end-to-end:

# 1. Encode
cat <<EOF | protoc -I=. --encode=auth.v1.GenerateApiKeyRequest \
 service/auth/v1/auth.proto > request.bin
email: "[email protected]"
EOF

# 2. Frame
python3 -c 'import sys,struct; data=sys.stdin.buffer.read(); \
 sys.stdout.buffer.write(b"\x00"+struct.pack(">I",len(data))+data)' \
 < request.bin > framed_request.bin

# 3. Send
curl -X POST https://your-instance.ema.co/auth.v1.AuthService/GenerateApiKey \
 -H "Authorization: Bearer <token>" \
 -H "Content-Type: application/grpc-web+proto" \
 -H "x-grpc-web: 1" \
 --data-binary @framed_request.bin \
 --output response.bin

# 4. Strip
python3 strip_grpc_web.py response.bin > clean_payload.bin

# 5. Decode
protoc -I=. --decode=auth.v1.GenerateApiKeyResponse \
 service/auth/v1/auth.proto < clean_payload.bin

gRPC-Web Endpoints in Ema

The following services use gRPC-Web:

ServicePath Prefix
Authentication/auth.v1.AuthService/
Tenant Management/tenant.v1.TenantService/
Action Manager/workflows.v1.ActionManager/
Workflow Manager/workflows.v1.WorkflowManager/
Dashboard Service/workflows.v1.DashboardsService/
Debug Log Service/persona.v1.DebugLogService/

Last updated: Jul 3, 2026