Gate

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:

  1. Your agent sends POST /approvals with what it wants to do and who should decide.
  2. Gate emails that person an "Approval needed" message with Approve and Reject.
  3. Your agent checks GET /approvals/{id} until the status is no longer pending.
  4. 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:

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:

FieldRequiredRules
titleyes1-200 characters. One line: what the agent wants to do.
descriptionnoUp to 4000 characters. Why, and anything the approver should know.
action_payloadyesA JSON object with the exact action (max 64 KB).
requester_nameyes1-100 characters. Your agent's name.
channelyes"email" (self-serve) or "slack" (coming soon, not self-serve yet; see below).
approver_emailwhen channel is emailFree plan: your sign-up email. Pro: any address.
expires_in_secondsno60 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:

statusMeaningWhat your agent does
pendingNobody has answered yetWait and check again (every 5-30 seconds is plenty)
approvedA person said yesDo exactly the action in action_payload, nothing more
rejectedA person said noDon't act. Tell whoever asked.
expiredNobody 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_payload you 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"}}}
HTTPcodeMeaning
401unauthorizedKey missing, wrong or revoked
402plan_limit_reachedYou used this month's approvals. details.resets_at says when it resets.
403approver_not_allowed_on_free_planFree key, and approver_email isn't your sign-up email
403slack_not_allowed_on_free_planFree key, and channel is "slack"
403email_not_verifiedFree key whose sign-up address isn't confirmed yet, and it already sent its 3 approval emails. Approve one of them to confirm it.
404not_foundNo such approval, or it belongs to another key
422validation_errorA field is missing or wrong; details.fields says which
429rate_limitedMore 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

Pro

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:

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

Every field and error is in the API reference. Questions: hello@gateapprove.com.