Skip to content

Webhook Human Approval Integration: Resume Work Safely

Build and verify a signed Actionbox approval webhook that resumes a deployment exactly once, with tested Python code and a real end-to-end walkthrough.

Resolved Actionbox production deployment approval with risk, rollback, and release evidence

A webhook is the right fit when a worker must pause for a person without holding an HTTP connection open. The safe pattern is to persist the worker's state, create one durable Action, verify the signed callback, and resume the side effect exactly once.

If your workflow runs in n8n and bounded polling is easier than hosting a callback, use the n8n human-in-the-loop tutorial. This guide focuses on signed webhooks for application workers.

This guide follows a working Python example through that full path. Its signature checks, replay protection, event deduplication, rejection path, and unmatched-event behavior are covered by automated tests. We also ran the example against a local Actionbox API, made a real decision in the Actionbox inbox, received the callback, and reported the outcome.

The verified callback path

Architecture Flow
1 Create Action with callback URL Persist run_id ↔ action_id 3 Present typed decision 4 Approve or reject 5 Signed decision event Verify, deduplicate, correlate 7 Return quickly 8 Re-read authoritative Action 9 Execute once if approved 10 Report immutable outcome Deployment worker Actionbox Reviewer Callback endpoint Deployment API API- Hook- Deployment worker Actionbox Reviewer Callback endpoint Deployment API API- Hook-

There are two important boundaries here:

  • The callback authenticates the event, but Actionbox remains the source of truth for the current Action state.
  • The callback can be delivered more than once, so both the event consumer and the downstream side effect need idempotency.

1. Persist the run before you wait

Create the Action with a stable idempotency key and save the returned Action ID beside your own run ID. This mapping is how a later callback finds the correct suspended workflow.

python
from actionbox import Actionbox, deployment_decision_context

with Actionbox(api_key, base_url=api_url) as client:
    action = client.create(
        title="Approve production deployment?",
        description="Release 2.19.0 passed staging and is ready for production.",
        priority="urgent",
        options=[
            {"id": "approve", "label": "Ship it", "style": "primary"},
            {"id": "reject", "label": "Hold", "style": "destructive"},
        ],
        context=[{
            "type": "key_value",
            "title": "Release evidence",
            "items": {
                "Environment": "production",
                "Commit": "abc123f",
                "Run": run_id,
                "Staging": "passed",
            },
        }],
        decision_class="production_deployment",
        decision_context=deployment_decision_context(
            reason="Release 2.19.0 passed the staging verification suite.",
            proposed_change="Deploy release 2.19.0 to production.",
            risk_level="high",
            reversibility="reversible",
            rollback_plan="Restore release 2.18.0 from the previous image.",
            affected_scope=["production API", "background workers"],
        ),
        callback_url=callback_url,
        idempotency_key=f"{run_id}:approval",
    )

store.register(run_id=run_id, action_id=action.id)

The worker can now exit or release its compute while the row remains in a waiting state.

2. Give the reviewer operational context

The reviewer should not have to leave the decision surface to understand what will happen. Include the proposed change, evidence, affected scope, risk, reversibility, and rollback plan in the Action itself.

Resolved Actionbox approval showing the Webhook Guide deployment context
The real Action created by this example preserves the proposed change, risk, rollback plan, affected scope, and release evidence.

The labels are for people; the stable option IDs (approve and reject) are what your software consumes.

3. Verify the exact bytes before parsing JSON

Actionbox signs timestamp.raw_body with HMAC-SHA256. Verify the raw request body, use constant-time comparison, and reject stale timestamps before you trust or parse the event.

python
import hashlib
import hmac
import json
import time

