Keep real keys private with OpenAI Responses.
Let your agent use a service without placing the key in its conversation. The Responses API is OpenAI’s interface for model requests and tool calls. Your server handles the tool call: send input + tools to client.responses.create, inspect response.output for function_call items, resolve them server-side, then send a follow-up call with function_call_output items and previous_response_id. This integration pattern resolves the credential between the tool call and the tool result, so the real key never enters the conversation state.
If you have an existing Assistants integration: OpenAI's deprecations page confirms a 26 August 2026 shutdown. This integration pattern is the same; the API surface is different. Migrate to Responses now; the snippet below is drop-in equivalent.
Before you start
npm install openai deny-sh(orpip install openai deny-sh requestsfor Python).openaiv6 or later.- A deny.sh account (free to start), its API key (service access credential) and a vault entry, set up at /dashboard/vault.
- An OpenAI API key.
TypeScript: Responses API turn loop
Show code : ~58 lines, typescript
// npm install openai deny-sh@^2.2.0
import OpenAI from 'openai';
import { vaultGet } from 'deny-sh/client';
const client = new OpenAI();
const tools = [{
type: 'function' as const,
name: 'get_invoice',
description: 'Look up a Stripe invoice by id',
strict: true,
parameters: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
additionalProperties: false,
},
}];
let resp = await client.responses.create({
model: 'gpt-5.5',
input: 'Look up invoice in_1Abc123XYZ',
tools,
});
// Walk function_call items, resolve server-side, send results back.
while (true) {
const calls = resp.output.filter(o => o.type === 'function_call');
if (calls.length === 0) break; // model is done; resp.output_text is the answer
const outputs = [];
for (const call of calls) {
if (call.name === 'get_invoice') {
const { id } = JSON.parse(call.arguments);
// resolve the credential INSIDE the tool boundary
const stripeKey = await vaultGet('stripe-prod', process.env.VAULT_PW!);
const r = await fetch(`https://api.stripe.com/v1/invoices/${id}`, {
headers: { Authorization: `Bearer ${stripeKey}` },
});
// narrowed DTO: pick only the fields the model needs. Never return
// raw upstream JSON, error bodies, or response headers to the model.
const body = r.ok ? await r.json() : null;
const dto = body
? { id: body.id, amount_due: body.amount_due, status: body.status }
: { error: 'invoice_lookup_failed', status: r.status };
outputs.push({
type: 'function_call_output' as const,
call_id: call.call_id,
output: JSON.stringify(dto),
});
}
}
resp = await client.responses.create({
model: 'gpt-5.5',
previous_response_id: resp.id, // chain server-side state
input: outputs,
tools,
});
}Why the key stays private
OpenAI's infrastructure sees only: the tool schema (no key), the model's tool-call arguments (no key), and the tool output (the invoice JSON, no key). The Stripe key never appears in the input, the tool definition, the system prompt, or any function_call_output. A prompt-injection that tries to print STRIPE_KEY cannot read that key from context if your integration has kept it out. The model’s response is not guaranteed, and authorised tools can still be misused.
Python variant
Show code : ~53 lines, python
# pip install openai deny-sh requests
import os, json, requests
from openai import OpenAI
from deny_sh import vault_get
client = OpenAI()
tools = [{
"type": "function",
"name": "get_invoice",
"description": "Look up a Stripe invoice by id",
"parameters": {
"type": "object",
"properties": {"id": {"type": "string"}},
"required": ["id"],
"additionalProperties": False,
},
}]
resp = client.responses.create(
model="gpt-5.5",
input="Look up invoice in_1Abc123XYZ",
tools=tools,
)
while True:
calls = [o for o in resp.output if o.type == "function_call"]
if not calls:
break # model is done; resp.output_text is the answer
outputs = []
for call in calls:
if call.name == "get_invoice":
args = json.loads(call.arguments)
# resolve the credential INSIDE the tool boundary
stripe_key = vault_get("stripe-prod", os.environ["VAULT_PW"])
r = requests.get(f"https://api.stripe.com/v1/invoices/{args['id']}",
headers={"Authorization": f"Bearer {stripe_key}"})
# narrowed DTO: never feed raw upstream JSON back to the model
if r.ok:
b = r.json()
dto = {"id": b.get("id"), "amount_due": b.get("amount_due"), "status": b.get("status")}
else:
dto = {"error": "invoice_lookup_failed", "status": r.status_code}
outputs.append({
"type": "function_call_output",
"call_id": call.call_id,
"output": json.dumps(dto),
})
resp = client.responses.create(
model="gpt-5.5",
previous_response_id=resp.id,
input=outputs,
tools=tools,
)Multiple customers Responses
The Responses API chains state through previous_response_id rather than a long-lived Assistant or Thread. Per-tenant deniability is therefore per-conversation: each end-user has their own deny.sh tenant API key and vault entries, and the credential resolver only runs against THAT tenant's vault inside the tool boundary.
Cross-tenant deniability is enforced at the deny.sh layer: tenant A's vault_get cannot return tenant B's plaintext even if the labels collide. If you store previous_response_id on your end to resume sessions, scope that storage per tenant too; OpenAI does not enforce tenancy across response chains.
Troubleshooting
Common integration problems. Expand an item for the technical fix.
BadRequestError: Assistants API has been deprecated
OpenAI deprecated the Assistants API on 2025-08-20; shutdown is 2026-08-26. Old tutorials use client.beta.assistants.create(...) + Threads + Runs. The current path is the Responses API (client.responses.create(...)) + Conversations API for state.
Fix: replace client.beta.assistants + client.beta.threads with client.responses.create(model, input, tools, previous_response_id). The function-calling shape is the same; only the wrapper changed.
response has no .tool_calls on first call
The Responses API emits a list of output items, not the old tool_calls array. Each item has a type field; you iterate and dispatch on type == 'function_call'.
Fix: for item in response.output: if item.type == 'function_call': dispatch(item.name, item.arguments). The call_id on the item is what you echo back on the function_call_output input on the next turn.
OPENAI_API_KEY or DENY_API_KEY not loading in serverless
Vercel/Netlify/Lambda functions get env vars at cold-start only. If you've added a new secret since the last deployment, redeploy or trigger a fresh invocation. process.env.OPENAI_API_KEY being undefined in production usually means the secret was added but the function wasn't redeployed.
Fix: confirm via a one-shot diagnostic route that returns typeof process.env.OPENAI_API_KEY. Remove it after confirming. Never log the value itself.
previous_response_id conversations leak across tenants
OpenAI's Responses API stores chain state server-side keyed by your API key, not by tenant. If you reuse one OpenAI key across tenants and pass previous_response_id from a different tenant's session, the model sees the wrong tenant's history.
Fix: scope previous_response_id storage in your database by your tenant id. Optionally use one OpenAI key per high-stakes tenant. The deny.sh layer keeps credentials isolated; OpenAI's conversation state is a separate boundary you have to enforce.
vault_get returned the wrong key after rotation
If you ran vault_store('stripe-prod', new_key) with a different tenant password than the original, the old ciphertext is still under the original password. vault_get returns whichever ciphertext decrypts under the supplied password.
Fix: always rotate vault entries with the same tenant password you originally registered, OR roll the password globally and re-store every label under it in one sweep. There is no "merge passwords" path; that would defeat deniability.
Production checklist
- Register a decoy SHA-256 at /dashboard/decoy-alerts: when a decoy controlData hits
/api/decrypt, deny.sh can alert through Datadog, PagerDuty, or Slack. - Wire your Datadog / PagerDuty / Slack at /dashboard/webhooks.
- Audit chain: /dashboard/audit records vault activity; inspect available trusted timestamp receipts (RFC 3161) for your plan’s retention period.
- The Responses API
instructionsfield is visible to the model; do not paste credentials, passwords, or vault passwords there. - Narrow what you return to the model. The example returns a response containing only approved fields (a data transfer object, or DTO) with only the fields the model needs. Keep that pattern for sensitive APIs; never return raw upstream JSON, auth errors, or response headers.
- For OpenAI compliance (SOC 2, BAA): deny.sh is the data-residency surface for the credentials, not OpenAI. Confirm your DPA covers both.