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
| Method | Path | Description |
|---|---|---|
POST | /api/admin/backups | Create a backup |
GET | /api/admin/backups | List backups |
GET | /api/admin/backups/download | Get a download URL |
POST | /api/admin/backups/restore | Restore a backup |
POST | /api/admin/backups/upload-url | Get an upload URL |
Create
POST /api/admin/backupsNo 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.
| Status | Reason |
|---|---|
| 403 | Caller is not an admin |
| 5xx | The 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.
| Status | Reason |
|---|---|
| 403 | Caller is not an admin |
Download
GET /api/admin/backups/download?key=_backups/2026-07-11T04-00-00.zipkey 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.
| Status | Reason |
|---|---|
| 400 | key missing |
| 400 | key doesn't match the _backups/*.zip pattern |
| 403 | Caller 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.
| Status | Reason |
|---|---|
| 400 | key missing/empty |
| 400 | key doesn't match the _backups/*.zip pattern |
| 403 | Caller is not an admin |
| 500 | The restore Lambda's function name isn't configured in this environment |
| 5xx | The Lambda invoke call itself failed (e.g. IAM, throttling) |
Upload URL
POST /api/admin/backups/upload-urlNo 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.
| Status | Reason |
|---|---|
| 403 | Caller is not an admin |