The timestamp always travels in
X-StandIn-Timestamp, in milliseconds. Signatures are lowercase hex.
Why the lanes differ
A WebSocket upgrade has no body worth signing. What identifies it is the thing in the URL, so the signature binds thecallId (inbound) or the channel name (outbound), and the window is tight: handshakes are dialed and answered immediately, so 60 seconds is already generous, and anything older is a replay.
A POST has a body, and the body is the whole message. Signing anything less would leave the payload unauthenticated, so the body lane signs the exact transmitted bytes, and the window is 300 seconds because chat relay retries are legitimately delayed. That 300 seconds belongs to the v1 body lane and to nothing else.
v2 goes further and binds the method and the path as well as the body hash, so a signature captured for one endpoint cannot be replayed against another. Control requests should use v2.
The only v2 verifier in this SDK is the worker’s own outcome route, the one on_call_outcome opens, and it checks freshness in the 60-second handshake window rather than the relay window: a control request is issued and answered immediately, like a dial. See Outbound call outcomes.
The inbound call handshake
StandIn dialswss://<your-host>/msteams/calling/{callId} with two headers. CallServer verifies them before the upgrade completes, and you write no code for this.
callId that is signed is the URL path segment. That matters later: when session.start arrives with a callId, the server checks it against the authenticated path and closes the call if they disagree. A body that disagrees with the path is either a bug or an attempt to ride one call’s signature into another’s session.
It is also why session.call_id is trustworthy: it is the value the HMAC signed, not something a caller supplied.
The replay guard
Verifying the signature is not enough on its own. A correctly signed upgrade, captured and replayed inside the 60-second window, would otherwise open a second socket. SoCallServer keeps each accepted handshake as single-use.
Three details in that guard are worth knowing, because each closes a real gap:
The fingerprint uses the normalized signature. Verification accepts case and whitespace variants, so keying the cache on the raw header would let the same capture replay once per casing.
Entries age from the signing timestamp, never from arrival. Verification accepts a timestamp up to the window in the future, so an entry aged from arrival could be pruned while its signature was still valid, reopening the exact replay the guard exists to close. Aged from the signing time, an entry lives precisely as long as the signature does.
Pruning is throttled by time, not triggered by size. Rebuilding the map once it passes a watermark would make every later request O(n). Only correctly signed, not-yet-seen handshakes reach the cache at all, since a bad signature is rejected earlier and a replay returns before it, so the map tracks StandIn’s real call rate rather than attacker traffic.
A replay gets 401 handshake already used.
What the SDK refuses
verify_handshake and verify_body fail closed. They return False, never raise, and never tell the caller which part was wrong.
Comparison is constant-time in every case that reaches it.
Signing helpers
All of these prepare signatures. None of them send anything.sign_handshake and verify_handshake
handshake_id is the callId when StandIn dials you, and the channel name when your worker dials the chat channel. ChatChannel signs with it on your behalf; CallServer verifies with it on your behalf.
sign_body and verify_body
"{timestampMs}." followed by the exact body bytes.
canonical_request and sign_request
path is the request path, without the origin and without the query string, matching the worker verifier.
tenantId, which is what selects the organisation to act on. Binding method and path as well means a signature for one endpoint is useless against another.
What the secret does not decide
The handshake proves the dial came from StandIn. That is all it proves. Authentication is not authorization.verify_handshake says the socket is genuine; it says nothing about whether this particular caller may be answered. Who is allowed to reach your agent is deployment policy, and it lives in your handler.
isInboundCallAllowed and its companions). A Python deployment writes the check in its handler instead, which is the snippet above. See Refusing a call.
The outbound fetch guard
An agent that can show the caller a picture will sooner or later be handed a URL by its own model, and that model is steered by whoever is on the call. So the URL is untrusted input wearing a trusted costume, and a plainGET of it is a server-side request forgery: 169.254.169.254 is cloud credentials, 127.0.0.1 is whatever else you run, and 10.0.0.0/8 is the rest of your network.
The subtle half is the rebind. Validating a hostname resolves it once, and the resolution the HTTP client does a moment later can answer differently, so the address the socket actually connects to is re-checked against the same rules. One redirect hop is followed, because image CDNs habitually redirect to the real asset, and the target goes through the whole guard again rather than being trusted for having come from a host that passed.
Link-local is on the list by name, because that is where cloud metadata lives.
These three are top-level exports in the TypeScript SDK and live in
standin.fetch here. See Vision and the avatar for the image case.The generated wire builders
standin.protocol is generated from the schema and carries the frame builders and parsers themselves: audio_frame, session_end, pong, assistant_cancel, parse_message, parse_session_start and decode_pcm. assistant_cancel is the only one re-exported from the standin barrel; the rest are from standin.protocol import ....
They are public because the wire is public, and they are what the shared conformance vectors assert across both languages. A handler should still not reach for them: CallServer owns the sequence number and the outbound timeline, and a hand-built frame is how those two stop agreeing.
Operational notes
Clock skew is authentication. Both windows are absolute differences, so a worker whose clock drifts more than 60 seconds stops accepting calls. Run NTP. Terminate TLS at your ingress. StandIn dialswss://, and the handshake headers are only as private as the transport carrying them.
The secret is the credential. It must byte-match the portal value. Keep it in the environment, never in a source file or an image layer, and rotate it in the portal if it is ever printed in a log.
Bind narrowly when you can. STANDIN_HOST=127.0.0.1 keeps the listener off every other interface when a local tunnel is the only thing that should reach it. The upgrade is authenticated either way, but there is no reason to offer more surface.
Capacity is checked before crypto. A flood cannot make the worker spend CPU verifying signatures for calls it was never going to accept.
Conformance
Both SDKs are generated fromprotocol/schema.yaml and checked against shared vectors in protocol/conformance.json, the HMAC cases included. The Python and TypeScript implementations sign identically, byte for byte, which is what lets a Python worker and a TypeScript worker share one StandIn identity. The protocol modules are generated and drift-gated, never hand-edited.
Next
CallServer
Where the handshake and the replay guard are enforced.
Chat
The outbound lane, where your worker is the one signing.