Sentinel

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 destinations entry, echoing the requested destination_id.
  • protocol is "http-put" or "s3-multipart". Per file: a plain put_url, or the multipart trio (upload_id, part_size, part_urls, complete_url). part_urls[i] is part number i + 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_at to match. Expiry mid-upload is normal: the robot re-asks with a resume block, and you must issue fresh URLs against the same upload_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_id for in-flight multiparts).
  • Grant every file in the manifest, at exactly the paths given.
  • retention is 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_id per 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}/check

Sends 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.