> ## Documentation Index
> Fetch the complete documentation index at: https://jephalabs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Callback Verification Contract

> HMAC v3 signature verification requirements for callback receivers

# Callback Verification Contract

Defines the **receiver implementation requirements** for callbacks that `qtg` sends to external systems.

This document is written so a receiver can be implemented in a language-agnostic way.
For the Python reference implementation, see `examples/callback_receiver_fastapi.py`.

***

## 1. Envelope Format

### HTTP Headers

| Header                             | Type             | Description                                                                            |
| ---------------------------------- | ---------------- | -------------------------------------------------------------------------------------- |
| `x-qtg-callback-signature`         | string           | HMAC-SHA256 hex digest                                                                 |
| `x-qtg-callback-timestamp`         | string (integer) | Unix timestamp at send time (seconds). Refreshed on retry                              |
| `x-qtg-callback-nonce`             | string           | 16-char hex random token. The same value is reused across retries of the same callback |
| `x-qtg-callback-signature-version` | string           | Currently fixed to `"v3"`                                                              |

The Content-Type is always `application/json`.

### Payload Fields

```jsonc theme={null}
{
  "movement_id": "uuid",           // movement request ID
  "request_state": "string",       // current request state (e.g., "approved", "executing", "completed")
  "compiled_plan_hash": "string",  // SHA-256 hash of the compiled plan
  "current_frontier": ["uuid"],    // list of currently active node IDs
  "event_type": "string",          // event kind (e.g., "request_approved", "request_completed", "frontier_advanced")
  "detail": {},                    // additional information per event_type
  "callback_id": "uuid",           // callback outbox entry ID (dedup key)
  "event_sequence": 1,             // event sequence number within the movement (starts at 1)
  "timestamp": 1711104000,         // Unix timestamp integer when the event was created (immutable, unchanged on retry)
  "nonce": "string"                // payload-level nonce (same as the header nonce)
}
```

> **Backward compatibility**: the receiver must ignore unknown JSON keys.
> New fields may be added to the payload in the future. Do not raise an error for unknown keys.

***

## 1-A. `detail` Fields by Event Type

The structure of the `detail` object varies by `event_type`.
The receiver must ignore unknown keys, and new event types may be added in the future.

### `request_approved`

Sent when the movement is approved. The `detail` field can distinguish the approval path.

| Field           | Type                   | Condition   | Description                        |
| --------------- | ---------------------- | ----------- | ---------------------------------- |
| `approval_mode` | `"auto"` \| `"manual"` | always      | Approval mode                      |
| `policy_id`     | string (UUID)          | auto only   | Matched auto-approve policy ID     |
| `strategy_id`   | string                 | auto only   | Strategy identifier                |
| `approver_id`   | string                 | manual only | Operator ID that approved manually |

**Auto-approve example**:

```jsonc theme={null}
{
  "event_type": "request_approved",
  "detail": {
    "approval_mode": "auto",
    "policy_id": "550e8400-e29b-41d4-a716-446655440000",
    "strategy_id": "strategy-cex-dex-arb"
  }
}
```

**Manual approve example**:

```jsonc theme={null}
{
  "event_type": "request_approved",
  "detail": {
    "approval_mode": "manual",
    "approver_id": "admin-user"
  }
}
```

### `frontier_advanced`

Sent when node execution progresses. `detail` can include information about the advanced node.

### `request_completed` / `request_failed`

Sent when the movement reaches a terminal state.

### `recovery_alarm`

Sent when CCIP `recover()` remains in an ambiguity state (`rpc_error`, `pending_only`, `implausible_nonce_pair`) for a long time without terminal evidence.

| Field                   | Type                                                            | Condition | Description                              |
| ----------------------- | --------------------------------------------------------------- | --------- | ---------------------------------------- |
| `executor`              | string                                                          | always    | Currently `"ccip_send"`                  |
| `ambiguity_reason`      | `"rpc_error"` \| `"pending_only"` \| `"implausible_nonce_pair"` | always    | Why automatic tombstoning was blocked    |
| `execution_intent_id`   | string (UUID)                                                   | always    | CCIP send intent ID                      |
| `age_seconds`           | integer                                                         | always    | Current elapsed time on the intent clock |
| `max_age_seconds`       | integer                                                         | always    | Alarm rate-limit/window threshold        |
| `attempts`              | integer                                                         | always    | Recover attempt counter                  |
| `max_attempts`          | integer                                                         | always    | Configured orphan log-scan max attempts  |
| `source_chain_selector` | string                                                          | always    | Source chain selector                    |
| `sender`                | string                                                          | always    | Sender address                           |
| `nonce`                 | integer                                                         | always    | CCIP send nonce                          |

