temperDocs
Menu

Concepts

Approvals

Policies that allow, deny or ask before the agent acts for a user, and how approval requests, decisions, grants and signed webhooks fit together.

Some actions should not happen just because the agent decided to: sending an email, posting a message, paying, deleting. Temper checks each action the agent takes with a user's credentials against your policy. The policy allows it, denies it, or pauses it and asks. When it asks, your backend shows the end user what the agent wants to do and sends the decision back to Temper.

The check happens in the credential gateway on the host, the only place the real credential is available. The agent has no way around it and can't fake an approval: decisions only come from your backend, signed or authenticated with your API key.

Action categories

Every request that uses a credential is put into one category:

CategoryExamples
readSearching mail, listing calendar events, any GET with a secret
writeCreating a calendar event, any non-GET request with a secret
sendSending an email, posting to Slack
deleteDeleting an email, an event or a Slack message
payPayments
change_permissionChanging sharing or access rights

send, pay, delete and change_permission are high-risk. A policy can ask or deny for them, but never allow them outright.

Requests that don't use a credential are not acting on the user's behalf, so they are not checked here. They are still limited by the egress allowlist and recorded in the audit log.

Policies

A policy is a list of up to 100 rules. Each rule has:

  • connector (optional): gmail, calendar, slack, or secret:NAME for one of your secrets. Left out, it matches every connector.
  • category (optional): one of the categories above. Left out, it matches every category.
  • effect: allow, deny or ask.
  • max_scope (only on ask): the widest grant the end user can give, from narrowest to widest once, task, session, time_limited, permanent. Left out, it is permanent.

How a request is decided:

  1. If any matching rule says deny, the request is denied.
  2. Otherwise, if any matching rule says ask, Temper asks, using the narrowest max_scope among those rules.
  3. Otherwise, if a rule says allow and the category is not high-risk, the request is allowed.
  4. If no rule matches, the built-in behaviour applies: high-risk categories ask (with max_scope: permanent) and everything else is denied.

Before asking, Temper checks for an existing grant that covers the request. If one does, the request goes through without a new approval.

Developer default and per-environment policies

  • Your default policy (policies.setDefault, Python set_default, Go SetDefault) applies to every environment that doesn't have its own.
  • An environment policy (setForEnvironment) replaces the default policy completely for that environment. It is not merged with it.

Both are replaced as a whole each time you set them. Deleting an environment policy puts the environment back on your default policy, and deleting the default policy goes back to the built-in behaviour. getForEnvironment returns the rules an environment actually uses and their source: environment, developer or default.

await temper.policies.setDefault([
  { category: "read", effect: "allow" },
  { connector: "calendar", category: "write", effect: "ask", max_scope: "session" },
  { connector: "gmail", category: "send", effect: "ask", max_scope: "once" },
  { connector: "secret:OPENROUTER_API_KEY", effect: "allow" },
]);
const effective = await temper.policies.getForEnvironment(env.id);
console.log(effective.source, effective.rules.length);
temper.policies.set_default([
    {"category": "read", "effect": "allow"},
    {"connector": "calendar", "category": "write", "effect": "ask", "max_scope": "session"},
    {"connector": "gmail", "category": "send", "effect": "ask", "max_scope": "once"},
    {"connector": "secret:OPENROUTER_API_KEY", "effect": "allow"},
])
effective = temper.policies.get_for_environment(env.id)
print(effective.source, len(effective.rules))
_, err = client.Policies.SetDefault(ctx, []temper.PolicyRule{
	{Category: "read", Effect: "allow"},
	{Connector: "calendar", Category: "write", Effect: "ask", MaxScope: "session"},
	{Connector: "gmail", Category: "send", Effect: "ask", MaxScope: "once"},
	{Connector: "secret:OPENROUTER_API_KEY", Effect: "allow"},
})
if err != nil {
	log.Fatal(err)
}
effective, err := client.Policies.GetForEnvironment(ctx, env.ID)

Setting a policy returns 400 if a rule allows a high-risk category, puts max_scope on anything other than ask, has an empty connector or one over 100 characters, or if there are more than 100 rules. An unknown field or a misspelled effect returns 422 invalid_body.

Keep ask rules narrow: a matching ask beats any allow, so a bare { category: "write", effect: "ask" } would also catch the agent's calls with secret:OPENROUTER_API_KEY and make every model call wait for approval.

Checking a decision

To find out how a request would be decided without making one, use the policy check. It evaluates the environment's actual policy and grants, and returns the decision (allow, ask or deny), the reason, the indexes of the rules that matched, the grant that would be used and the policy's source. It doesn't create an approval request or use up a grant.

