Approvals & Webhooks
When a policy resolves to approval_required, OpenProdkt does not execute the request and returns a
governed approval decision. SDK integrations may wait or poll for resolution; proxy mode never
waits for approval and blocks the original request immediately.
SDK and proxy modes
| Mode | While approval is pending | After approval |
|---|---|---|
| SDK | The SDK may wait for a bounded time, or return approval_id for app-managed polling or webhook handling. | Your app retries with the same operation_id, approval_id, and payload_hash. |
| Proxy | The proxy immediately returns HTTP 403 or gRPC PERMISSION_DENIED; the protected upstream is not called. A CX/orchestrator polls or receives the approval webhook. | The app retries the original request with the same stable operation_id; the client does not need to send approval_id. |
Proxy mode is transparent to business logic. If the request contains a stable operation_id,
OpenProdkt can automatically correlate the retry and consume the approval in a single successful
redemption. This is an approval state transition only. approval_id is optional for the proxy client.
Identical metadata alone cannot distinguish a retry from a separate operation.
A single redemption does not guarantee exactly-once execution by your upstream. If the
authorization response is lost after redemption, OpenProdkt will not redeem that approval again,
and the upstream outcome may be unknown. Make the business operation idempotent using the same
operation_id and reconcile ambiguous outcomes in your application.
Proxy challenge
When a proxied request needs approval, /v1/proxy/evaluate returns HTTP 403 immediately, with
Retry-After: 30 and Cache-Control: no-store. The protected upstream is not called. gRPC callers
receive PERMISSION_DENIED. The body contains:
| Field | Value |
|---|---|
code | approval_required |
retry_after_seconds | 30 |
operation_id | Echoed when it matches ^[A-Za-z0-9._:-]{1,128}$; omitted otherwise |
approval_expires_at | When the pending approval expires |
{
"code": "approval_required",
"retry_after_seconds": 30,
"operation_id": "refund-2026-001",
"approval_expires_at": "2026-09-13T11:15:00Z"
}
The challenge never includes the approval ID or the payload hash. The CX or orchestrator tracks the
approval through polling or webhooks. The application retries the original request with the same
operation_id after approval.
How it works
The SDK flow does not silently replay your business request. Your application owns the retry unless it opts into a bounded SDK wait.
For details on plans and how licensing works, see Licensing & Distribution.
Choose a delivery model
| Option | How it works | Best for |
|---|---|---|
| Async + Webhook (recommended) | SDK apps or the CX/orchestrator receive an approvalId; OpenProdkt sends lifecycle events to a registered webhook endpoint | Backend jobs, agents |
| App-managed retry (SDK) | Your app stores approvalId, a human approves in the Console, your app retries the original operation with the explicit tuple | Web/mobile apps |
| Short wait (SDK) | The SDK blocks for a bounded time polling for resolution, then returns the result or a timeout | Interactive apps |
| Polling (SDK/CX) | The SDK app or CX/orchestrator checks status via GET /v1/approvals/{id} | Integrations without webhooks |
| Transparent proxy | The proxy immediately blocks pending requests; after approval, the app retries the original request with the same stable operation_id | Existing applications behind the proxy |
SDK integrations may wait or poll. Proxy integrations never wait for approval: they block immediately and rely on the CX/orchestrator to observe resolution before the request is retried.
Async + Webhook
For SDK flows, your application receives the approval ID in the challenge. For proxy flows, the
CX/orchestrator owns the approval handle; the protected application does not need it.
Register a webhook endpoint from the Console (Settings → Webhooks) or via
POST /v1/webhooks, with a URL and the event types you want: approval.pending,
approval.approved, approval.rejected, approval.expired, approval.revoked. Each delivery includes a stable
eventId in the body and X-OpenProdkt-Event-Id header, plus an
X-OpenProdkt-Timestamp header. Verify X-OpenProdkt-Signature using the endpoint secret shown
once at creation. During secret rotation, configure the receiver to accept both the previous and
new secret until in-flight deliveries have drained. The versioned format is v1=sha256=<hex HMAC-SHA256> over the timestamp,
an ASCII period, and the exact raw request body. Enforce a replay window on the timestamp and
durably deduplicate on event ID before handling the event. The bundled SDKs accept timestamps within 5 minutes (Go) or 300 seconds (Python and TypeScript).
App-managed retry
This explicit mode is for SDK integrations whose application manages the approval workflow.
Store the operation_id, approvalId, payload_hash, and the exact asset identity and
authorization context returned by the initial approval_required decision. When a reviewer
resolves it in the Console (or your webhook fires), retry /v1/evaluate with the same
operation_id, asset, transport, and metadata, plus the original approval_id and
payload_hash:
{
"version": "v1",
"asset": { "id": "ast_e8f1a2c3" },
"operation_id": "refund-2026-001",
"approval_id": "apr_5c6d7e8f",
"payload_hash": "sha256:<hash returned by the initial decision>",
"transport": { "kind": "http" },
"metadata": { "operation": "refund", "amount": 9000 }
}
OpenProdkt recomputes the canonical payload hash and checks the organization, authenticated
principal, operation, asset, transport, metadata, and policy revision binding. A changed
context is rejected rather than treated as a new authorization. A pending approval remains
pending; the first valid approved retry atomically redeems it and returns allow. Later attempts
report the consumed status and do not authorize again. Rejected, expired, revoked, unknown, or
mismatched approvals do not authorize the operation. The application must still make the business
operation idempotent using the same operation_id to handle an ambiguous upstream result.
Transparent proxy retry
Proxy mode is transparent to business logic. If the request contains a stable operation_id,
OpenProdkt can automatically correlate the retry and consume the approval in a single successful
redemption. This is an approval state transition only, not upstream execution. approval_id is
optional for the proxy client. The CX/orchestrator polls the
approval or receives its webhook; the protected application does not wait and does not need to
know the approval ID. After approval, it resends the original request through the proxy with the
same operation_id and authorization context. The proxy immediately blocks while approval is
pending and never holds the original request open.
A single redemption is not exactly-once upstream execution. If the authorization response is
lost after redemption, the approval cannot be redeemed again and the upstream outcome may be
unknown. Make the business operation idempotent with the same operation_id and reconcile
ambiguous outcomes. Without a stable operation_id, a matching request fingerprint alone is not
enough to redeem an approval.
Short wait (SDK)
SDKs expose an opt-in bounded wait — see SDK Manual — that polls internally and returns either the resolved decision or a timeout. This never replaces async delivery; it's for interactive UX where a user is actively waiting.
Polling (SDK/CX)
GET /v1/approvals/{id} returns the current status of a single approval. SDKs expose
get_approval_status() (Python), GetApprovalStatus() (Go), and getApprovalStatus()
(TypeScript) for this scoped lookup; polling reads one approval and does not list or reevaluate
requests. API keys must include the approvals:read scope. A decision-only key receives a
403 with code=insufficient_scope; the SDK reports this as a typed scope error rather than a
PDP outage. Async delivery remains the recommended default.
An authorized reviewer may revoke a pending or approved approval with
POST /v1/approvals/{id}/revoke. Revocation records revokedAt, clears any unconsumed grant,
and emits approval.revoked; later explicit retries remain denied.
Approval scope
An approval is never a permanent permission — it's scoped to a single request:
- Asset — what the request was about
- Policy — which rule triggered the review
- Request/context — the metadata submitted with the decision
- Requester — who or what made the call
- Expiration — pending approvals expire 24 hours after creation and move to
expired. An approved approval is redeemable within a 2-minute grant window (grant_expires_at); a redemption after that window is refused - Status —
pending,approved,rejected,expired,revoked, orconsumedafter one successful redemption
{
"id": "apr_5c6d7e8f",
"decisionId": "dec_9f2a1c4e",
"assetId": "invoice-agent",
"policyId": "pol_8a1b2c3d",
"requesterId": "key_4f5e6d7c",
"status": "pending",
"expiresAt": "2026-09-13T11:15:00Z"
}
See API Reference for the full approvals and webhooks endpoint list, and SDK Manual for language-specific helpers.