Even if the receiver does not handle this event type yet, it can ignore it as an unknown event.
The sender does not treat that as a failure.

***

## 2. Signature Scheme (v3)

### 2.1 Body Canonicalization

The HTTP body is serialized with the following rule:

```python theme={null}
json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
```

* keys sorted alphabetically (`sort_keys=True`)
* compact separators: no space after comma, no space after colon
* non-ASCII characters are preserved as-is (`ensure_ascii=False`)
* UTF-8 encoding

### 2.2 Canonical String

```text theme={null}
{timestamp}\n{nonce}\n{sha256(body_bytes)}
```

Each element:

| Element              | Source                            | Description                                      |
| -------------------- | --------------------------------- | ------------------------------------------------ |
| `timestamp`          | `x-qtg-callback-timestamp` header | Integer string (e.g., `"1711123200"`)            |
| `nonce`              | `x-qtg-callback-nonce` header     | Hex string (e.g., `"a1b2c3d4e5f60718"`)          |
| `sha256(body_bytes)` | Computed                          | SHA-256 hex digest of the body bytes (lowercase) |

`\n` is a literal newline (`0x0a`).

### 2.3 Signature Creation

```text theme={null}
HMAC-SHA256(callback_hmac_secret, canonical_string)
```

* key: UTF-8 encoded shared secret
* message: UTF-8 encoded canonical string
* result: lowercase hex digest

### 2.4 Signature Verification Pseudocode

```
received_signature = headers["x-qtg-callback-signature"]
timestamp          = headers["x-qtg-callback-timestamp"]
nonce              = headers["x-qtg-callback-nonce"]
version            = headers["x-qtg-callback-signature-version"]

if version != "v3":
    reject("unsupported version")

body_hash  = sha256_hex(raw_body_bytes)
canonical  = f"{timestamp}\n{nonce}\n{body_hash}"
expected   = hmac_sha256_hex(shared_secret, canonical)

if not constant_time_compare(expected, received_signature):
    reject("signature mismatch")
```

> **Important**: signature comparison must use constant-time comparison.
> This is to prevent timing side-channel attacks.
> Python: `hmac.compare_digest()`, Go: `crypto/subtle.ConstantTimeCompare()`,
> Node.js: `crypto.timingSafeEqual()`.

***

## 3. Receiver Implementation Checklist

### 3.1 Signature Verification (Required)

1. Confirm that `x-qtg-callback-signature-version` is `"v3"`
2. Build the canonical string: `{timestamp}\n{nonce}\n{sha256(body)}`
3. Compute HMAC-SHA256 and compare the signature with **constant-time comparison**
4. Return `401` on mismatch

### 3.2 Timestamp Freshness (Required)

1. Parse `x-qtg-callback-timestamp` from the header
2. Reject if `abs(current_time - timestamp) > max_age_seconds`
3. Default `max_age_seconds = 300` (5 minutes)

> Because the header timestamp is refreshed on retry, an old callback does not fail the freshness check.

### 3.3 Nonce Duplicate Rejection (Required)

1. Attempt to record the received `x-qtg-callback-nonce` in persistent storage
2. If it already exists, treat it as a replay -> reject
3. Recommended store implementations:
   * **single instance**: PostgreSQL + `UNIQUE` constraint
   * **multiple instances**: Redis `SET NX EX` or PostgreSQL `INSERT ... ON CONFLICT`
4. Recommended TTL: `max_age_seconds * 2` or more (e.g., 600 seconds)

> Retries of the same callback use the same nonce.
> A nonce that is already recorded in the nonce store is automatically rejected on retry.
> This is intentional — the sender retries only when it did not receive a `200 ACK`, so
> if the receiver already finished processing, duplicate processing must be prevented.

### 3.4 `callback_id` Dedup (Recommended)

`callback_id` in the payload is the unique ID of the callback outbox entry.
Separately from nonce-based dedup, it can be used as an idempotency key at the business-logic level.

```python theme={null}
if already_processed(payload["callback_id"]):
    return {"ok": True}  # already processed, only return ACK
```

### 3.5 `event_sequence` Tracking (Recommended)

`event_sequence` is an integer that represents the event order within a single movement.
If events are received out of order, use it for warning logs or buffering when ordering is required.

