Files and webhooks
Upload files, publish assets, and subscribe to events with webhooks.
Uploads
An upload stages one file in a workspace for later use: as a skill package or as an asset. Send it as multipart form data with a part named file and an Idempotency-Key:
curl -X POST "$A13N_URL/api/v1/uploads" \
-H "Authorization: Bearer $A13N_API_KEY" -H "Idempotency-Key: report-2026-09" -F [email protected]The response gives the upload_id, filename, content type, size and SHA-256 digest. Retrying with the same caller, workspace, key, bytes, filename and content type returns the same upload. Changing any upload content or metadata under that key is 409 conflict (reason idempotency_key_reused). Uploads need write, are limited to objects.upload_bytes (1 MiB by default), and count against a per-principal rate limit (objects.upload_limit per objects.upload_window_seconds).
Assets
An asset is immutable, named content. You create assets from uploads, agents can publish them, and you attach them to messages.
- Create from an upload with
POST /api/v1/assetsand{"upload_id": ..., "name": ...}. The first creation returns201; repeating it with the same upload and name returns the same asset with200. An upload becomes at most one asset. - Read with
GET …/assets,GET …/assets/{asset_id}, andGET …/assets/{asset_id}/content, which returns the bytes as an attachment. - Retire with
DELETE …/assets/{asset_id}and itsIf-Match. A retired asset cannot be attached to new messages, but its content stays readable for the history that references it.
Attach an asset to a message as a content part {"type": "asset", "asset_id": "ast_..."}; see messages. The run reads the content with the submitter's access and gives it to the model as media, as text, or as a file in the run's environment; see attached files.
Agents with the assets toolset enabled can publish a file from their environment with publish_asset. The new asset records the run, attempt and tool call that produced it in source, and is bounded by objects.max_bytes.
Webhooks
A subscription delivers run lifecycle events of a workspace to an HTTPS endpoint. Managing subscriptions and reading their deliveries requires workspace admin.
curl -X POST "$A13N_URL/api/v1/subscriptions" \
-H "Authorization: Bearer $A13N_API_KEY" -H "Content-Type: application/json" \
-d '{"name": "Run outcomes", "url": "https://hooks.example.com/a13n",
"kinds": ["run.completed", "run.failed", "run.waiting"],
"filter": {"agent_id": "ap_..."}}'kindsselects events:run.accepted,run.running,run.waiting,run.completed,run.failed,run.cancelled, and for attemptsrun_attempt.leased,run_attempt.running,run_attempt.succeeded,run_attempt.yielded,run_attempt.failed,run_attempt.cancelled.filteroptionally narrows events to anagent_id,session_idorthread_id.signing_secret(16–256 characters) is optional; without it the Service generates one. The secret is returned only in the creation response.PATCHchanges the name, URL, kinds, filter,enabledorsigning_secret;DELETEremoves the subscription. Both takeIf-Match. Changes affect only deliveries queued afterwards.- A workspace has at most
control.subscriptionssubscriptions (32 by default). The URL must pass the outbound policy when saved and on every delivery.
Payload and signature
Each event is a JSON POST:
{
"id": "obx_...",
"type": "run.completed",
"occurred_at": "2026-09-24T08:00:00+00:00",
"workspace_id": "ws_...",
"run": {"id": "run_...", "session_id": "sess_...", "thread_id": "thread_...", "agent_id": "ap_...",
"agent_revision_id": "apr_...", "status": "completed", "trigger": "input", "wait_reason": null, "failure": null},
"attempt": null
}Run events carry attempt: null; attempt events add the attempt's id, number, status, start_reason, yield_reason and failure. Fetch details, such as the run's result, through the API.
Every request carries:
| Header | Value |
|---|---|
X-A13n-Delivery-Id | The delivery ID, also the payload id. Use it to discard duplicates. |
X-A13n-Webhook-Timestamp | Unix seconds when the request was signed. |
X-A13n-Webhook-Signature | v1= followed by the hex HMAC-SHA256 of {timestamp}.{delivery_id}.{body}, keyed with the signing secret. |
Verify the signature over the raw body and reject stale timestamps. This example allows five minutes of clock difference; choose a window for your receiver and keep its clock synchronized:
import hashlib
import hmac
import time
def verify(secret: str, headers, body: bytes, *, max_age_seconds: int = 300) -> bool:
try:
timestamp = headers["X-A13n-Webhook-Timestamp"]
delivery_id = headers["X-A13n-Delivery-Id"]
received = headers["X-A13n-Webhook-Signature"]
if abs(time.time() - int(timestamp)) > max_age_seconds:
return False
except (KeyError, ValueError):
return False
signed = f"{timestamp}.{delivery_id}.".encode() + body
expected = "v1=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)Delivery and retries
Delivery is at least once and not ordered. Any 2xx response within control.webhook_timeout (10 seconds by default) counts as delivered; redirects are not followed. Otherwise the delivery is retried with exponential backoff (up to 2, 4, 8, … seconds with 20% jitter, capped at one hour) until it has used the webhook outbox policy's max_attempts attempts (outbox.by_kind.webhook, else outbox.defaults; 12 by default), and is then marked dead.
GET …/subscriptions/{subscription_id}/deliveries lists deliveries newest first with their status (pending, delivered, dead), attempts, last error and payload. POST …/deliveries/{delivery_id}/redeliver sends a dead delivery again with the same ID, URL, payload and signing secret it was queued with. Settled deliveries are purged after the webhook retention period: by default one day after delivery, and fourteen days after a delivery became dead.