def verify_actionbox_event(raw_body, *, timestamp, signature, secret):
    if not timestamp or not signature:
        raise ValueError("missing Actionbox signature headers")

    sent_at = int(timestamp)
    if abs(int(time.time()) - sent_at) > 300:
        raise ValueError("stale Actionbox callback")

    expected = "v1=" + hmac.new(
        secret.encode("utf-8"),
        timestamp.encode("ascii") + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(expected, signature):
        raise ValueError("invalid Actionbox signature")

    return json.loads(raw_body)

Read the signature values from X-Actionbox-Timestamp and X-Actionbox-Signature. Keep this verifier in one tested adapter rather than copying cryptographic code into every route.

4. Deduplicate and correlate with action_id

Do not expect your arbitrary Action metadata to be repeated in the webhook envelope. The signed payload supplies data.action_id; look up the corresponding run ID in the mapping you persisted at creation time.

python
MAX_CALLBACK_BODY_BYTES = 256 * 1024

async def read_bounded_body(request: Request) -> bytes:
    body = bytearray()
    async for chunk in request.stream():
        body.extend(chunk)
        if len(body) > MAX_CALLBACK_BODY_BYTES:
            raise HTTPException(status_code=413, detail="Webhook payload is too large")
    return bytes(body)

@app.post("/actionbox/webhook")
async def callback(request: Request, background_tasks: BackgroundTasks):
    raw_body = await read_bounded_body(request)
    event = verify_actionbox_event(
        raw_body,
        timestamp=request.headers.get("X-Actionbox-Timestamp"),
        signature=request.headers.get("X-Actionbox-Signature"),
        secret=webhook_secret,
    )

    result = store.accept(event)  # unique event ID + action_id lookup
    if result.state == "queued" and result.run_id and not result.duplicate:
        background_tasks.add_task(
            resume,
            result.run_id,
            result.action_id,
        )
    return {"ok": True, "duplicate": result.duplicate}

The durable store should enforce a unique constraint on the callback event ID and on the Action-to-run mapping. An unmatched but valid event should be recorded for investigation, not attached to a guessed workflow.

5. Reconcile, execute once, and report the outcome

Before changing production, re-fetch the Action and confirm that it is resolved with the decision you expect. Then invoke an idempotent deployment operation and report the result back to the same Action.

python
def process_approved_run(*, store, run_id, action_id, client, deploy):
    action = client.get(action_id)
    if action.status != "resolved" or action.decision != "approve":
        store.finish(run_id=run_id, succeeded=False)
        return

    try:
        deploy(run_id)  # the real deployment API must also be idempotent
    except Exception:
        action.report_outcome("failed", reason_code="deployment_failed")
        store.finish(run_id=run_id, succeeded=False)
        raise

    action.report_outcome("success")
    store.finish(run_id=run_id, succeeded=True)

Local callback receiver showing the approved run completed
After the signed callback is accepted, the persisted run reaches completed with the approve decision.

The Action history now joins the human decision and the reported machine outcome in one audit trail.

Resolved Actionbox Action with execution success and outcome history
The resolved Action records the Ship it decision, successful execution, and Outcome_reported event.

How this guide was validated

Before publication, we validated this implementation with automated coverage for:

  • a valid HMAC signature over the raw body;
  • rejection of a changed body and a stale timestamp;
  • duplicate delivery of the same event ID;
  • the reject path, which stops rather than queues the run; and
  • a valid event whose Action ID has no local run mapping.

We then completed a real local Actionbox round trip: Action creation, human decision, signed callback delivery, duplicate-safe resume, and outcome reporting. The walkthrough uses a simulated deployment function so it cannot modify real infrastructure. In production, replace that function with an API that accepts its own idempotency key. Use a public HTTPS callback URL; loopback callbacks are only appropriate for an explicitly enabled non-production environment.

For worker restart and reconciliation patterns, continue with durable execution. For the broader control-plane design, read human-in-the-loop AI agent architecture and AI agent audit trails.

Create a free Source · Human approval API · Read the docs

Further reading

About the author

Suson Sapkota

Suson founded ActionBox and works in software and data engineering. He writes about approval workflows, background jobs, and how to verify what happened after a human decision.

Turn the next risky operation into a reviewable decision.

Create a free Source, run the example from this guide, and keep the decision and execution outcome connected.