API
A contract-first REST API, its conventions and how CI keeps code and contract together.
Everything the Secure File Transfer web app does goes through a public, versioned REST API, and tenant automation uses the same API with API keys. The contract is written first in OpenAPI 3.1, and CI keeps the code in step with it.
Contract first
The requirement (FR-31) is that every web app action goes through a public REST API described in OpenAPI 3.1. The project went further and wrote the contract before any code (ADR-0002). The file docs/api/openapi.yaml leads, and the code follows.
Four CI checks hold the two together:
| Check | Fails when |
|---|---|
| Lint | The spec breaks the linter's rules, or an operation lacks a declared permission or a 429 response |
| Generated spec | An operation the code implements is missing from the contract, or differs in method, path, parameters, request body or any response |
| Breaking changes | A pull request changes the contract in a way that breaks clients, and doesn't carry the breaking label |
| Web app client | The web app's TypeScript client, generated from the contract, is out of date or no longer type-checks |
The generated-spec check builds the OpenAPI document FastAPI produces and compares each operation with the contract, error responses included, in both directions. Operations not yet implemented are allowed, and the check prints how many remain. The breaking-change check runs oasdiff against the base branch, with no network access, because the pull request's file is untrusted.
Operations
The contract has 82 operations under the /v1 base path, each in one tag.
| Tag | Operations | Covers |
|---|---|---|
| Data loss prevention | 22 | DLP policy, rules and detectors; findings; confirmation; approvals; reports |
| Tenant admin | 10 | Members, roles, tenant policy, email suppressions |
| Transfers | 9 | Create, send, cancel, revoke, extend; recipients and unlocks |
| Recipient session | 9 | What a verified recipient or uploader can list, upload and download |
| Files | 8 | Declaring files, part URLs, completion; received-file downloads |
| Upload requests | 6 | Create, list, revoke, unlock; received files |
| Operator | 5 | Operator token, tenant provisioning, SSO setup, admin recovery, break-glass records |
| Sign-in | 4 | Start and complete sign-in, invitation sign-in, sign-out |
| Links and verification | 3 | Resolve a link, request a code, open a link session |
| API keys | 3 | Create, list, revoke |
| Audit | 2 | Search and export |
| Account | 1 | The signed-in member |
The operator operations are served by a separate operator API that no load balancer routes to. Operators reach it by port-forward.
Authentication
Every operation declares its security scheme and the permission it checks. A request that carries both a session cookie and an Authorization header is refused with 401 credentials_invalid.
| Caller | Sends |
|---|---|
| Member or admin, web app | __Host-sft_session cookie, plus X-CSRF-Token on POST, PATCH and DELETE |
| Tenant automation | An API key in Authorization: Bearer, never in a query string or body |
| Recipient or uploader, before verification | The link token in the request body |
| Recipient or uploader, after verification | __Host-sft_link cookie, X-Link-Session naming the session, and X-CSRF-Token |
| Platform operator | A 15-minute operator token in Authorization: Bearer |
Only seven operations are public: the three link steps, the three sign-in steps, and the operator token exchange, whose signed AWS request is itself the credential. No token ever appears in a URL path or query string, because URLs end up in logs and browser history. See Recipient verification for the link flow.
Errors, pagination and idempotency
Errors
Every error is an RFC 9457 problem document (application/problem+json) with a stable machine-readable code and the request_id that also appears in the logs. Clients branch on code and the HTTP status, never on the human-readable text. An item in another tenant returns 404, never 403, so its existence is never revealed. A path ID that can't exist gets the same 404.
Pagination
List operations take cursor and limit (1 to 100, default 25) and return items and next_cursor, which is null on the last page. Cursors are opaque. Lists the requirements already bound, such as the 50 files in a transfer, are returned whole.
Idempotency
Every POST that creates something accepts an Idempotency-Key header. The key is kept for 24 hours with a fingerprint of the method, the path and the body. A retry with the same key and request gets the same status; the same key with a different request gets 409 idempotency_conflict. The service never stores a response body. Calls that grant access, such as download grants and verification codes, are checked and audited every time and never replay. State changes such as send and revoke are safe to retry by design.
Security-critical changes, such as revocations, take effect at once and are then written to the audit archive in both regions. If that write fails, the call returns 503, but the change stands, and a retry completes the record.
Rate limits
A request over a limit gets 429 rate_limited with a Retry-After header in whole seconds. The error never names the key, another tenant or another address. Every operation declares 429, and a lint rule enforces it.
| Limit | Applies to |
|---|---|
| 1,000 API requests per tenant per minute | Every tenant operation, members and API keys together. Revocations never count |
| 60 link-page requests per IP per minute | The three link steps |
| 5 verification codes per email address per hour | Code requests, across all tenants |
| 300 requests per minute per member and per key | Other tenant calls |
| 1 audit export an hour, at most 100,000 events | Each API key |
The WAF adds per-IP limits in front of the API. If the rate-limit counters are unavailable, the link and sign-in steps fail closed with 503, and other calls fall back to a per-pod limiter.
API keys
Tenant API keys (FR-32) serve automation such as creating transfers or exporting the audit log.
- Format.
sft_prd_<secret>_<check>: the environment, 256 random bits, and a checksum, so typos and random strings are rejected without a database lookup. The service stores only a SHA-256 hash, and the full key is shown once. - Created by a tenant admin in the web app only, with a recent sign-in. A key can't create a key.
- Scopes are fixed at creation:
transfers:read,transfers:write,upload_requests:read,upload_requests:writeandaudit:read. - Expiry is 90 days by default and at most 365. A tenant has at most 25 active keys.
- Limits. A key never downloads file contents, unlocks recipients, decides DLP approvals or manages users, policy or keys. Deactivating or demoting its owner revokes it in the same transaction.
- Audited. Every call a key makes, reads included, has its own audit record.
Versioning
The major version is in the path. Within /v1, only backwards-compatible changes are made: new operations, new optional request fields, new response fields and new error codes. Clients ignore response fields they don't know. A breaking change ships only as /v2, which runs alongside /v1 until /v1 is retired. A deprecated operation is marked in the spec, and its responses carry Deprecation and Sunset headers, with at least six months' notice for API-key automation.
Source: API contract, openapi.yaml, ADR-0002, Authentication and authorization, PRD