API Reference¶
Complete reference for all 19 HandoffRail API endpoints.
Base URL¶
Authentication¶
All endpoints require the X-API-Key header. API keys are scoped to a tenant and tier.
Create keys via POST /api/v1/keys. Keys are hashed at rest — the full key is only returned once at creation.
Rate Limiting¶
Rate limits are enforced per API key based on tier:
| Tier | Requests / Hour |
|---|---|
| Free | 100 |
| Pro | 1,000 |
| Business | 10,000 |
Rate limit info is returned in response headers:
When exceeded, the API returns 429 Too Many Requests with a Retry-After header.
Packet Endpoints¶
POST /packets — Create Handoff Packet¶
Create a new handoff packet. Validates against v1 schema.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
parent_packet_id |
UUID | No | ID of parent packet (for chain handoffs) |
metadata.source_agent |
object | Yes | Source agent identity (id, name, framework) |
metadata.target_agent |
object | Yes | Target agent identity (id, name, optional framework) |
metadata.priority |
string | No | low, normal, high, critical (default: normal) |
metadata.tags |
string[] | No | Freeform tags for filtering |
context.summary |
string | Yes | Natural-language summary of work so far |
context.conversation_state |
object[] | No | Conversation turns (role, content, optional timestamp) |
context.artifacts |
object[] | No | Named artifacts (key, value, optional content_type) |
context.custom |
object | No | Framework-specific or user-defined fields |
decisions |
object[] | No | Decisions made (id, decision, rationale, optional alternatives, decided_by) |
actions.pending |
object[] | No | Pending actions (id, description, assignee, optional priority, depends_on) |
actions.completed |
object[] | No | Completed actions (id, description, result) |
actions.failed |
object[] | No | Failed actions (id, description, error) |
dependencies |
object[] | No | External dependencies (id, type, description, optional status, source) |
hitl |
object | No | HITL checkpoint (required, reason, question, optional options, timeout_seconds) |
Response 201 Created:
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"version": "1.0.0",
"parent_packet_id": null,
"metadata": { "source_agent": { "..." }, "target_agent": { "..." }, "created_at": "..." },
"context": { "summary": "...", "conversation_state": [], "artifacts": [], "custom": {} },
"decisions": [],
"actions": { "pending": [], "completed": [], "failed": [] },
"dependencies": [],
"hitl": null,
"status": "created",
"created_at": "2026-05-30T19:35:00Z",
"updated_at": "2026-05-30T19:35:00Z",
"_links": {
"self": "/api/v1/packets/a1b2c3d4-...",
"claim": "/api/v1/packets/a1b2c3d4-.../claim",
"history": "/api/v1/packets/a1b2c3d4-.../history"
}
}
Errors:
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Schema validation failed (includes field-level errors) |
| 401 | UNAUTHORIZED |
Missing or invalid API key |
| 413 | PAYLOAD_TOO_LARGE |
Packet exceeds tier size limit |
| 429 | RATE_LIMITED |
Rate limit exceeded |
GET /packets — List Packets¶
List packets with filtering, sorting, and pagination.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
status |
string | — | Filter by status (comma-separated for OR: created,claimed) |
source_agent |
string | — | Filter by source agent ID |
target_agent |
string | — | Filter by target agent ID |
tags |
string | — | Filter by tags (comma-separated, AND logic) |
priority |
string | — | Filter by priority |
hitl_required |
boolean | — | Only packets needing human review |
created_after |
datetime | — | ISO 8601 timestamp |
created_before |
datetime | — | ISO 8601 timestamp |
limit |
integer | 50 | Max results (1–200) |
offset |
integer | 0 | Pagination offset |
sort |
string | created_at |
Sort field: created_at, priority, status |
order |
string | desc |
Sort order: asc or desc |
Response 200 OK:
{
"packets": [ "...array of packet summaries..." ],
"total": 142,
"limit": 50,
"offset": 0,
"_links": {
"next": "/api/v1/packets?offset=50&limit=50",
"prev": null
}
}
GET /packets/{id} — Get Single Packet¶
Full packet detail by ID.
Response 200 OK: Complete packet object (same shape as create response).
Errors: 404 Not Found if packet doesn't exist.
PATCH /packets/{id} — Update Packet¶
Partial update to packet fields. Used for progressing work, adding decisions, updating actions.
Request Body: Any subset of packet fields:
{
"status": "in_progress",
"decisions": [
{"id": "d2", "decision": "Applied Business tier pricing", "rationale": "Customer confirmed", "decided_by": "billing-01"}
],
"actions": {
"completed": [
{"id": "a1", "description": "Process upgrade", "result": "Upgraded to Business", "completed_by": "billing-01"}
]
}
}
Response 200 OK: Full updated packet.
Errors:
| Status | When |
|---|---|
| 400 | Invalid status transition |
| 404 | Packet not found |
DELETE /packets/{id} — Soft-Delete Packet¶
Marks packet as expired. Hard delete requires admin scope.
Response 204 No Content
POST /packets/{id}/claim — Claim a Packet¶
Agent claims an unclaimed packet, transitioning created → claimed.
Request Body:
Response 200 OK: Updated packet with status: "claimed" and claimed_at populated.
Errors:
| Status | When |
|---|---|
| 404 | Packet not found |
| 409 | Packet already claimed (returns current claimant info) |
| 410 | Packet expired |
POST /packets/{id}/respond — HITL Response¶
Submit a human response to a packet in awaiting_human status.
Request Body:
{
"response": "Approve full refund",
"responded_by": "manager@company.com",
"notes": "Customer is VIP"
}
Response 200 OK: Updated packet with hitl.response filled and status → claimed.
Errors:
| Status | When |
|---|---|
| 409 | Packet not in awaiting_human status |
| 410 | HITL timeout expired |
POST /packets/{id}/chain — Chain Handoff¶
Create a new packet that continues from this one. parent_packet_id is auto-set.
Request Body: Same shape as POST /packets (without parent_packet_id).
Response 201 Created: New packet with parent_packet_id populated.
GET /packets/awaiting — List HITL-Pending¶
Convenience endpoint: all packets with status=awaiting_human.
Query Parameters: limit, offset (same as GET /packets).
Response 200 OK: Same shape as GET /packets.
GET /packets/{id}/history — Event History¶
Audit trail of all status transitions and modifications for a packet.
Response 200 OK:
{
"packet_id": "a1b2c3d4-...",
"events": [
{ "timestamp": "2026-05-30T19:35:00Z", "event_type": "created", "actor": "agent:sales-01", "details": {} },
{ "timestamp": "2026-05-30T19:40:00Z", "event_type": "claimed", "actor": "agent:billing-01", "details": {} },
{ "timestamp": "2026-05-30T19:50:00Z", "event_type": "completed", "actor": "agent:billing-01", "details": { "actions_completed": 1 } }
]
}
Webhook Endpoints¶
POST /hooks — Register Webhook¶
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | Yes | HTTPS callback URL |
events |
string[] | No | Event types to subscribe to (defaults to standard set) |
secret |
string | Yes | Secret for HMAC-SHA256 signing (min 16 chars) |
Valid Event Types:
packet.created, packet.claimed, packet.in_progress, packet.awaiting_human, packet.completed, packet.failed, packet.expired, hitl.response_ready
Response 201 Created: Webhook object with id, url, events, active, created_at.
Webhook Payload: POST to url with full packet payload and X-HR-Signature HMAC-SHA256 header.
Verifying Webhooks:
import hmac, hashlib
def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
GET /hooks — List Webhooks¶
All webhooks for the authenticated tenant.
Response 200 OK: Array of webhook objects.
DELETE /hooks/{id} — Deactivate Webhook¶
Soft-delete. Response 204 No Content.
API Key Endpoints¶
POST /keys — Create API Key¶
Request Body:
Response 201 Created:
{
"id": "key-uuid",
"key": "hr_full_key_returned_once",
"name": "my-agent",
"tier": "free",
"created_at": "2026-05-30T19:35:00Z"
}
⚠️ The full key is only returned once. Store it securely.
GET /keys — List API Keys¶
All keys for the authenticated tenant. Full keys are not returned.
Response 200 OK: Array of key objects (without key field).
DELETE /keys/{id} — Revoke API Key¶
Immediately invalidates the key. Response 204 No Content.
System Endpoints¶
GET /health — Liveness Probe¶
Returns 200 OK if the process is running. No authentication required.
GET /ready — Readiness Probe¶
Returns 200 OK if the database is connected, 503 Service Unavailable if not. No authentication required.
GET /metrics — Prometheus Metrics¶
Standard Prometheus format. No authentication required.
Key Metrics:
| Metric | Type | Description |
|---|---|---|
handoffrail_requests_total |
Counter | Request count by method, endpoint, status |
handoffrail_request_latency_seconds |
Histogram | Request latency by method, endpoint |
handoffrail_active_packets |
Gauge | Non-terminal packet count |
handoffrail_handoffs_total |
Counter | Handoffs per tenant |
Error Format¶
All errors follow a consistent structure:
{
"detail": "Human-readable error message",
"code": "MACHINE_READABLE_CODE",
"field": "field_name",
"status_code": 400
}
Common Error Codes:
| Code | HTTP Status | Description |
|---|---|---|
VALIDATION_ERROR |
400 | Request body failed schema validation |
UNAUTHORIZED |
401 | Missing or invalid API key |
NOT_FOUND |
404 | Resource doesn't exist |
CONFLICT |
409 | State conflict (e.g., already claimed) |
GONE |
410 | Resource expired |
RATE_LIMITED |
429 | Rate limit exceeded |
PAYLOAD_TOO_LARGE |
413 | Packet exceeds tier size limit |
SERVER_ERROR |
500 | Internal server error |