# API reference

Safeput development upload, verdict, and retrieval API endpoints.

The development API is available at `https://dev.safeput.dev/v1`. It creates
private upload records, returns short-lived single-use upload targets, and exposes
controlled retrieval after inspection.

## Authentication

Send a project API key as a bearer token. Use API keys only from a trusted
backend; never embed them in browser or mobile application code.

```http
Authorization: Bearer sp_test_...
```

The key selects exactly one project and environment. A revoked, malformed, or
unknown key returns `401 invalid_api_key`.

## Create an upload

`POST /v1/uploads`

An `Idempotency-Key` header of at most 128 printable ASCII characters is required.
Repeating the same key and body while the upload is still `waiting` returns the
original upload with `200` and `Idempotency-Replayed: true`, without extending its
original ten-minute transfer deadline. A different body returns
`409 idempotency_conflict`; a non-waiting upload returns `409 upload_not_waiting`;
an expired target returns `410 upload_target_expired`. Use a new key for a new upload.

```json
{
  "filename": "invoice.pdf",
  "content_type": "application/pdf",
  "size": 12480
}
```

`size` is the expected byte count and must be between 1 and 52,428,800 bytes
(50 MiB), and cannot exceed the project's snapshotted maximum. A stricter project
limit returns `413 policy_size_limit_exceeded`. See [Scan policies](/docs/scan-policies/)
for detected-type and ZIP-member rules. A successful first request returns `201`:

```json
{
  "upload": {
    "id": "7cc816f4-0e2e-4be0-9eaa-b9615a27ee52",
    "status": "waiting",
    "filename": "invoice.pdf",
    "content_type": "application/pdf",
    "size": "12480",
    "actual_size": null,
    "created_at": "2026-08-15T18:00:00.000Z",
    "updated_at": "2026-08-15T18:00:00.000Z",
    "upload_url_expires_at": "2026-08-15T18:10:00.000Z",
    "expires_at": null,
    "download_available": false,
    "verdict": null
  },
  "upload_target": {
    "method": "PUT",
    "url": "https://...",
    "headers": { "Content-Type": "application/pdf", "Authorization": "Bearer st_<scoped-capability>" },
    "expires_at": "2026-08-15T18:10:00.000Z"
  }
}
```

Upload the exact file bytes to `upload_target.url` using its method and every
returned header, including its scoped Authorization value (not your project key).
The target expires after 10 minutes and is single-use, even after rejected bytes
are deleted. Send a known-length file/Blob with the exact declared size. Success
returns `204`; a reused target returns `409`, missing Content-Length `411`, and
an oversized body `413`. Admission or edge limits return `429`. The underlying
R2 object is private; a successful transfer still requires a clean scan verdict.

## Get an upload

`GET /v1/uploads/:id`

Returns `{ "upload": ... }` for an upload belonging to the API key's project.
Unknown IDs and uploads owned by other projects both return
`404 upload_not_found`.

The upload includes an immutable `expires_at` once it becomes clean and a
computed `download_available` flag.

## Create a download URL

`POST /v1/uploads/:id/download-url`

Returns a bearer URL that remains valid for at most five minutes and never
beyond the upload's 72-hour clean retention deadline:

```json
{
  "download": {
    "method": "GET",
    "url": "https://dev.safeput.dev/v1/downloads/...",
    "expires_at": "2026-08-19T20:05:00.000Z"
  }
}
```

Unknown and cross-project IDs return `404`; non-clean uploads return `409`; an
expired upload returns `410`.

## Status behavior

Records move through `waiting`, `uploaded`, and `scanning` before reaching a
terminal verdict. Clean files remain retrievable for 72 hours. The expiry job
deletes their bytes before durably transitioning them to `expired`.

## Dashboard management endpoints

The dashboard uses an Auth0 access token—not a project API key—to manage:

```text
GET  /v1/projects/:projectId/uploads
PUT  /v1/projects/:projectId/scan-policy
GET  /v1/projects/:projectId/webhook
PUT  /v1/projects/:projectId/webhook
POST /v1/projects/:projectId/webhook/rotate-secret
POST /v1/projects/:projectId/webhook/test
GET  /v1/projects/:projectId/webhook-deliveries
POST /v1/projects/:projectId/webhook-deliveries/:deliveryId/retry
GET  /v1/projects/:projectId/usage
GET  /v1/billing
```

The scan-policy endpoint accepts a complete v2 project policy and changes only
future upload snapshots. Its schema, limits and ZIP semantics are documented in
[Scan policies](/docs/scan-policies/) and the OpenAPI specification.

Upload history returns up to 100 rows per page and accepts `status`, `search`
(literal filename or upload ID substring), and `cursor`. Delivery history returns
up to 50 rows and accepts `status` and `cursor`. Both responses include
`next_cursor`: pass it unchanged with the same filters for the next page; `null`
means there are no more results. Cursors and results are scoped to the owned
project. Changing filters starts a new traversal without a cursor.

Usage reports exact inspected bytes and billable bytes. Without `from`/`to`, these
are lifetime project totals. Date filters use an inclusive `from` and exclusive
`to`. Failed inspections remain visible for audit but have zero billable bytes;
test projects are not billed.

Account billing includes `cancel_at`, the metered subscription's `period_start`
and `period_end`, safe price information, and `period_usage` aggregated across
owned live projects. Unknown dates and period usage are `null`. Usage-reporting
counts are diagnostics, not invoice totals. Scheduled cancellation does not make
an active subscription inactive before Stripe confirms cancellation.

## Error shape

```json
{
  "error": {
    "code": "invalid_request",
    "message": "Files cannot exceed 50 MB.",
    "request_id": "..."
  }
}
```
