temperDocs
Menu

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:

  1. Receive the request and verify Temper-Signature against the raw body.
  2. Store the event, skipping duplicates by event ID, and respond 2xx right away.
  3. For approval.requested, show the summary to the end user.
  4. 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.

TypeSent whendata
approval.requestedAn action needs the end user's approval.approval_id, env_id, connector, category, summary, max_scope, expires_at, decision_url
connection.createdAn end user finished authorizing a connection.connection_id, end_user_id, provider, connectors, account, replaced
connection.revokedA connection was revoked.connection_id, end_user_id, provider
connection.errorThe provider rejected a token refresh; the user needs to authorize again.connection_id, end_user_id, error
browser.takeover_requestedThe 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_exceededThe environment was suspended because it went over a usage limit.reason
environment.agent_schedule_setThe agent in the VM set a schedule.event details, with source: "agent"
environment.agent_schedule_deletedThe agent deleted a schedule.event details, with source: "agent"
environment.agent_keep_awakeThe agent asked to keep the environment awake.event details, with source: "agent"
environment.agent_keep_awake_releasedThe agent released its keep-awake.event details, with source: "agent"
environment.agent_request_rejectedA 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 id is also sent in the Temper-Event-Id header. 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 "", 200
package 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.
  • connector and category: what the action goes through (gmail, slack, or secret:NAME for 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's created).
  • 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: task and session (only for the task or session the request came from), until a time in the future, or permanent. The SDKs have helpers: sessionScope(approval), taskScope(approval), untilScope(date) (Python session_scope, task_scope, until_scope; Go SessionScope, TaskScope, UntilScope, OnceScope, PermanentScope). The event's data doesn't include the session or task ID, so fetch the approval with approvals.get before building a session or task scope.
  • A scope wider than max_scope fails with 403 scope_too_wide; a task or session scope that doesn't match the request, or an until in the past, fails with 400 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:

http
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:

  1. Rotate in the console and copy the new secret.
  2. 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.