const check = await temper.policies.check(envId, { connector: "secret:OPENROUTER_API_KEY", category: "write" });
console.log(check.decision, check.reason, check.matched_rules); // allow rule [1]
check = temper.policies.check(env_id, connector="secret:OPENROUTER_API_KEY", category="write")
print(check.decision, check.reason, check.matched_rules)  # allow rule [1]
check, err := client.Policies.Check(ctx, envID, temper.PolicyCheckParams{Connector: "secret:OPENROUTER_API_KEY", Category: "write"})

The reason is rule, grant, deny_rule, ask_rule, high_risk (a high-risk category that no rule covers), no_rule (a read or write that no rule covers, so it is denied) or infrastructure_credential (an infrastructure secret, which skips the policy). Pass task_id or session_id to see whether a task- or session-scoped grant would apply.

Approval requests

When the policy says ask, Temper holds the agent's request, creates an approval request, and sends an approval.requested webhook to your backend. The approval request has:

FieldMeaning
idapr_...
environment_id, connector, categoryWhat is asking, and for what kind of action
summaryOne line to show the end user, such as POST gmail.googleapis.com /gmail/v1/users/me/messages/send
session_id, task_idThe agent's session and task, if it sent them (see Task and session scopes)
max_scopeThe widest grant the end user may give
statuspending, approved, denied or expired
scope, decided_via, decided_atSet once decided. decided_via is signature, api_key or console.
expires_atWhen the request expires. By default, ten minutes after it was created.

If nobody decides by expires_at, the request becomes expired and the action is denied. You can also find pending requests without webhooks: approvals.list({ status: "pending" }) (Python approvals.list(status="pending"), Go Approvals.List). If you haven't set a webhook URL, no webhook is sent and requests can only be decided with your API key or expire.

Decisions

A decision is either deny, or approve with a scope:

Scope (in the decision)MeaningStored grant
{"kind": "once"}Only the request that is waiting goes through.None
{"kind": "task", "task_id": "..."}The same kind of action is allowed for the rest of this task.task
{"kind": "session", "session_id": "..."}Allowed for the rest of this agent session.session
{"kind": "until", "until": 1791200000}Allowed until this time (Unix seconds, must be in the future).time_limited
{"kind": "permanent"}Always allowed in this environment, until you revoke the grant.permanent

