# App-Backend Callback Contract

Source: https://docs.generalaugment.com/guides/app-backend-callbacks/
Description: Verify signed General Augment Project callbacks from an authenticated app backend.

This page mirrors the canonical repository contract in
[`docs/public/APP-BACKEND-CALLBACKS.md`](https://github.com/LunarVentures/general-augment-platform/blob/main/docs/public/APP-BACKEND-CALLBACKS.md).
The protocol is `genaug-app-tools/v1`. Keep the signing credential and Project runtime key only
on the app backend.

## Verification callback

Expose the configured verification path (the default is `/.well-known/genaug/verify`). General
Augment sends one `POST` request with `Content-Type: application/json` and these headers:

| Header | Required value |
| --- | --- |
| `X-General-Augment-Signature` | `v1=<lowercase 64-character HMAC-SHA256 hex>` |
| `X-General-Augment-Timestamp` | Unix timestamp in seconds |

The signed bytes are the ASCII timestamp, one period, and the exact raw request body:

```text
<X-General-Augment-Timestamp>.<raw request body bytes>
```

The HMAC key is the app-backend signing credential. Reject a missing, malformed, or mismatched
signature and any timestamp more than 60 seconds from the app server clock. Verify raw bytes before
parsing JSON; parsing and re-serializing changes the signed bytes.

Canonical JSON uses recursively lexicographically sorted object keys, compact separators (no
whitespace), JSON booleans/null/numbers, and UTF-8 encoded bytes. Do not add a newline, normalize
Unicode, or change escaping after signing. Non-finite numbers are invalid protocol JSON.

The signed verification request contains:

```text
protocol, challenge, project_id, connection_id, operation_id, effect_key,
target, runtime_key_hash_algorithm
```

`protocol` is exactly `genaug-app-tools/v1`; `challenge` is non-empty and must be echoed; the
Project must match the configured binding; `runtime_key_hash_algorithm` is `sha256`.

For a valid, fresh request, return HTTP `200`, `Content-Type: application/json`, and exactly:

```json
{
  "accepted": true,
  "challenge": "<the exact request challenge>",
  "project_id": "<the configured Project ID>",
  "runtime_key_sha256": "<lowercase SHA-256 hex of the UTF-8 runtime key>"
}
```

Sign the exact response bytes with the app server's current Unix timestamp and the same
`<timestamp>.<raw body>` rule. Return `X-General-Augment-Signature: v1=<lowercase hex>` and
`X-General-Augment-Timestamp: <the response timestamp>`. The response timestamp may differ from the
request timestamp but must be within the same 60-second tolerance at General Augment. General
Augment rejects a receipt with a different challenge, Project, or runtime-key digest.

## Replay and errors

Atomically claim a replay key made from Project, challenge, and timestamp before returning a
receipt. The app-side replay store must retain the claim until at least processing time plus
`2 * 60` seconds (120 seconds), not merely request timestamp plus 60 seconds, so distributed clock
skew cannot reopen a valid request. Duplicate challenges must not run app work or receive a second
accepted receipt. The reconciliation operation is also retry-keyed by `effect_key`.

Reject non-`POST` requests before reading their body. The maximum request body is 64,000 bytes;
reject an oversized declared `Content-Length` before reading and enforce the same cumulative limit
while streaming a body without a trusted length. Return unsigned `400` for invalid method/body,
UTF-8, protocol, Project, challenge, timestamp, or signature. Return unsigned `409` for a replay.
Never put signing credentials, runtime keys, or raw authorization data in errors or logs. General
Augment reports any invalid or unsigned receipt as `app_backend_callback_response_invalid`.

## Direct application-tool callbacks

Verified direct capabilities are sent over `POST`, including capabilities whose declared semantic
method is `GET`. The signed request fields are:

```text
protocol, workspace_id, project_id, release_id, release_generation, target,
authority_fingerprint, agent_id, release_agent_id, run_id, session_id,
application_user_id, capability_id, capability_fingerprint, capability_name,
capability_method, capability_risk, callback_transport, connection_resource_id,
connection_fingerprint, connection_binding_id, credential_binding_id,
credential_binding_version, release_fingerprint, idempotency_key, input
```

Verify the same signature/freshness fields, exact Project/release/Agent/user authority,
`capability_method`, `capability_risk`, and `idempotency_key` before dispatching to the existing
app authorization and domain service. Atomically deduplicate `idempotency_key` before writes and
return the stored result on retry. The response echoes every request field except `input` and adds:

```json
{
  "success": true,
  "output": {}
}
```

`output` must satisfy the release-pinned schema. Never treat Agent tool selection as business
authorization or forward callback secrets to a browser/mobile client.

## Edge-compatible TypeScript helper

`@general-augment/sdk` exports `verifyAppBackendCallback`. It uses Web Crypto (`crypto.subtle`),
`Request`, and `Response`; it has no Node-only crypto dependency and runs in Workers-compatible
server runtimes. `claimReplay` is required: the helper rejects invalid configuration rather than
accepting a signed callback without an app-supplied atomic replay store. It rejects non-`POST`
requests and enforces the 64,000-byte body limit before signature verification.

```ts
import {
  AppBackendCallbackError,
  verifyAppBackendCallback,
} from "@general-augment/sdk";

export async function POST(request: Request, env: Env): Promise<Response> {
  try {
    return await verifyAppBackendCallback(request, {
      expectedProjectId: env.GENAUG_PROJECT_ID,
      runtimeKey: env.GENAUG_RUNTIME_KEY,
      signingSecret: env.GENAUG_APP_BACKEND_SIGNING_SECRET,
      claimReplay: (key, expiresAt) => env.REPLAY_STORE.claim(key, expiresAt),
    });
  } catch (error) {
    const status =
      error instanceof AppBackendCallbackError && error.code === "replayed_request" ? 409 : 400;
    return new Response("Invalid General Augment callback", { status });
  }
}
```

The Python SDK has no equivalent helper in this release. The following standalone Python
standard-library example is a complete framework-neutral implementation. Pass it a binary request
stream and the request headers from your framework; `claim_replay` must be an atomic durable
operation shared by all app instances. Python parity is tracked as a later SDK slice.

```python
from __future__ import annotations

import hashlib
import hmac
import json
import time
from collections.abc import Callable, Mapping
from typing import Any

PROTOCOL = "genaug-app-tools/v1"
SIGNATURE_HEADER = "X-General-Augment-Signature"
TIMESTAMP_HEADER = "X-General-Augment-Timestamp"
TIMESTAMP_TOLERANCE_SECONDS = 60
MAX_BODY_BYTES = 64_000
REPLAY_RETENTION_SECONDS = 2 * TIMESTAMP_TOLERANCE_SECONDS

class CallbackError(ValueError):
    def __init__(self, code: str, message: str) -> None:
        super().__init__(message)
        self.code = code

def header(headers: Mapping[str, str], name: str) -> str:
    return next((value for key, value in headers.items() if key.lower() == name.lower()), "")

def read_limited_body(stream: Any, content_length: str | None) -> bytes:
    """Read a binary request stream without buffering more than 64,000 bytes."""
    if content_length is not None:
        if not content_length.isdecimal():
            raise CallbackError("invalid_body", "Content-Length is invalid.")
        if int(content_length) > MAX_BODY_BYTES:
            raise CallbackError("body_too_large", "The callback body is too large.")

    chunks: list[bytes] = []
    total = 0
    while True:
        chunk = stream.read(min(8192, MAX_BODY_BYTES + 1 - total))
        if not chunk:
            return b"".join(chunks)
        total += len(chunk)
        if total > MAX_BODY_BYTES:
            raise CallbackError("body_too_large", "The callback body is too large.")
        chunks.append(chunk)

def verify_callback(
    *,
    method: str,
    headers: Mapping[str, str],
    raw_body: bytes,
    expected_project_id: str,
    runtime_key: str,
    signing_secret: str,
    claim_replay: Callable[[str, int], bool],
    now: int | None = None,
) -> tuple[int, dict[str, str], bytes]:
    """Return (status, headers, body) for the verified callback receipt."""
    if method != "POST":
        raise CallbackError("invalid_method", "The callback endpoint accepts POST only.")
    if not expected_project_id or not runtime_key or not signing_secret or not callable(claim_replay):
        raise CallbackError("invalid_configuration", "Callback configuration is incomplete.")
    if len(raw_body) > MAX_BODY_BYTES:
        raise CallbackError("body_too_large", "The callback body is too large.")

    current_time = int(time.time()) if now is None else now
    try:
        timestamp = int(header(headers, TIMESTAMP_HEADER))
    except ValueError as exc:
        raise CallbackError("invalid_timestamp", "The callback timestamp is invalid.") from exc
    if abs(current_time - timestamp) > TIMESTAMP_TOLERANCE_SECONDS:
        raise CallbackError("stale_request", "The callback timestamp is stale.")

    supplied = header(headers, SIGNATURE_HEADER)
    expected = hmac.new(
        signing_secret.encode("utf-8"),
        f"{timestamp}.".encode("ascii") + raw_body,
        hashlib.sha256,
    ).hexdigest()
    if not supplied.startswith("v1=") or not hmac.compare_digest(supplied[3:], expected):
        raise CallbackError("invalid_signature", "The callback signature is invalid.")

    try:
        payload = json.loads(raw_body.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError) as exc:
        raise CallbackError("invalid_body", "The callback body is not UTF-8 JSON.") from exc
    if not isinstance(payload, dict):
        raise CallbackError("invalid_body", "The callback body must be a JSON object.")
    if payload.get("protocol") != PROTOCOL:
        raise CallbackError("unsupported_protocol", "The callback protocol is unsupported.")
    if payload.get("project_id") != expected_project_id:
        raise CallbackError("project_mismatch", "The callback Project does not match this app.")
    challenge = payload.get("challenge")
    if not isinstance(challenge, str) or not challenge:
        raise CallbackError("missing_challenge", "The callback challenge is missing.")

    replay_key = f"{expected_project_id}:{challenge}:{timestamp}"
    if not claim_replay(replay_key, current_time + REPLAY_RETENTION_SECONDS):
        raise CallbackError("replayed_request", "The callback was already processed.")

    response_body = json.dumps(
        {
            "accepted": True,
            "challenge": challenge,
            "project_id": expected_project_id,
            "runtime_key_sha256": hashlib.sha256(runtime_key.encode("utf-8")).hexdigest(),
        },
        sort_keys=True,
        separators=(",", ":"),
    ).encode("utf-8")
    response_timestamp = str(current_time)
    response_signature = hmac.new(
        signing_secret.encode("utf-8"),
        response_timestamp.encode("ascii") + b"." + response_body,
        hashlib.sha256,
    ).hexdigest()
    return 200, {
        "Content-Type": "application/json",
        SIGNATURE_HEADER: f"v1={response_signature}",
        TIMESTAMP_HEADER: response_timestamp,
    }, response_body

# Example framework boundary:
# raw_body = read_limited_body(request_body_stream, header(request_headers, "Content-Length"))
# status, response_headers, response_body = verify_callback(
#     method=request_method, headers=request_headers, raw_body=raw_body, ...
# )
```

## Non-secret test vector

`test-vector-signing-key` is a public test value and must never be reused as a credential. Copy
these values into an external contract test rather than depending on a General Augment source
checkout.

```json
{
  "protocol": "genaug-app-tools/v1",
  "timestamp": 1735689600,
  "request_body": "{\"challenge\":\"challenge_vector_01\",\"connection_id\":\"connection_vector_01\",\"effect_key\":\"project-connection:vector:provision\",\"operation_id\":\"operation_vector_01\",\"project_id\":\"project_vector_01\",\"protocol\":\"genaug-app-tools/v1\",\"runtime_key_hash_algorithm\":\"sha256\",\"target\":\"candidate\"}",
  "request_signature": "20f5c2ddefdfe7a96cae4853b3316ef9b3b8cecc02e833285355254020fa4072",
  "response_body": "{\"accepted\":true,\"challenge\":\"challenge_vector_01\",\"project_id\":\"project_vector_01\",\"runtime_key_sha256\":\"1113f5af77949e37f90c8b6db4ac7894cc38596bb7dc4c4a8b70b3b0520e1279\"}",
  "response_signature": "d6ace867e1f20c19dcd4aa687ea8fa9f6b8f7cc758c15c5a6982e3e3588eb3f8"
}
```
