The OpenAI Responses API user migration is easy to misread as a one-field rename. It is actually a split. The deprecated field mixed end-user safety attribution with prompt-cache routing, while the current request contract provides safety_identifier and prompt_cache_key for those separate jobs.
I would rather make that distinction explicit in one request builder than scatter it across call sites. The result is easier to review, keeps raw identity out of the payload, and can be verified without sending a model request.
Why OpenAI Responses API user migration is a split
The Responses API create reference marks user as deprecated and says it is being replaced by both fields. It describes safety_identifier as a stable end-user identifier used to help detect policy abuse, with a maximum length of 64 characters. It describes prompt_cache_key as a key that helps route requests with similar reusable prefixes.
Those are different lifecycles.
A safety identifier should remain stable for one account across prompts. In this scheme, I change the cache key when the reusable prompt contract changes, and I can share it across users whose requests have the same prefix. Blindly copying the old value into both fields misses the chance to separate the policies; it may still be valid when the old value is privacy-preserving and per-user cache grouping is intentional.
I model the split with two inputs:
- a canonical internal subject for identity;
- a cache group plus prompt-contract version for routing.
That makes a review question concrete: does this value identify a user, or does it identify reusable prompt structure?
Build two values from two policies
OpenAI's safety guidance recommends hashing a username or email instead of sending identifying information. In production I prefer an opaque internal account ID when one exists. I also use HMAC-SHA-256 with a secret pepper, rather than an unkeyed hash, so a copied digest is less useful for guessing common identifiers.
HMAC is an application choice here, not an API requirement. The pepper belongs in a secret manager and should be at least 32 random bytes. The committed sample uses an obvious fixed fixture solely to keep its output reproducible.
The derived value remains a stable, linkable pseudonym—not anonymous—and its privacy depends on protecting the pepper.
var subjectBytes = Encoding.UTF8.GetBytes(subject);
var safetyIdentifier = Convert.ToHexString(
HMACSHA256.HashData(privacyPepper, subjectBytes))
.ToLowerInvariant();
var promptCacheKey =
$"{cacheGroup.Length}_{cacheGroup}_{cacheVersion.Length}_{cacheVersion}";
if (promptCacheKey.Length > 64)
throw new ArgumentException("Prompt cache key is too long.");
var request = new
{
model = "your-model",
input,
safety_identifier = safetyIdentifier,
prompt_cache_key = promptCacheKey
};
The lowercase hexadecimal digest is exactly 64 characters, which fits the documented safety-identifier maximum. The sample also caps the cache key at 64 characters and restricts its components to a conservative ASCII subset. Length prefixes keep pairs such as a-b plus c distinct from a plus b-c.
The cache key says nothing about the person. In the sample, 12_support-flow_2_v3 identifies one reusable prompt contract; changing the prompt contract to v4 changes the key.
That separation also makes rotation decisions visible. Rotating the HMAC pepper changes safety identifiers, so a production rollout may need a deliberate overlap plan. In this sample's policy, a deliberate prompt-contract revision advances the cache version even when the identity policy stays unchanged.
Verify the payload before transport
The runnable sample on main uses only the .NET 10 shared framework. It builds JSON locally and performs fourteen checks, including these invariants:
user is absent
safety_identifier is stable for the same subject
different subjects produce different identifiers
raw identity is absent from JSON
prompt_cache_key is shared across matching prompt contracts
a prompt-version change produces a different cache key
It also rejects a short pepper before a transport boundary and serializes the same request twice to prove the bytes are identical. The executable makes no OpenAI request and requires no runtime network connection; restore and vulnerability-audit commands may contact configured NuGet sources.
This is useful because a successful HTTP response would not prove the migration is correct. Both replacement fields are optional. A request can be accepted while omitting the per-user safety signal, the application-supplied cache-routing key, or both. A local contract test catches the omission where the request is assembled.
The prompt-caching guide adds one important boundary: prompt_cache_key influences routing, but it does not pin traffic to a machine or guarantee a cache hit. Prefix content still has to match, and cache eligibility depends on the model and prompt shape. I treat the key as a routing hint, not a response cache or correctness mechanism.
Limits and when not to use this pattern
This sample does not prove that OpenAI accepts a chosen model, that a cache read occurs, or that any safety action will result. Those belong to integration tests and production telemetry. It also does not prescribe one universal cache-key scheme; high-volume applications should tune grouping against real prefix reuse and overflow behavior.
For anonymous previews, OpenAI's guidance allows a session ID as the safety identifier. For trusted internal batch jobs with no individual end user, forcing a fictional per-user identity would be misleading. I would document that boundary instead of inventing one.
The practical migration rule is small: remove user, derive safety_identifier from a stable privacy-preserving identity policy, derive prompt_cache_key from a reusable prompt policy, and test both independently.
What deprecated request field are you turning into an explicit contract test next?
Happy coding!
Top comments (0)