Build a grant provider
Run an external destination: your endpoint issues the upload URLs, and robot data lands in storage only you can read.
With an external destination, the upload URLs come from you. When a robot has an episode to sync, the platform forwards the request to your HTTPS endpoint, you answer with presigned URLs into your own storage, and the robot uploads each file directly to the URL you issued. Data goes straight from the robot to your storage — Avea passes links along and never has access to the bytes. What the platform keeps is metadata: the episode index, sync state, and the object_key you optionally report.
Register
curl -X POST https://api-prod.avearobotics.com/v1/destinations \
-H "Authorization: Bearer ak_..." \
-H "Content-Type: application/json" \
-d '{ "type": "external", "url": "https://grants.example.com/grants" }'The response carries a signing secret (dsec_...) once — the HMAC key Avea uses to sign every request it forwards to your endpoint. Store it and verify signatures with it; it is the only way to tell a genuine grant request from anyone else POSTing JSON at your URL.
If this is your only destination, registration is enough — with no routing rules configured and exactly one destination, every episode routes to it. Add rules when you need to split traffic.
Handle a grant request
The platform POSTs the robot's request verbatim, wrapped in an envelope with resolved identifiers:
{
"episode_id": "3e1b...",
"org_id": "org_...",
"request": {
"episode": { "robot_id": "rbt_...", "recording_session_id": "session_20260810_141133", "episode_seq": 3 },
"manifest": {
"files": [
{ "path": "episode_0003/data.mcap", "size_bytes": 123456789, "sha256": "9f2c..." },
{ "path": "episode_0003/metadata.yaml", "size_bytes": 4096, "sha256": "11ab..." }
],
"total_bytes": 123460885
},
"resume": [
{ "destination_id": "dst_...", "uploads": [ { "path": "episode_0003/data.mcap", "upload_id": "s3-upload-id", "parts_done": [1, 2, 3] } ] }
],
"pressure": { "free_bytes": 50000000000, "backlog_bytes": 2000000000, "hours_to_full": 48.5 }
}
}resume and pressure are optional. Requests are signed with the webhook signature scheme using the destination's secret — verify before acting.
Respond within 10 seconds, ≤ 1 MiB, with a grant or a deferral.
Grant
{
"grant_id": "g_your_id_01",
"expires_at": "2026-08-10T22:00:00Z",
"retry_after": null,
"retention": { "local": "keep" },
"destinations": [
{
"destination_id": "dst_...",
"protocol": "s3-multipart",
"object_key": "robots/unit-04/session_20260810_141133/episode_0003/",
"files": [
{
"path": "episode_0003/data.mcap",
"upload_id": "s3-upload-id",
"part_size": 67108864,
"part_urls": ["https://...&partNumber=1&...", "https://...&partNumber=2&..."],
"complete_url": "https://...?uploadId=..."
},
{ "path": "episode_0003/metadata.yaml", "put_url": "https://...X-Amz-Signature=..." }
]
}
]
}- Exactly one
destinationsentry, echoing the requesteddestination_id. protocolis"http-put"or"s3-multipart". Per file: a plainput_url, or the multipart trio (upload_id,part_size,part_urls,complete_url).part_urls[i]is part numberi + 1. Multipart completion is presigned — the robot never calls you back.object_key(optional) is recorded on the replica and surfaced in the API and webhooks; the platform cannot derive keys from opaque URLs.- Keep URLs short-lived (about an hour) and set
expires_atto match. Expiry mid-upload is normal: the robot re-asks with aresumeblock, and you must issue fresh URLs against the sameupload_id.
Deferral
{ "grant_id": null, "destinations": [], "retention": { "local": "keep" }, "retry_after": 3600 }"Ask again in retry_after seconds" — for quota, maintenance, or an episode you haven't ingested yet. Decisions can be deferred indefinitely; the robot keeps the episode and retries. If your endpoint times out, errors, or returns an invalid body, the robot receives a deferral automatically.
Requirements
- Idempotent per episode. A re-ask for an already-granted episode returns a working grant for the same objects (fresh URLs, same
upload_idfor in-flight multiparts). - Grant every file in the manifest, at exactly the paths given.
retentionis required but not applied. The schema requires it in every response (grant and deferral); the effective local-copy directive comes from the destination's registration.- New
grant_idper response. Reuse is rejected and becomes a deferral.
Episode context
Subscribe to episode.finalized webhooks to pre-ingest episode context. Dispatch runs on a short delay, so a grant request can arrive before the webhook — on a cache miss, fetch GET /v1/episodes/{episode_id}; if that fails, defer.
Test your provider
POST /v1/destinations/{id}/checkSends a synthetic signed grant request through the real forwarding path and grades the response:
{ "result": "pass", "mode": "granted", "http_status": 200, "latency_ms": 412 }mode is granted or deferred — a deferral passes, since the check uses a synthetic episode your provider has never seen. On fail, failure is one of unreachable, timeout, http_error, invalid_json, schema_invalid, or destination_mismatch. Nothing is persisted — run it in CI.
GET /v1/destinations reports per-provider health for external destinations: last_forward_at, forwards_24h, and deferrals_24h.