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:
| Category | Examples |
|---|---|
read | Searching mail, listing calendar events, any GET with a secret |
write | Creating a calendar event, any non-GET request with a secret |
send | Sending an email, posting to Slack |
delete | Deleting an email, an event or a Slack message |
pay | Payments |
change_permission | Changing 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, orsecret:NAMEfor 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,denyorask.max_scope(only onask): the widest grant the end user can give, from narrowest to widestonce,task,session,time_limited,permanent. Left out, it ispermanent.
How a request is decided:
- If any matching rule says
deny, the request is denied. - Otherwise, if any matching rule says
ask, Temper asks, using the narrowestmax_scopeamong those rules. - Otherwise, if a rule says
allowand the category is not high-risk, the request is allowed. - 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, Pythonset_default, GoSetDefault) 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:
| Field | Meaning |
|---|---|
id | apr_... |
environment_id, connector, category | What is asking, and for what kind of action |
summary | One line to show the end user, such as POST gmail.googleapis.com /gmail/v1/users/me/messages/send |
session_id, task_id | The agent's session and task, if it sent them (see Task and session scopes) |
max_scope | The widest grant the end user may give |
status | pending, approved, denied or expired |
scope, decided_via, decided_at | Set once decided. decided_via is signature, api_key or console. |
expires_at | When 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) | Meaning | Stored 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:
| Status | Code | When |
|---|---|---|
400 | invalid_decision | The body is not a valid decision. |
400 | missing_scope | approve without a scope, or a task / session scope with an empty id. |
400 | invalid_scope | The task_id / session_id doesn't match the request's own, or until is not in the future. |
403 | scope_too_wide | The scope is wider than max_scope. |
409 | already_decided | The request was already decided, or has expired. |
401 | invalid_signature | A 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.approveandapprovals.denyin the SDKs. Recorded asdecided_via: api_key. - A signed callback. The
approval.requestedwebhook carries adecision_url. Your backend canPOSTthe decision there with no API key, signed with your webhook secret in the same way Temper signs webhooks. Recorded asdecided_via: signature. - A member of your team in the console. Recorded as
decided_via: console.
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:
{
"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
2xxresponse 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 theTemper-Event-Idheader: 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:
Temper-Signature: t=1791200000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdv1 is the hex HMAC-SHA256 of <t>.<raw body>, keyed with your webhook secret (whsec_...). To verify:
- Use the raw request body bytes. Parsing the JSON and serializing it again changes the bytes and breaks the signature.
- Reject the request if
tis too far from the current time. The SDKs default to a 300-second tolerance. - Compute the HMAC and compare it with each
v1in 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 denyevent, 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.