Integrations › OpenAI Responses API
openai responses api

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.

Note: Assistants API migration

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.

Jump to code ↓

Before you start

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