User-owned callback receivers¶
Awa supports three ways to host the HTTP callback ingress surface:
- Bundled with the admin UI — the default.
awa serveexposes the admin UI plus the three callback routes from the same router. - Standalone receiver —
awa callbacks serveruns a router that mounts only the callback ingress endpoints. Use this when callbacks must be reachable from outside the operator network but the admin surface must remain private. See HTTP callbacks. - User-owned API layer — mount the callback ingress routes inside your own application (FastAPI / Starlette / Flask / Django / axum / actix). This page covers option 3.
The on-wire contract is identical across all three options:
- Routes:
POST {prefix}/{callback_id}/{complete,fail,heartbeat}. - Signature: BLAKE3 keyed-hash of the callback id, lowercase hex, in the
X-Awa-Signatureheader. - Payloads: see the callback receiver contract.
When you host the routes yourself, reuse the shared signing and URL helpers from awa::callback_contract (Rust) or awa.callback_contract (Python) so your implementation cannot drift from the worker's. The helpers are exported specifically so this is a one-line dependency, not a copy-paste of the algorithm.
Security¶
The callback receiver routes mutate job state. Specifically, complete and fail are terminal — once they succeed, the job leaves waiting_external. A receiver that does not verify X-Awa-Signature must be protected another way (mTLS at the load balancer, IP allow-list, private VPC). See ADR-027 for the deployable-role model.
Custom axum receiver (Rust)¶
use awa::callback_contract;
use awa_model::admin;
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::post,
Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
#[derive(Clone)]
struct CallbackState {
pool: PgPool,
secret: [u8; 32],
}
#[derive(Deserialize)]
struct CompletePayload {
#[serde(default)]
payload: Option<serde_json::Value>,
}
#[derive(Serialize)]
struct CallbackResponse {
id: i64,
state: String,
}
async fn complete(
State(state): State<Arc<CallbackState>>,
headers: HeaderMap,
Path(callback_id): Path<String>,
Json(body): Json<CompletePayload>,
) -> Result<Json<CallbackResponse>, (StatusCode, String)> {
let signature = headers
.get(callback_contract::SIGNATURE_HEADER)
.and_then(|v| v.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "missing signature".into()))?;
if !callback_contract::verify(&state.secret, &callback_id, signature) {
return Err((StatusCode::UNAUTHORIZED, "invalid signature".into()));
}
let uuid = uuid::Uuid::parse_str(&callback_id)
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let job = admin::complete_external(&state.pool, uuid, body.payload, None)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(CallbackResponse {
id: job.id,
state: format!("{:?}", job.state),
}))
}
pub fn callback_routes(state: Arc<CallbackState>) -> Router {
Router::new()
.route(
"/api/callbacks/{callback_id}/complete",
post(complete),
)
// fail/heartbeat follow the same pattern — see
// awa-ui/src/handlers/callbacks.rs for the canonical implementation.
.with_state(state)
}
For most users the built-in receiver in awa::callback_router (PR #279) covers this case. Mount the routes yourself only when you need them inside an existing axum application.
Custom FastAPI receiver (Python)¶
import os
from typing import Any
import uuid
from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Request
from pydantic import BaseModel
from awa import AsyncClient, callback_contract
CALLBACK_SECRET = bytes.fromhex(os.environ["AWA_CALLBACK_HMAC_SECRET"])
router = APIRouter(prefix=callback_contract.DEFAULT_PATH_PREFIX)
def verify_signature(
callback_id: str,
x_awa_signature: str | None = Header(default=None),
) -> None:
if x_awa_signature is None:
raise HTTPException(
status_code=401,
detail=f"missing {callback_contract.SIGNATURE_HEADER} header",
)
if not callback_contract.verify(CALLBACK_SECRET, callback_id, x_awa_signature):
raise HTTPException(status_code=401, detail="invalid callback signature")
class CompletePayload(BaseModel):
payload: dict[str, Any] | None = None
class FailPayload(BaseModel):
error: str
class HeartbeatPayload(BaseModel):
timeout_seconds: float = callback_contract.DEFAULT_HEARTBEAT_TIMEOUT_SECS
def get_client(request: Request) -> AsyncClient:
# AsyncClient is constructed in your application's lifespan and stashed on
# app.state — see awa-python's getting-started guide.
return request.app.state.awa
@router.post("/{callback_id}/complete")
async def complete(
callback_id: str,
body: CompletePayload,
client: AsyncClient = Depends(get_client),
_: None = Depends(verify_signature),
) -> dict[str, Any]:
job = await client.complete_external(callback_id, body.payload)
return {"id": job.id, "state": str(job.state)}
@router.post("/{callback_id}/fail")
async def fail(
callback_id: str,
body: FailPayload,
client: AsyncClient = Depends(get_client),
_: None = Depends(verify_signature),
) -> dict[str, Any]:
job = await client.fail_external(callback_id, body.error)
return {"id": job.id, "state": str(job.state)}
@router.post("/{callback_id}/heartbeat")
async def heartbeat(
callback_id: str,
body: HeartbeatPayload,
client: AsyncClient = Depends(get_client),
_: None = Depends(verify_signature),
) -> dict[str, Any]:
if body.timeout_seconds < 0 or body.timeout_seconds != body.timeout_seconds:
raise HTTPException(
status_code=400,
detail="timeout_seconds must be a finite, non-negative number",
)
job = await client.heartbeat_callback(callback_id, body.timeout_seconds)
return {"id": job.id, "state": str(job.state)}
app = FastAPI()
app.include_router(router)
The awa.callback_contract module wraps the same Rust signing implementation the worker uses, so a signature this verifier accepts is bit-for-bit identical to a signature awa serve would accept. There is a parity test (awa-python/tests/test_callback_contract.py) that pins a known BLAKE3 vector across both languages.
Configuring the worker to point at your receiver¶
Whichever option you pick, the worker side needs to know the full callback URL. Set HttpWorkerConfig::callback_base_url to your receiver's externally-reachable base URL, and override callback_path_prefix if your routes are mounted somewhere other than /api/callbacks:
HttpWorkerConfig {
callback_base_url: Some("https://api.example.com".into()),
callback_path_prefix: Some("/api/v1/awa-cb".into()),
hmac_secret: Some(callback_secret),
..Default::default()
}
The worker uses awa::callback_contract::callback_url internally to build the URL — the same helper your receiver can use to compute URLs for outbound retries or diagnostics.