```python theme={null}
last_seq = get_last_sequence(movement_id)
if event_sequence <= last_seq:
    log.warning("out-of-order or duplicate event", seq=event_sequence, last=last_seq)
```

> Callbacks may arrive out of order depending on network conditions.
> `event_sequence` is a hint for receiver-side ordering, not a strict ordering guarantee.

***

## 4. Error Response Recommendations

| HTTP Status | Meaning                     | Sender behavior                  |
| ----------- | --------------------------- | -------------------------------- |
| `200`       | ACK — received successfully | Mark complete (`status -> sent`) |
| `4xx`       | Permanent reject            | Move to DLQ, no retry            |
| `5xx`       | Transient error             | Retry (backoff)                  |

* The `200` response body format is free, but `{"ok": true}` is recommended.
* Use `401` for signature verification failure.
* `429` can be used for rate limiting, and the sender retries it the same way as `5xx`.

***

## 5. Retry Behavior

| Item             | Value                                                 |
| ---------------- | ----------------------------------------------------- |
| Max attempts     | `max_attempts` (default 5)                            |
| Backoff strategy | exponential: `2^min(attempt, 6)` seconds              |
| Nonce            | Same across all attempts of the same callback         |
| Payload          | Same across all attempts of the same callback         |
| Header timestamp | Refreshed at each send attempt                        |
| DLQ transition   | After exceeding `max_attempts` or on a `4xx` response |

> **From the receiver's point of view**: retries use the same nonce, so if the nonce is already recorded in the nonce store,
> it is rejected as a replay. This prevents duplicate processing in the case of "already processed successfully, but the ACK was lost".

***

## 6. Dual Timestamp Structure

Callbacks contain two timestamps:

| Location     | Field                      | Purpose                                                             | On retry  |
| ------------ | -------------------------- | ------------------------------------------------------------------- | --------- |
| HTTP header  | `x-qtg-callback-timestamp` | **Send time** — used for signature verification and freshness check | Refreshed |
| JSON payload | `timestamp`                | **Event creation time** — for business logic                        | Unchanged |

* Use the header timestamp for **signature verification**.
* Use the payload timestamp for **recording the event time**.
* Only the header timestamp is refreshed on retry, so it can pass the freshness check.
* The payload timestamp preserves the original event occurrence time exactly.

***

## 7. CCIP `request_attention` Detail Extension

The CCIP lane keeps the existing callback envelope / `event_type` contract. The receiver must not
expect a new top-level schema, and all fields below must be handled forward-compatibly only inside
the existing `detail` JSON.

Based on the current code, the CCIP callback emitter reuses the generic `request_attention` path. Therefore,
the receiver should be implemented with the contract that the keys below **may be added**, and should not
treat their absence as an error.

### `attention_reason`

CCIP manual-intervention/attention events can use the reason values below.

* `ccip_execute_failure_manual_exec_required`
* `ccip_lane_cursed`
* `ccip_router_drift_detected`
* `ccip_router_snapshot_stale`
* `ccip_stalled_no_execution`
* `ccip_submit_orphaned`

### `requires_manual_recovery`

* Type: `boolean`
* Meaning: whether the operator must run a separate manual recovery procedure

### `ccip_recovery_context`

* Type: object
* Purpose: helper information so the receiver can identify the CCIP recovery target in an operator UI / incident log
* Expected keys:
  * `execution_intent_id`
  * `message_id`
  * `source_chain_selector`
  * `dest_chain_selector`
  * `router`
  * `receiver`
  * `token`
  * `amount`
  * `send_tx_hash`
  * `last_observed_status`
  * `sdk_message_status`

### `recovery_hint`

* Type: string
* Meaning: recommended operator command or runbook hint
* Example:
  `uv run python -m qtg.interfaces.tools.ccip_manual_execute --message-id 0x... --source-selector ...`

Receiver implementation rules:

* Do not parse `detail` with a strict schema; ignore unknown keys
* Even if `attention_reason` has a CCIP value, reuse the existing `request_attention` handling pipeline as-is
* `ccip_recovery_context` / `recovery_hint` may be absent, so treat them as optional

***

## 8. Python Reference Implementation

Using `verify_callback()` from the `qtg.callback_auth` module lets you handle
signature verification, timestamp freshness, and nonce dedup in one call.

