ActionBoxBlog

BackendAutomationPlatformDevOps

Webhook Human Approval Integration: Resume Work Safely

A developer tutorial for connecting a human approval webhook to a backend, verifying the event, and resuming work exactly once.

Ethernet cables connected to network hardware

A webhook is useful when the worker should not hold an HTTP connection open while a person decides. The safe pattern is still the same: create one durable approval, persist the caller's state, verify the signed callback, and resume the work exactly once after the typed decision arrives.

The callback path

Architecture Flow
1 Create Action with callback URL Persist run ID and Action ID 3 Notify approval request 4 Approve or reject 5 Signed decision event 6 Verify and enqueue resume 7 Execute once if approved 8 Report outcome Backend worker Actionbox Reviewer Callback endpoint Side effect API- Backend worker Actionbox Reviewer Callback endpoint Side effect API-

The callback should be fast and idempotent. Acknowledge the event after signature verification and enqueue the actual work; do not perform a slow deployment or refund inside the webhook request itself.

Create the Action with a callback

The CLI is convenient for a scheduled job or a small integration:

bash
actionbox ask "Approve the production deploy?" \
  --option approve="Ship it" \
  --option reject="Hold" \
  --callback-url "https://ops.example.com/actionbox/deploy" \
  --context-json '[{"type":"key_value","items":{"environment":"production","commit":"abc123f","run_id":"deploy-4821"}}]'

For a backend service, use the SDK and record the Action ID before returning to the queue:

python
import os
from actionbox import Actionbox

box = Actionbox(os.environ["ACTIONBOX_API_KEY"])
action = box.create(
    title="Approve production deploy?",
    options=[
        {"id": "approve", "label": "Ship it", "style": "primary"},
        {"id": "reject", "label": "Hold", "style": "destructive"},
    ],
    callback_url="https://ops.example.com/actionbox/deploy",
    idempotency_key="deploy:4821:approval",
)
save_pending_run(run_id="deploy-4821", action_id=action.id)

The callback URL is a delivery mechanism, not the source of truth. If delivery fails, the worker should be able to retrieve the Action by ID and reconcile the pending run.

Verify before you enqueue

Use the webhook secret and the official verification procedure for your Actionbox client or deployment. Verify the signature against the raw request bytes before parsing JSON, reject old or replayed events, and store the event ID with a unique constraint.

python
from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()

@app.post("/actionbox/deploy")
async def actionbox_callback(
    request: Request,
    actionbox_signature: str | None = Header(default=None),
):
    raw = await request.body()
    if not actionbox_signature:
        raise HTTPException(status_code=400, detail="missing signature")

    # Call the Actionbox SDK/verifier here with the raw bytes, secret, and
    # signature header. Do not parse or trust the body before verification.
    event = verify_actionbox_event(raw, actionbox_signature)
    if not claim_event_once(event["id"]):
        return {"ok": True, "duplicate": True}

    enqueue_resume(
        run_id=event["data"]["metadata"]["run_id"],
        action_id=event["data"]["action_id"],
        decision=event["data"].get("decision"),
    )
    return {"ok": True}

The verifier name and header vary by client implementation, so keep that code in one tested adapter instead of copying cryptographic logic into every route. Never accept an unsigned browser request as a decision.

Resume exactly once

The queue consumer should use the Action ID and run ID as a deduplication key. Re-read the Action, check that it is the expected version and decision, and atomically mark the pending run as resumed before starting the side effect.

python
def resume_pending(run_id: str, action_id: str) -> None:
    pending = claim_pending_run_once(run_id, action_id)
    if pending is None:
        return  # another delivery already resumed it

    action = box.get(action_id)
    if action.decision != "approve":
        mark_stopped(run_id, "rejected or expired")
        return

    try:
        deploy(pending.payload)
    except Exception:
        action.report_outcome("failed", reason_code="deploy_error")
        raise
    else:
        action.report_outcome("success")

A callback can be delivered more than once, arrive after a worker restart, or race with a manual reconciliation. Idempotency belongs both at the event-consumer boundary and at the side-effecting operation.

For long-running framework state, combine this pattern with durable execution and the human-in-the-loop AI agent architecture. For the decision and audit contract, see AI agent audit trails and the Actionbox docs.

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

Sources and further reading

Try this workflow in minutes

Create a free Source, then run the exact commands from this post against the live API — no approval infrastructure to build.

S
Suson Sapkota

Founder, Actionbox