API Reference
Backups API

Backups API

Full REST reference for the four /api/admin/backups* routes: creating, listing, downloading, and restoring application-level data backups. These routes are served by the same admin_handler.py / admin_service.py module as the rest of the admin domain (see the Admin API reference), require a Cognito JWT (Authorization: Bearer <token>), and are backed by services/backend/lambda/admin_handler.py / admin_service.py + services/backend/lambda/common/backup.py (create) + services/backend/lambda/backup_restore_handler.py / backup_restore_service.py (restore, a separate Lambda invoked asynchronously). See specs/api/backups.md in the repo for the underlying spec this page is based on, and the Backups feature guide for the product-level behavior.

⚠️

Every endpoint on this page requires the caller's JWT custom:role claim to be admin — checked fresh on every call, exactly like the rest of the admin domain. Anything else gets 403 {"error": "Admin access required"}.

Endpoints at a glance

MethodPathDescription
POST/api/admin/backupsCreate a backup
GET/api/admin/backupsList backups
GET/api/admin/backups/downloadGet a download URL
POST/api/admin/backups/restoreRestore a backup
POST/api/admin/backups/upload-urlGet an upload URL

Create

POST /api/admin/backups

No request body. This call is synchronous — it blocks while it scans the full DynamoDB table, lists and downloads every S3 asset, builds the ZIP, and uploads it, then responds. (Compare with Restore, which returns immediately and runs in the background.)

// 201 Response
{
  "timestamp": "2026-07-11T04-00-00",
  "key": "_backups/2026-07-11T04-00-00.zip",
  "item_count": 812,
  "asset_count": 47,
  "size_bytes": 5242880,
  "created_at": "2026-07-11T04:00:03.512000+00:00"
}

timestamp uses hyphens instead of colons in the time portion (so it's safe as an S3 key) — it looks like ISO 8601 but isn't quite. created_at is a real ISO 8601 timestamp.

StatusReason
403Caller is not an admin
5xxThe DynamoDB scan, S3 listing/download, ZIP build, or final upload failed — errors are re-raised, not swallowed

List

GET /api/admin/backups
// 200 Response
{
  "backups": [
    {
      "key": "_backups/2026-07-11T04-00-00.zip",
      "timestamp": "2026-07-11T04-00-00",
      "size_bytes": 5242880,
      "created_at": "2026-07-11T03:00:04.221000+00:00"
    }
  ]
}

Sorted newest-first by timestamp.

⚠️

This sort is a plain string comparison, not a real date comparison. Backups created via Upload URL get a key like _backups/upload-2026-07-11T09-30-00.zip, so their derived timestamp literally starts with the text upload-. Since the letter u sorts after every digit, an imported backup always shows up first in this list — "newest" — no matter when it was actually created or uploaded.

StatusReason
403Caller is not an admin

Download

GET /api/admin/backups/download?key=_backups/2026-07-11T04-00-00.zip

key is required and must look like _backups/*.zip (a basic guard against path traversal, not a check that the object exists).

// 200 Response
{ "url": "https://.../_backups/2026-07-11T04-00-00.zip?X-Amz-..." }

A presigned S3 GET URL, valid for 1 hour.

A well-formed but nonexistent key still returns 200 with a URL — S3 presigning doesn't check the object exists. The failure only shows up when something actually tries to fetch that URL.

StatusReason
400key missing
400key doesn't match the _backups/*.zip pattern
403Caller is not an admin

Restore

POST /api/admin/backups/restore
// Request
{ "key": "_backups/2026-07-11T04-00-00.zip" }

key is required and validated the same way as Download. Asynchronously triggers a separate Lambda (backup_restore_handler) and returns immediately.

// 200 Response
{
  "started": true,
  "message": "Restore of _backups/2026-07-11T04-00-00.zip has been started. The process runs in the background."
}
⚠️

This response does not mean the restore succeeded — only that the background job was accepted for execution. The restore itself (full destructive table replace, cross-environment user remapping by email, asset re-upload — see the Backups feature guide) can still fail afterward for reasons this endpoint never learns about (corrupt ZIP, a DynamoDB/S3 error mid-restore). There is no polling endpoint and no notification — the only record of a failure is in the restore Lambda's own logs.

StatusReason
400key missing/empty
400key doesn't match the _backups/*.zip pattern
403Caller is not an admin
500The restore Lambda's function name isn't configured in this environment
5xxThe Lambda invoke call itself failed (e.g. IAM, throttling)

Upload URL

POST /api/admin/backups/upload-url

No request body. Step 1 of importing an external backup file: generates a presigned S3 PUT URL for a new _backups/upload-{timestamp}.zip key (1 hour expiry, Content-Type: application/zip pinned into the signature — the client's upload request must send that same header).

// 200 Response
{
  "url": "https://.../_backups/upload-2026-07-11T09-30-00.zip?X-Amz-...",
  "key": "_backups/upload-2026-07-11T09-30-00.zip"
}

This only mints the URL — nothing is uploaded or validated yet. The full flow is: call this endpoint, PUT the ZIP bytes straight to the returned url, then call Restore with the returned key. If the upload step never happens, the key is simply never created — there is no cleanup job for abandoned uploads either way.

StatusReason
403Caller is not an admin