Gate / Guide
Add human approval to an AI agent in 5 minutes (curl and plain Python)
Your agent can send emails, refund money and delete data. Most of the time that is fine. Sometimes you want a person to say yes first.
This guide adds that "ask first" step with Gate, using two HTTP calls. No SDK, no framework, no dashboard. You need curl, Python 3 and the requests package (pip install requests).
Using a framework? There are copy-paste examples for LangGraph and the OpenAI Agents SDK.
How it works:
- Your agent sends
POST /approvalswith what it wants to do and who should decide. - Gate emails that person an "Approval needed" message with Approve and Reject.
- Your agent checks
GET /approvals/{id}until the status is no longerpending. - It acts only if the status is
approved.
Step 1. Get a free API key (30 seconds)
Use the sign-up form on the home page, or curl:
curl -X POST https://gateapprove.com/signup \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com"}'
Answer (201 Created)
{"api_key": "gate_live_...", "plan": "free", "monthly_limit": 25,
"email_verified": false, "unverified_email_limit": 3,
"note": "Store this key now; it is shown once. Approving your first approval email confirms your sign-up address; until then this key can send 3 approval emails. No email within a minute? Check your junk or spam folder."}
Save the key now. Gate stores only a hash of it, so it can't be shown again. Keep it in an environment variable or your agent's secret settings, not in code:
read -rs GATE_API_KEY && export GATE_API_KEY # paste the key, press Enter (nothing is echoed)
export GATE_APPROVER_EMAIL=you@example.com # the email you signed up with
Things to know about sign-up:
- Only an email is needed. No credit card.
- One key per email. Signing up again with the same email gives
409 email_already_registered. - Use your real address. On the free plan it is the only address your key can email.
- Your address is confirmed on first use: the first time you tap Approve and confirm on one of your approval emails. Until then your key can send 3 approval emails in total (after that,
403 email_not_verified).
Lost your key?
Use Lost your key? on the home page and enter your sign-up email. Gate emails a recovery link valid for 30 minutes. Opening it only shows a confirmation page. Confirm to replace your key: the old key stops working immediately and the new key is shown once. Save it and update your agents.
Your plan, monthly usage, approvals, Stripe subscription and webhook settings stay the same. Confirming also verifies your email address. Operator-created keys without an email cannot use this flow; contact the operator. Never share the recovery link.
Step 2. Ask for approval (curl)
curl -X POST https://gateapprove.com/approvals \
-H "Authorization: Bearer $GATE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Refund $450 to customer #1832",
"description": "Customer reports a double charge on order 5521.",
"action_payload": {"action": "stripe.refund", "charge_id": "ch_123", "amount_cents": 45000, "currency": "usd"},
"requester_name": "support-bot",
"channel": "email",
"approver_email": "you@example.com",
"expires_in_seconds": 3600
}'
Answer (201 Created), shortened
{"id": "apr_x7k2m9q4w8e1r5t3y6u0i2o7", "status": "pending", "channel": "email",
"approver_email": "you@example.com", "expires_at": "2026-10-28T11:15:00Z",
"decided_at": null, "decided_by": null, ...}
Save the id. The fields:
| Field | Required | Rules |
|---|---|---|
title | yes | 1-200 characters. One line: what the agent wants to do. |
description | no | Up to 4000 characters. Why, and anything the approver should know. |
action_payload | yes | A JSON object with the exact action (max 64 KB). |
requester_name | yes | 1-100 characters. Your agent's name. |
channel | yes | "email" (self-serve) or "slack" (coming soon, not self-serve yet; see below). |
approver_email | when channel is email | Free plan: your sign-up email. Pro: any address. |
expires_in_seconds | no | 60 to 604800 (7 days). Default 86400 (24 hours). Must be a whole number. |
Unknown fields are rejected with 422 validation_error, so a typo fails loudly instead of being ignored.
Step 3. Check the answer (curl)
curl https://gateapprove.com/approvals/apr_x7k2m9q4w8e1r5t3y6u0i2o7 \
-H "Authorization: Bearer $GATE_API_KEY"
After the approver taps Approve and confirms
{"id": "apr_x7k2m9q4w8e1r5t3y6u0i2o7", "status": "approved",
"decided_by": "email:you@example.com", "decided_at": "2026-10-28T10:17:42Z", ...}
status is always one of four values:
status | Meaning | What your agent does |
|---|---|---|
pending | Nobody has answered yet | Wait and check again (every 5-30 seconds is plenty) |
approved | A person said yes | Do exactly the action in action_payload, nothing more |
rejected | A person said no | Don't act. Tell whoever asked. |
expired | Nobody answered in time (decided_by is "system") | Don't act. Silence is never a yes. |
A decision is final. It can't flip from approved to rejected later, and if two clicks race, exactly one counts. You can only read approvals created with your own key (anything else is 404 not_found).
Step 4. The same thing in plain Python
Save this as gate.py:
import os
import time
import requests
GATE_URL = os.environ.get("GATE_BASE_URL", "https://gateapprove.com")
HEADERS = {"Authorization": f"Bearer {os.environ['GATE_API_KEY']}"}
class GateError(Exception):
"""Gate refused the request or couldn't be reached. Never act on this."""
def _error(r: requests.Response) -> GateError:
try:
err = r.json()["error"]
return GateError(f"{r.status_code} {err['code']}: {err['message']}")
except (ValueError, KeyError, TypeError):
return GateError(f"HTTP {r.status_code}")
def request_approval(title, action_payload, approver_email, *, description=None,
requester_name="my-agent", expires_in_seconds=3600) -> dict:
body = {
"title": title,
"action_payload": action_payload,
"requester_name": requester_name,
"channel": "email",
"approver_email": approver_email,
"expires_in_seconds": expires_in_seconds,
}
if description:
body["description"] = description
r = requests.post(f"{GATE_URL}/approvals", json=body, headers=HEADERS, timeout=15)
if r.status_code != 201:
raise _error(r)
return r.json()
def wait_for_decision(approval_id: str, poll_seconds: int = 10) -> dict:
"""Polls until the approval is approved, rejected or expired. Gate never returns
"pending" after expires_at, so this loop always ends."""
while True:
r = requests.get(f"{GATE_URL}/approvals/{approval_id}", headers=HEADERS, timeout=15)
if r.status_code == 429: # rate limited (60 requests a minute per key): wait and retry
time.sleep(int(r.headers.get("Retry-After", "5")))
continue
if r.status_code != 200:
raise _error(r)
approval = r.json()
if approval["status"] != "pending":
return approval
time.sleep(poll_seconds)
def ask_first(title, action_payload, approver_email, **kwargs) -> bool:
"""True only if a person approved. Any error, rejection or expiry is False."""
try:
approval = request_approval(title, action_payload, approver_email, **kwargs)
print(f"Waiting for approval {approval['id']} (check {approver_email})")
result = wait_for_decision(approval["id"])
except (GateError, requests.RequestException) as exc:
print(f"Gate error, not acting: {exc}")
return False
print(f"{result['status']} by {result['decided_by']} at {result['decided_at']}")
return result["status"] == "approved"
Use it in front of the risky step:
import os
from gate import ask_first
payload = {"action": "stripe.refund", "charge_id": "ch_123", "amount_cents": 45000, "currency": "usd"}
if ask_first("Refund $450 to customer #1832", payload, os.environ["GATE_APPROVER_EMAIL"],
description="Customer reports a double charge on order 5521.",
requester_name="support-bot"):
print("Approved: doing the refund") # do_refund(**payload)
else:
print("Not approved: skipping the refund")
Run it, check your inbox, tap Approve, confirm, and the script carries on. No email within a minute? Check your junk or spam folder and mark it Not junk. Outlook and Hotmail often file the first one there.
Two habits that keep this safe:
- Do nothing risky while it's pending. Don't "pre-do" the action and undo it on a no.
- Act on what the person saw. Use the
action_payloadyou sent (it comes back unchanged in Gate's answer), not a value your agent changed since.
Errors you may see
Errors always have the same shape. Branch on error.code, never on message:
{"error": {"code": "approver_not_allowed_on_free_plan", "message": "...", "request_id": "req_9f2c1a7e",
"details": {"plan": "free", "upgrade": "POST /billing/checkout"}}}
| HTTP | code | Meaning |
|---|---|---|
| 401 | unauthorized | Key missing, wrong or revoked |
| 402 | plan_limit_reached | You used this month's approvals. details.resets_at says when it resets. |
| 403 | approver_not_allowed_on_free_plan | Free key, and approver_email isn't your sign-up email |
| 403 | slack_not_allowed_on_free_plan | Free key, and channel is "slack" |
| 403 | email_not_verified | Free key whose sign-up address isn't confirmed yet, and it already sent its 3 approval emails. Approve one of them to confirm it. |
| 404 | not_found | No such approval, or it belongs to another key |
| 422 | validation_error | A field is missing or wrong; details.fields says which |
| 429 | rate_limited | More than 60 requests a minute from one key; wait Retry-After seconds |
In every error case: don't act. The 403s and the 402 create nothing, send nothing and don't count against your limit. Quote request_id if you ask for help.
Free and Pro
Free
- 25 approvals per calendar month (UTC). Checking an approval (
GET) never counts toward it. - Email approvals to your own sign-up address only. That covers the most common case: you approve your own agent's actions from your inbox.
- Until you approve your first approval email, 3 approval emails in total.
- No Slack, and no webhook set-up yourself. Polling
GET /approvals/{id}always works.
Pro
- US$29 a month, flat, for up to 1,000 approvals a month. No overage charges: at the limit, new requests get
402until next month. - Approval emails to any address, so someone else can decide.
- Slack approvals are coming soon (not self-serve yet). Tell us if you want it.
Upgrade with the Upgrade to Pro button under Pricing, or call POST /billing/checkout with your key and open the url it returns. Payments are handled by Stripe. Prices are in US dollars. Taxes such as GST are added at checkout where they apply.
Webhooks instead of polling (optional)
Gate can also POST a signed webhook to your server when an approval is approved or rejected (not on expiry). The request carries X-Gate-Event: approval.decided and an X-Gate-Signature: t=...,v1=... header that you verify with your webhook secret on the raw body. Delivery is at least once, so dedupe on approval_id. Setting a webhook URL on a key isn't self-serve yet, so start with polling.
LangGraph
LangGraph can pause a graph with interrupt(), save it with a checkpointer, and continue it later with Command(resume=...) on the same thread_id. Gate fits in that pause: one node creates the approval, the next node waits for the person, and the risky node runs only if Gate says approved.
Install with pip install langgraph httpx, set GATE_API_KEY and GATE_APPROVER_EMAIL as in Step 1, and save this as langgraph_gate.py:
import os
import time
from typing import TypedDict
import httpx
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
GATE = "https://gateapprove.com"
HEADERS = {"Authorization": f"Bearer {os.environ['GATE_API_KEY']}"}
class State(TypedDict, total=False):
refund: dict
approval_id: str
status: str
def ask_gate(state: State) -> dict:
# Its own node, so it runs once: LangGraph re-runs a node from the top when it resumes.
r = httpx.post(f"{GATE}/approvals", headers=HEADERS, timeout=15, json={
"title": f"Refund {state['refund']['amount_cents'] / 100:.2f} USD on {state['refund']['charge_id']}",
"action_payload": state["refund"],
"requester_name": "langgraph-agent",
"channel": "email",
"approver_email": os.environ["GATE_APPROVER_EMAIL"],
"expires_in_seconds": 3600,
})
r.raise_for_status() # any error stops the graph: nothing is refunded
return {"approval_id": r.json()["id"]}
def gate_status(approval_id: str) -> str:
r = httpx.get(f"{GATE}/approvals/{approval_id}", headers=HEADERS, timeout=15)
r.raise_for_status()
return r.json()["status"] # pending, approved, rejected or expired
def wait_for_human(state: State) -> dict:
interrupt({"gate_approval_id": state["approval_id"]}) # pause here; the checkpointer saves the state
return {"status": gate_status(state["approval_id"])} # resumed: Gate decides, not the resume value
def refund(state: State) -> dict:
if state["status"] == "approved": # rejected or expired: do nothing
print("Refunding", state["refund"]) # your real refund call, with exactly state["refund"]
return {}
builder = StateGraph(State)
builder.add_sequence([ask_gate, wait_for_human, refund]) # node names = function names
builder.add_edge(START, "ask_gate")
builder.add_edge("refund", END)
graph = builder.compile(checkpointer=InMemorySaver()) # use a durable checkpointer in production
config = {"configurable": {"thread_id": "refund-ch_123"}}
result = graph.invoke({"refund": {"action": "stripe.refund", "charge_id": "ch_123",
"amount_cents": 45000, "currency": "usd"}}, config)
approval_id = result["__interrupt__"][0].value["gate_approval_id"]
# Polling (works on every plan): wait until the approval isn't pending, then resume the same thread.
while gate_status(approval_id) == "pending":
time.sleep(10)
print(graph.invoke(Command(resume=True), config)["status"])
How it works:
- Create the approval in its own node. When a graph resumes, LangGraph runs the interrupted node again from the top. If
POST /approvalssat in the same node asinterrupt(), every resume would create a new approval and send a new email. - Gate decides, not the resume value. After it resumes,
wait_for_humanreadsGET /approvals/{id}itself, so whatever value resumes the graph can't approve anything by mistake. - Use a durable checkpointer.
InMemorySaveris lost when the process stops. For approvals that may take hours, use a database-backed checkpointer (langgraph-checkpoint-sqliteorlanggraph-checkpoint-postgres) and keep thethread_id.
With a webhook instead of polling (see above), save the thread_id for each approval_id. In your webhook handler, after you verify X-Gate-Signature, resume that thread:
graph.invoke(Command(resume=event["status"]), {"configurable": {"thread_id": thread_for[event["approval_id"]]}})
Expiry sends no webhook, so after expires_at check GET /approvals/{id} once and resume the thread to close it. Webhooks can arrive more than once, so resume each approval_id only once.
OpenAI Agents SDK
With the OpenAI Agents SDK (pip install openai-agents httpx), put Gate inside the tool itself. The model can call refund_charge whenever it likes, but the refund only happens after a person approves it in Gate. The agent run waits while the tool polls.
import asyncio
import os
import httpx
from agents import Agent, Runner, function_tool
GATE = "https://gateapprove.com"
HEADERS = {"Authorization": f"Bearer {os.environ['GATE_API_KEY']}"}
async def gate_approves(title: str, action_payload: dict, description: str = "",
expires_in_seconds: int = 3600) -> bool:
"""True only if a person approved. Rejected, expired or any error is False."""
body = {"title": title[:200], "action_payload": action_payload, "requester_name": "openai-agent",
"channel": "email", "approver_email": os.environ["GATE_APPROVER_EMAIL"],
"expires_in_seconds": expires_in_seconds}
if description:
body["description"] = description[:4000]
try:
async with httpx.AsyncClient(base_url=GATE, headers=HEADERS, timeout=15) as http:
r = await http.post("/approvals", json=body)
if r.status_code != 201:
return False
approval_id = r.json()["id"]
while True: # ends: Gate never returns "pending" after expires_at
await asyncio.sleep(10)
r = await http.get(f"/approvals/{approval_id}")
if r.status_code == 429: # rate limited: wait Retry-After, then check again
await asyncio.sleep(int(r.headers.get("Retry-After", "5")))
continue
if r.status_code != 200:
return False
if r.json()["status"] != "pending":
return r.json()["status"] == "approved"
except httpx.HTTPError:
return False
@function_tool(timeout=3660) # hard stop a minute after the Gate approval expires
async def refund_charge(charge_id: str, amount_cents: int, reason: str) -> str:
"""Refund a card charge. A person approves every refund in Gate first."""
payload = {"action": "stripe.refund", "charge_id": charge_id, "amount_cents": amount_cents, "currency": "usd"}
if not await gate_approves(f"Refund {amount_cents / 100:.2f} USD on {charge_id}", payload, reason):
return "Not approved, so no refund was made. Tell the customer a person declined it."
# Your real refund call goes here, with exactly `payload`.
return f"Refunded {amount_cents} cents on {charge_id}."
agent = Agent(name="Support agent", instructions="Help customers. Use refund_charge for refunds.",
tools=[refund_charge])
result = Runner.run_sync(agent, "I was charged twice for order 5521 (charge ch_123, $450). Please refund one.")
print(result.final_output)
The tool returns a plain sentence either way, so the model can tell the customer what happened. Any Gate error, rejection or expiry means no refund. timeout=3660 is the SDK's own per-call limit for async tools: if it fires, the tool stops and nothing is refunded.
Using the SDK's needs_approval with Gate
The SDK also has its own human-in-the-loop step. A tool declared with @function_tool(needs_approval=True) doesn't run straight away. The run pauses, and the pending calls appear in result.interruptions. You then approve or reject each one on result.to_state() and run again. Gate can make that decision by email:
from agents import Runner
# A tool declared with @function_tool(needs_approval=True) pauses the run before it executes.
# Gate makes the decision (gate_approves and agent are from the example above).
async def run_with_gate(prompt: str) -> str:
result = await Runner.run(agent, prompt)
while result.interruptions:
state = result.to_state()
for item in result.interruptions:
if await gate_approves(f"Run {item.name}", {"tool": item.name, "arguments": item.arguments}):
state.approve(item)
else:
state.reject(item)
result = await Runner.run(agent, state)
return result.final_output
This waits in one process. For approvals that take hours, save the state with state.to_string(), keep the Gate id, and later load it with await RunState.from_string(agent, saved) once Gate has an answer. Keep that saved state on your server, never in the browser.
See your usage and approvals (dashboard)
Open the dashboard and paste your key. It shows your plan, how many approvals you used this month and when the count resets, and your recent approvals with who decided and when. Free keys get an Upgrade to Pro button; Pro keys get Manage billing. The dashboard only reads: it can't approve, change or delete anything, and looking never counts toward your limit. Your key stays in that browser tab until you close it or press Forget key.
The same data is in the API: GET /account (plan, approvals_used, approvals_limit, usage_resets_at) and GET /approvals?limit=20 (newest first; pass next_before back as ?before= for the next page).
No-code version
If your agent follows instructions and can make HTTP requests (Claude Code, Cursor, a Grok bot, a custom GPT action, an n8n HTTP step), you don't need the Python at all. After you sign up on the home page, the page gives you a "Gate approval rule" block with your key and email filled in. Paste it into your agent's instructions or rules file.
Recap
POST /approvalswithchannel: "email"and your sign-up email asapprover_email. Save theid.GET /approvals/{id}untilstatusisn'tpending.- Act only on
approved. Rejected, expired or any error means don't act.
Every field and error is in the API reference. Questions: hello@gateapprove.com.