---
title: "Idempotency"
description: "Safe-retry semantics for await_human(). What the key actually does, and what it doesn't."
---

`idempotency_key` is the most important parameter most users won't touch. It's how `await_human()` stays safe to retry.

## The model

awaithumans follows Stripe's idempotency model: **same key, same task, always.**

The first call with a given `idempotency_key` creates the task. Every subsequent call with that key — while the task is in flight, after it completes, after it times out, after it's cancelled, after the verifier exhausts — returns the same task. Different result, same row.

This is what you want. It means:

- ✅ Calling `await_human(...)` twice with the same key while a human is reviewing → returns the same task (in-flight dedup)
- ✅ Network blip during task creation → safe to retry without creating a duplicate
- ✅ **Agent process crashes mid-`await_human()` and the human completes during the outage → re-invoking on restart returns the stored response and your `if decision.approved:` block runs**
- ✅ The original timed out → re-invoking returns the timed-out task; your code gets a typed `TaskTimeoutError`, not a phantom new task

If you want "this is a fresh review for the same business event," pass a distinct key — see [Re-triggering a review](#re-triggering-a-review) below.

## What happens under the hood

When you call `await_human(...)`:

1. Server looks up tasks with this key — any status, terminal or not.
2. **Found?** Return the existing task. The SDK long-polls it. If the status is already terminal the long-poll returns immediately with the stored response or the appropriate typed error.
3. **Not found?** INSERT a fresh task.
4. **Race condition** (two concurrent INSERTs with the same new key)? The unique constraint catches it; the loser re-fetches the winner.

The `tasks.idempotency_key` column has a partial unique index gated on non-terminal status, kept from earlier versions for race safety on concurrent insertion. With the application-layer lookup now covering terminal rows, the index never gets exercised on the recovery path — the lookup returns the existing row before any INSERT is attempted.

## Default keys

If you don't pass `idempotency_key=`, the SDK derives one from `(task, payload)`:

```python
hashlib.sha256(canonical_json({"task": task, "payload": payload})).hexdigest()[:32]
```

That covers the common case ("same task description with same payload should dedup"). It does NOT cover the case where you want two distinct tasks with the same content — pass an explicit key for those.

The Temporal and LangGraph adapters use prefixed defaults:

- `temporal:{hash}`  — so operators can filter Temporal-driven tasks at the dashboard
- `langgraph:{hash}` — same idea for LangGraph

## When to pass an explicit key

```python
from pydantic import BaseModel
from awaithumans import await_human_sync


class RefundRequest(BaseModel):
    order_id: str
    amount_usd: int


class RefundDecision(BaseModel):
    approved: bool


order_id = "A-4721"

# RECOMMENDED: bake your application's natural ID into the key
decision = await_human_sync(
    task=f"Approve refund for order {order_id}?",
    payload_schema=RefundRequest,
    payload=RefundRequest(order_id=order_id, amount_usd=250),
    response_schema=RefundDecision,
    timeout_seconds=900,
    idempotency_key=f"refund:{order_id}",
)
```

Why: the `(task, payload)` default is content-addressed, so two refund requests for the same amount + same customer dedup, even if they're a month apart. That's usually wrong.

A natural-key approach (`refund:{order_id}`) makes intent explicit:

- Same `order_id` while task is active → same task (intentional dedup)
- Same `order_id` after task closed → same task (intentional recovery — see [What about retries](#what-about-retries-from-the-agents-side))
- Different `order_id` → different task

## Re-triggering a review

When you genuinely want to start a *fresh* review for the same logical event — for example, a refund timed out yesterday and you want to surface it again today — encode that intent in the key:

```python
attempt = 1
decision = await_human_sync(
    task=f"Approve refund for {order_id}?",
    payload_schema=RefundRequest,
    payload=RefundRequest(order_id=order_id, amount_usd=250),
    response_schema=RefundDecision,
    timeout_seconds=900,
    idempotency_key=f"refund:{order_id}:retry-{attempt}",
)
```

The previous task (under `refund:{order_id}` or `refund:{order_id}:retry-0`) stays in the audit log; the new key creates a new task that the human can act on independently. Same approach Stripe recommends for retried payment intents.

## What about retries from the agent's side?

Two cases. Both work; the second is the one that distinguishes awaithumans from a naive long-poll.

**Crash *before* the human completes.** Agent re-invokes with the same key, the server returns the still-in-flight task, the SDK resumes the long-poll. The human only ever sees one ticket. No duplicate work.

**Crash *during* human review, human completes during the outage, agent restarts.** Agent re-invokes with the same key, the server returns the now-`COMPLETED` task with the stored response, the SDK skips long-polling and returns the typed result immediately. Your `if decision.approved:` block runs as if the agent had been alive the whole time.

This is the entire point. `await_human()` is the durable-but-with-humans equivalent of Temporal's `await activity()` — your retry loop just works.

## What about the verifier?

When the verifier rejects an attempt, the task goes to `REJECTED` (non-terminal). If your agent calls `await_human(...)` again with the same key, it returns the same task — the human can still resubmit and run another verifier attempt.

If the verifier exhausts attempts → `VERIFICATION_EXHAUSTED` (terminal). Subsequent calls with the same key return the same exhausted task and the SDK raises `VerificationExhaustedError`. To retry the whole pipeline with a fresh attempt counter, pass a new key (see [Re-triggering a review](#re-triggering-a-review)).

## Example: a refund pipeline

```python
from dataclasses import dataclass

from pydantic import BaseModel
from awaithumans import await_human_sync


class RefundApprovalPayload(BaseModel):
    order_id: str
    customer_id: str
    amount_usd: int


class RefundApprovalDecision(BaseModel):
    approved: bool
    notes: str | None = None


class RefundConfirmationPayload(BaseModel):
    refund_id: str
    order_id: str


class RefundConfirmation(BaseModel):
    correct: bool


@dataclass
class Order:
    id: str
    amount: int
    customer_id: str


async def process_refund(order: Order):
    # First HITL: approval. If we ever retry this whole function,
    # the same order_id maps to the same task — including the stored
    # decision if the human completed it during a previous outage.
    decision = await_human_sync(
        task=f"Approve ${order.amount} refund for {order.customer_id}?",
        payload_schema=RefundApprovalPayload,
        payload=RefundApprovalPayload(
            order_id=order.id,
            customer_id=order.customer_id,
            amount_usd=order.amount,
        ),
        response_schema=RefundApprovalDecision,
        timeout_seconds=900,
        idempotency_key=f"refund-approval:{order.id}",
    )
    if not decision.approved:
        return {"status": "rejected"}

    # Side effect — your application's own idempotency story.
    refund_id = await stripe.refund(order, idempotency_key=f"stripe-refund:{order.id}")

    # Second HITL: post-refund review. Different key, different task.
    confirmation = await_human_sync(
        task=f"Confirm refund {refund_id} processed correctly?",
        payload_schema=RefundConfirmationPayload,
        payload=RefundConfirmationPayload(
            refund_id=refund_id,
            order_id=order.id,
        ),
        response_schema=RefundConfirmation,
        timeout_seconds=900,
        idempotency_key=f"refund-confirmation:{order.id}",
    )

    return {"status": "completed", "refund_id": refund_id}
```

Both `await_human()` calls are safe to retry from a workflow restart. Stripe's own idempotency key handles the side-effect side. The whole pipeline is recoverable.
