Guides
Handle approval webhooks
Build a webhook endpoint that verifies Temper's signature, deduplicates retries, shows approval requests to the end user, and sends back their decision.
When the agent tries an action that your policy says to ask about, such as sending an email, Temper pauses
the action and sends an approval.requested event to your webhook endpoint. Your backend shows the request to the end user, and when
they decide, you approve or deny it through the API. Temper sends other events to the same endpoint: connection changes, browser
takeover requests, and environment events.
This guide builds a complete handler:
- Receive the request and verify
Temper-Signatureagainst the raw body. - Store the event, skipping duplicates by event ID, and respond
2xxright away. - For
approval.requested, show the summary to the end user. - When the user decides, call approve or deny.
Event types
Every event has the same envelope: id, type, created (RFC 3339), environment_id, and data. New types may be added later, so
ignore types you don't know.
| Type | Sent when | data |
|---|---|---|
approval.requested | An action needs the end user's approval. | approval_id, env_id, connector, category, summary, max_scope, expires_at, decision_url |
connection.created | An end user finished authorizing a connection. | connection_id, end_user_id, provider, connectors, account, replaced |
connection.revoked | A connection was revoked. | connection_id, end_user_id, provider |
connection.error | The provider rejected a token refresh; the user needs to authorize again. | connection_id, end_user_id, error |
browser.takeover_requested | The agent asks the end user to take over the browser (for example, to sign in). | lease_id, end_user_id, profile_id, reason |
environment.limit_exceeded | The environment was suspended because it went over a usage limit. | reason |
environment.agent_schedule_set | The agent in the VM set a schedule. | event details, with source: "agent" |
environment.agent_schedule_deleted | The agent deleted a schedule. | event details, with source: "agent" |
environment.agent_keep_awake | The agent asked to keep the environment awake. | event details, with source: "agent" |
environment.agent_keep_awake_released | The agent released its keep-awake. | event details, with source: "agent" |
environment.agent_request_rejected | A request from the agent was rejected. | event details, with source: "agent" |
environment_id is an empty string for connection.created and connection.revoked. For connection.error it is the environment
whose request triggered the refresh.
The payload schemas are in the API reference: approvals, connections, browser, environments.
Set your webhook URL
Set the URL in the console. Only an owner can change it, and only after signing in recently. The URL must be https:// with a
certificate from a public CA (http:// is accepted only for local testing). Temper does not follow redirects, and a 3xx response
counts as a failed delivery, so enter the final URL.
When you first set the URL, change it, or rotate the secret, the console shows a new signing secret (whsec_...) once. Store it on
your server, for example as TEMPER_WEBHOOK_SECRET. An API key can read the current settings
(get webhook) but cannot change them.
If no webhook URL is set, nothing is sent. Approval requests then wait until you decide them with your API key or they expire.
How delivery works
- At least once. An event is recorded in the same transaction as the change that caused it, so if the change happened, the event will be sent.
- 2xx means delivered. Any other response, a timeout, or a redirect counts as a failure. Failed deliveries are retried with exponential backoff, starting at 1 minute and growing to at most 1 hour between attempts. After 24 hours without success, Temper gives up on that event.
- Same ID on every retry. The event
idis also sent in theTemper-Event-Idheader. Use it to drop duplicates. - Don't rely on order. Retries can arrive after newer events.
Each request carries Temper-Signature: t=<unix seconds>,v1=<hex>, where the hex value is HMAC-SHA256 of <t>.<raw body> keyed with
your webhook secret. The SDK helpers check the signature in constant time and reject timestamps more than 5 minutes from your
server's clock, which stops replayed requests.
The webhook handler
The handler does as little as possible: verify, store, respond. Anything slow, such as sending a push notification, happens after the response, so Temper isn't left waiting.
The examples call a few functions from your own app (db, processLater) for storage and background work.
import { createServer } from "node:http";
import { verifyWebhook, type WebhookEvent } from "@temper-hq/sdk";
import { db, processLater } from "./app.js"; // your storage and job queue
const secret = process.env.TEMPER_WEBHOOK_SECRET!;
createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/webhooks/temper") {
res.writeHead(404).end();
return;
}
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const raw = Buffer.concat(chunks);
let event: WebhookEvent;
try {
event = await verifyWebhook(secret, raw, req.headers["temper-signature"] as string | undefined);
} catch {
// TemperError with code webhook_signature_missing / _malformed / _expired / _mismatch
res.writeHead(400).end();
return;
}
// Store the event under a unique key on event.id. Returns false if it was already stored (a retry).
const isNew = await db.saveEvent(event);
res.writeHead(200).end();
if (isNew) processLater(event.id);
}).listen(8787);import os
from flask import Flask, request
from temper_hq import WebhookVerificationError, verify_webhook
from app import db, process_later # your storage and job queue
secret = os.environ["TEMPER_WEBHOOK_SECRET"]
app = Flask(__name__)
@app.post("/webhooks/temper")
def temper_webhook():
try:
# request.get_data() is the raw body.
event = verify_webhook(secret, request.get_data(), request.headers.get("Temper-Signature"))
except WebhookVerificationError as e:
# e.reason is "missing", "malformed", "expired" or "mismatch"
return {"error": e.reason}, 400
# Store the event under a unique key on event.id. Returns False if it was already stored (a retry).
if db.save_event(event):
process_later(event.id)
return "", 200package main
import (
"io"
"log"
"net/http"
"os"
"time"
temper "github.com/temper-hq/sdk-go"
"example.com/app" // your storage and job queue
)
func main() {
secret := os.Getenv("TEMPER_WEBHOOK_SECRET")
http.HandleFunc("/webhooks/temper", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// 0 and time.Time{} mean the default tolerance and the current time.
event, err := temper.VerifyWebhook(secret, raw, r.Header.Get(temper.SignatureHeader), 0, time.Time{})
if err != nil {
// *temper.Error with Code temper.CodeWebhookSignature
w.WriteHeader(http.StatusBadRequest)
return
}
// Store the event under a unique key on event.ID. Returns false if it was already stored (a retry).
isNew, err := app.SaveEvent(r.Context(), event)
if err != nil {
w.WriteHeader(http.StatusInternalServerError) // Temper will retry
return
}
w.WriteHeader(http.StatusOK)
if isNew {
app.ProcessLater(event.ID)
}
})
log.Fatal(http.ListenAndServe(":8787", nil))
}Keep deduplication in a durable store, such as a table with a unique constraint on the event ID. An in-memory set forgets everything on restart, and retries can arrive up to a day later. If storing fails, respond with an error so Temper retries.
Show the request to the end user
When your background job picks up an approval.requested event, the useful fields in data are:
approval_id: the ID you approve or deny.summary: one sentence describing the action, written for the end user. Show it as is.connectorandcategory: what the action goes through (gmail,slack, orsecret:NAMEfor one of your secrets) and its category (read,write,send,pay,delete,change_permission).max_scope: the widest approval the user may give, from narrowest to widest:once,task,session,time_limited,permanent. Only offer choices up to this.expires_at: when the request expires, in unix seconds (not RFC 3339 like the envelope'screated).env_id: the environment, which tells you which end user to ask.
Look up the end user for env_id, store the pending request, and reach them where they are: a push notification, an in-app banner,
an email. Show the summary faithfully and let the end user decide. Don't approve high-risk actions automatically in your backend.
import { db, notifyEndUser } from "./app.js";
export async function processEvent(eventId: string) {
const event = await db.getEvent(eventId);
if (event.type !== "approval.requested") return; // handle other types here
const a = event.data as {
approval_id: string; env_id: string; connector: string; category: string;
summary: string; max_scope: string; expires_at: number;
};
const user = await db.endUserForEnvironment(a.env_id);
await db.savePendingApproval(user, a);
await notifyEndUser(user, {
approvalId: a.approval_id,
text: a.summary,
maxScope: a.max_scope,
expiresAt: new Date(a.expires_at * 1000),
});
}from datetime import datetime, timezone
from app import db, notify_end_user
def process_event(event_id: str) -> None:
event = db.get_event(event_id)
if event.type != "approval.requested":
return # handle other types here
a = event.data
user = db.end_user_for_environment(a["env_id"])
db.save_pending_approval(user, a)
notify_end_user(
user,
approval_id=a["approval_id"],
text=a["summary"],
max_scope=a["max_scope"],
expires_at=datetime.fromtimestamp(a["expires_at"], tz=timezone.utc),
)func processEvent(ctx context.Context, eventID string) error {
event, err := app.GetEvent(ctx, eventID)
if err != nil {
return err
}
if event.Type != "approval.requested" {
return nil // handle other types here
}
var a temper.ApprovalRequested
if err := json.Unmarshal(event.Data, &a); err != nil {
return err
}
user, err := app.EndUserForEnvironment(ctx, a.EnvID)
if err != nil {
return err
}
if err := app.SavePendingApproval(ctx, user, a); err != nil {
return err
}
return app.NotifyEndUser(ctx, user, a.ApprovalID, a.Summary, a.MaxScope, time.Unix(a.ExpiresAt, 0))
}Send the decision
When the end user chooses, call approve or deny with your API key (decide an approval).
- Approve once (
{ kind: "once" }) lets only the waiting action through and leaves nothing behind. - Wider scopes leave a grant, so later actions of the same connector and category pass without asking while the grant applies:
taskandsession(only for the task or session the request came from),untila time in the future, orpermanent. The SDKs have helpers:sessionScope(approval),taskScope(approval),untilScope(date)(Pythonsession_scope,task_scope,until_scope; GoSessionScope,TaskScope,UntilScope,OnceScope,PermanentScope). The event'sdatadoesn't include the session or task ID, so fetch the approval withapprovals.getbefore building a session or task scope. - A scope wider than
max_scopefails with403 scope_too_wide; a task or session scope that doesn't match the request, or anuntilin the past, fails with400 invalid_scope. In both cases the request stays pending and you can try again. - If the request was already decided (for example, by a teammate in the console) or has expired, you get
409 already_decided. Treat that as done, not as an error.
import { Temper, isTemperError, sessionScope, type GrantScopeRequest } from "@temper-hq/sdk";
const temper = new Temper();
// Called from your app when the end user taps a button.
export async function decide(approvalId: string, choice: "once" | "session" | "deny") {
try {
if (choice === "deny") {
await temper.approvals.deny(approvalId);
return;
}
let scope: GrantScopeRequest = { kind: "once" };
if (choice === "session") scope = sessionScope(await temper.approvals.get(approvalId));
const result = await temper.approvals.approve(approvalId, scope);
console.log(result.status, result.scope);
} catch (err) {
if (isTemperError(err, "already_decided")) return; // decided elsewhere, or expired
throw err;
}
}from temper_hq import Temper, TemperError, session_scope
temper = Temper()
# Called from your app when the end user taps a button.
def decide(approval_id: str, choice: str) -> None:
try:
if choice == "deny":
temper.approvals.deny(approval_id)
return
scope = {"kind": "once"}
if choice == "session":
scope = session_scope(temper.approvals.get(approval_id))
result = temper.approvals.approve(approval_id, scope)
print(result.status, result.scope)
except TemperError as e:
if e.code == "already_decided":
return # decided elsewhere, or expired
raise// Called from your app when the end user taps a button.
func decide(ctx context.Context, client *temper.Client, approvalID, choice string) error {
var err error
switch choice {
case "deny":
_, err = client.Approvals.Deny(ctx, approvalID)
case "session":
var a *temper.Approval
if a, err = client.Approvals.Get(ctx, approvalID); err != nil {
return err
}
var scope temper.GrantScope
if scope, err = temper.SessionScope(a); err != nil {
return err
}
_, err = client.Approvals.Approve(ctx, approvalID, scope)
default:
_, err = client.Approvals.Approve(ctx, approvalID, temper.OnceScope())
}
if temper.IsCode(err, temper.CodeAlreadyDecided) {
return nil // decided elsewhere, or expired
}
return err
}Grants can be listed and revoked with approvals.listGrants and approvals.revokeGrant (Python list_grants, revoke_grant; Go
ListGrants, RevokeGrant). Give end users a way to see and withdraw what they approved permanently.
Deciding without an API key
Each event also carries a decision_url. Instead of using your API key, you can POST the decision there, signed with your webhook
secret in the same format as incoming webhooks and without an Authorization header:
POST /v1/approvals/apr_.../decision
Content-Type: application/json
Temper-Signature: t=1767225600,v1=<HMAC-SHA256 of "<t>.<body>" with your whsec_ secret>
{"decision": "approve", "scope": {"kind": "once"}}A bad signature returns 401 invalid_signature. The Python and Go SDKs can build the header (sign_webhook, SignWebhook).
Expiry
An approval request that isn't decided by expires_at counts as denied (by default this is 10 minutes after the request). The
agent's action is refused, and there is no separate event for it. Reading the request afterwards shows status expired, and trying
to decide it returns 409 already_decided.
Clear expired requests from your UI using expires_at. To reconcile after an outage, list what is still waiting with
approvals.list({ status: "pending" }) (Python approvals.list(status="pending"), Go Approvals.List with Status: "pending").
Rotate the signing secret
Rotate the secret in the console's webhook settings. The new secret is shown once. The old one stays valid for 24 hours, and during
that time every webhook carries two signatures, t=…,v1=<new>,v1=<old>. The SDK helpers accept a request if any v1 matches, so:
- Rotate in the console and copy the new secret.
- Deploy it to your webhook handler within 24 hours. Until you do, the old secret still verifies.
Get webhook shows previous_secret_expires_at while the old secret is still valid. Rotate right away if you think the
secret leaked. Anyone with the secret can forge webhooks to you and sign decisions for your approval requests.
Test your handler
Use the signing helpers to build valid requests in your tests: sign_webhook(secret, payload, timestamp) in Python and
SignWebhook(secret, payload, t) in Go. Check that your handler rejects a wrong signature, a stale timestamp, and a body changed
after signing, and that it processes a repeated event ID only once.