The scope can't be wider than the request's max_scope. The time-limited scope is written until in a decision and time_limited everywhere else (max_scope, the approval's scope, grants).

import { sessionScope, untilScope } from "@temper-hq/sdk";

const approval = await temper.approvals.get(approvalId);
await temper.approvals.approve(approval.id, { kind: "once" });
// or: sessionScope(approval), untilScope(new Date(Date.now() + 3_600_000)), { kind: "permanent" }
// or: await temper.approvals.deny(approval.id);
from datetime import datetime, timedelta, timezone

from temper_hq import session_scope, until_scope

approval = temper.approvals.get(approval_id)
temper.approvals.approve(approval.id, {"kind": "once"})
# or: session_scope(approval), until_scope(datetime.now(timezone.utc) + timedelta(hours=1)), {"kind": "permanent"}
# or: temper.approvals.deny(approval.id)
approval, err := client.Approvals.Get(ctx, approvalID)
if err != nil {
	log.Fatal(err)
}
_, err = client.Approvals.Approve(ctx, approval.ID, temper.OnceScope())
// or: temper.SessionScope(approval), temper.UntilScope(time.Now().Add(time.Hour)), temper.PermanentScope()
// or: client.Approvals.Deny(ctx, approval.ID)

Errors when deciding:

StatusCodeWhen
400invalid_decisionThe body is not a valid decision.
400missing_scopeapprove without a scope, or a task / session scope with an empty id.
400invalid_scopeThe task_id / session_id doesn't match the request's own, or until is not in the future.
403scope_too_wideThe scope is wider than max_scope.
409already_decidedThe request was already decided, or has expired.
401invalid_signatureA signed callback with a missing or wrong signature.

After a 400 or 403 the request stays pending, so you can fix the decision and send it again.

Task and session scopes

The agent can tag its requests with a task and a session by sending the X-Temper-Task-Id and X-Temper-Session-Id headers. The gateway removes them before forwarding. An approval request carries those ids, and a task or session grant only covers requests with the same id in the same environment. If a request has no task or session id, those scopes can't be used for it. sessionScope(approval) and taskScope(approval) (Python session_scope / task_scope, Go SessionScope / TaskScope) build the scope from the request and fail if it has no such id.

Grants

Every approval except once leaves a grant: the same connector and category in the same environment is allowed within the grant's scope, without asking again. listGrants (Python list_grants, Go ListGrants) returns the grants still in effect, and revokeGrant removes one immediately. Revoking is idempotent.

Who can decide

The decision endpoint (POST /v1/approvals/{id}/decision) accepts any one of:

  • Your backend with an API key. approvals.approve and approvals.deny in the SDKs. Recorded as decided_via: api_key.
  • A signed callback. The approval.requested webhook carries a decision_url. Your backend can POST the decision there with no API key, signed with your webhook secret in the same way Temper signs webhooks. Recorded as decided_via: signature.
  • A member of your team in the console. Recorded as decided_via: console.
http
POST /v1/approvals/apr_0123456789abcdef0123456789abcdef/decision
Content-Type: application/json
Temper-Signature: t=1791200000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{"decision": "approve", "scope": {"kind": "once"}}

The signature for a callback is computed over the exact body you send. The Python SDK's sign_webhook(secret, body, timestamp) and the Go SDK's SignWebhook(secret, body, time) produce the header value.

Webhooks and signatures

Temper sends approval.requested to the webhook URL you set in the console (see Team and keys). The body is an event envelope:

json
{
  "id": "evt_0123456789abcdef0123456789abcdef",
  "type": "approval.requested",
  "created": "2026-11-02T09:30:00Z",
  "environment_id": "env_0123456789abcdef0123456789abcdef",
  "data": {
    "approval_id": "apr_0123456789abcdef0123456789abcdef",
    "env_id": "env_0123456789abcdef0123456789abcdef",
    "connector": "gmail",
    "category": "send",
    "summary": "POST gmail.googleapis.com /gmail/v1/users/me/messages/send",
    "max_scope": "once",
    "expires_at": 1793611800,
    "decision_url": "https://api.temper.im/v1/approvals/apr_0123456789abcdef0123456789abcdef/decision"
  }
}

data.expires_at is in Unix seconds, not RFC 3339. The same envelope and signature are used for every webhook Temper sends, including connection events and environment events.

Delivery

  • A 2xx response counts as delivered. Anything else, including a redirect, is retried with growing delays for up to 24 hours.
  • An event is sent at least once. Retries keep the same id, which also arrives in the Temper-Event-Id header: use it to drop duplicates.
  • Respond quickly and do slow work (like waiting for the user) after you've answered.

Verifying Temper-Signature

Every webhook carries a header like:

http
Temper-Signature: t=1791200000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

v1 is the hex HMAC-SHA256 of <t>.<raw body>, keyed with your webhook secret (whsec_...). To verify:

  1. Use the raw request body bytes. Parsing the JSON and serializing it again changes the bytes and breaks the signature.
  2. Reject the request if t is too far from the current time. The SDKs default to a 300-second tolerance.
  3. Compute the HMAC and compare it with each v1 in constant time. Accept the event if any of them match.

The SDKs do all of this: verifyWebhook(secret, rawBody, header) in TypeScript, verify_webhook(secret, raw_body, header) in Python, and temper.VerifyWebhook(secret, rawBody, header, 0, time.Time{}) in Go. Each returns the parsed event or fails.

import { verifyWebhook } from "@temper-hq/sdk";

const event = await verifyWebhook(secret, rawBody, req.headers["temper-signature"] as string);
if (event.type === "approval.requested") {
  // show event.data.summary to the end user, then approve or deny
}
from temper_hq import verify_webhook

event = verify_webhook(secret, raw_body, headers.get("Temper-Signature"))
if event.type == "approval.requested":
    ...  # show event.data["summary"] to the end user, then approve or deny
event, err := temper.VerifyWebhook(secret, rawBody, r.Header.Get(temper.SignatureHeader), 0, time.Time{})
if err != nil {
	w.WriteHeader(http.StatusBadRequest)
	return
}
if event.Type == "approval.requested" {
	// show the summary to the end user, then approve or deny
}

Rotating the secret

When you rotate the webhook secret, the old one keeps working for 24 hours. During that time every webhook carries two signatures, t=...,v1=<new>,v1=<old>, so a handler that still has the old secret keeps verifying while you deploy the new one. Signed decision callbacks are accepted with either secret during the same window. The SDK verifiers accept several v1 values.

For a complete handler, including showing the request to the user and deciding, see Handle approval webhooks. The approvals API lists every endpoint.