```python theme={null}
from qtg.callback_auth import verify_callback

ok, reason = verify_callback(
    headers=headers,          # dict[str, str], lowercase keys
    body_bytes=raw_body,      # bytes, raw HTTP body as-is
    secret=shared_secret,     # str, HMAC shared secret
    max_age_seconds=300,      # int, allowed timestamp freshness window
    nonce_recorder=record_fn, # Callable[[str], bool], True=new / False=duplicate
)
```

### `nonce_recorder` Interface

```python theme={null}
def record_nonce(nonce: str) -> bool:
    """
    Attempt to record the nonce in storage.
    Returns:
        True  — new nonce, recorded successfully
        False — nonce already exists (replay)
    """
```

Implementation example (PostgreSQL):

```python theme={null}
from sqlalchemy.exc import IntegrityError

def record_nonce(nonce: str) -> bool:
    try:
        conn.execute(
            "INSERT INTO callback_nonces (nonce, created_at) VALUES (:nonce, :created_at)",
            {"nonce": nonce, "created_at": datetime.now(UTC).isoformat()},
        )
        conn.commit()
        return True
    except IntegrityError:
        return False  # already exists -> replay
```

Implementation example (Redis):

```python theme={null}
def record_nonce(nonce: str) -> bool:
    return redis.set(f"cb:nonce:{nonce}", "1", nx=True, ex=600)
```

### Full Receiver Handler Example

For the full runnable example, see `examples/callback_receiver_fastapi.py`.

```python theme={null}
@app.post("/qtg/callback")
async def qtg_callback(request: Request):
    body = await request.body()
    headers = {k.lower(): v for k, v in request.headers.items()}

    ok, reason = verify_callback(
        headers=headers,
        body_bytes=body,
        secret=CALLBACK_SECRET,
        max_age_seconds=300,
        nonce_recorder=record_nonce,
    )
    if not ok:
        raise HTTPException(status_code=401, detail=f"invalid: {reason}")

    payload = await request.json()

    # Business-level dedup based on callback_id (recommended)
    callback_id = payload.get("callback_id")
    if callback_id and already_processed(callback_id):
        return {"ok": True}

    # Process the event
    process_event(payload)
    return {"ok": True}
```

***

## 8. Other Language Implementation Guide

### Go

```go theme={null}
func verifyCallback(secret string, headers http.Header, body []byte) error {
    timestamp := headers.Get("X-Qtg-Callback-Timestamp")
    nonce := headers.Get("X-Qtg-Callback-Nonce")
    version := headers.Get("X-Qtg-Callback-Signature-Version")
    signature := headers.Get("X-Qtg-Callback-Signature")

    if version != "v3" {
        return errors.New("unsupported version")
    }

    bodyHash := sha256.Sum256(body)
    canonical := fmt.Sprintf("%s\n%s\n%x", timestamp, nonce, bodyHash)

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(canonical))
    expected := hex.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(expected), []byte(strings.ToLower(signature))) {
        return errors.New("signature mismatch")
    }
    return nil
}
```

### Node.js

```javascript theme={null}
function verifyCallback(secret, headers, bodyBuffer) {
  const timestamp = headers['x-qtg-callback-timestamp'];
  const nonce = headers['x-qtg-callback-nonce'];
  const version = headers['x-qtg-callback-signature-version'];
  const signature = headers['x-qtg-callback-signature'];

  if (version !== 'v3') throw new Error('unsupported version');

  const bodyHash = crypto.createHash('sha256').update(bodyBuffer).digest('hex');
  const canonical = `${timestamp}\n${nonce}\n${bodyHash}`;
  const expected = crypto.createHmac('sha256', secret).update(canonical).digest('hex');

  const expectedBuf = Buffer.from(expected);
  const signatureBuf = Buffer.from(signature.toLowerCase());
  if (!crypto.timingSafeEqual(expectedBuf, signatureBuf)) {
    throw new Error('signature mismatch');
  }
}
```

***

## Appendix: Checklist Summary

| # | Item                                   | Level       | Description                   |
| - | -------------------------------------- | ----------- | ----------------------------- |
| 1 | Signature verification (constant-time) | Required    | HMAC-SHA256, `compare_digest` |
| 2 | Timestamp freshness                    | Required    | `max_age_seconds=300`         |
| 3 | Nonce persistent store + dedup         | Required    | Replay defense                |
| 4 | `callback_id` dedup                    | Recommended | Business-level idempotency    |
| 5 | `event_sequence` tracking              | Recommended | Order tracking                |
| 6 | Ignore unknown JSON keys               | Required    | Forward compatibility         |
| 7 | Follow 200/4xx/5xx response rules      | Recommended | Retry/DLQ control             |
