Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,27 @@ FORMS_DATABASE_URL=postgresql+psycopg://forms:forms@localhost:5432/forms
# production lets anyone who can create an endpoint reach your internal network.
# FORMS_ALLOW_PRIVATE_WEBHOOK_TARGETS=false

# --- submission retrieval and retention ---------------------------------------

# How many days a stored submission is kept before an operator's cleanup command
# may delete it. 0, the default, keeps submissions indefinitely.
#
# Nothing is ever deleted automatically. Setting this only makes older
# submissions eligible; the sweep is a command you run:
#
# python -m hymical_forms.cli cleanup-submissions --dry-run
# python -m hymical_forms.cli cleanup-submissions
#
# A submission whose webhook delivery is still pending, processing, or failed and
# therefore replayable is never deleted, however old it is, because the payload
# is built from the submission at the moment it is sent.
# FORMS_SUBMISSION_RETENTION_DAYS=0

# Largest number of submissions one export may return. A filter matching more
# than this is refused rather than silently truncated, so an export is either
# complete or an error. Narrow the range and export it in parts.
# FORMS_EXPORT_MAX_SUBMISSIONS=10000

# --- public ingestion rate limiting -------------------------------------------
#
# These apply only to POST /f/{endpoint_id}. Management routes and /health are
Expand Down
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ crash cannot lose work the service already acknowledged.
- **Management API keys**, created by an operator CLI and stored only as digests
- **Endpoint and delivery operations**: reconfigure, inspect attempt history,
replay a failed delivery
- **Submission retrieval and export**: browse, filter by endpoint and time, read
one back, export a filtered range as JSON or CSV
- **Retention cleanup** driven by an operator command, which never deletes a
submission a delivery could still need
- **Distributed rate limiting** on public ingestion, per source address and per
endpoint, shared across API processes
- **Alembic migrations** with a startup revision check and model drift tests
Expand Down Expand Up @@ -142,10 +146,11 @@ Full walkthrough:
| Section | Covers |
| --- | --- |
| [Getting Started](https://hymical.github.io/forms/getting-started/installation/) | Install, configure, migrate, first submission |
| [Guides](https://hymical.github.io/forms/guides/form-ingestion/) | Ingestion, idempotency, webhooks, rate limiting, endpoints, replay |
| [Guides](https://hymical.github.io/forms/guides/form-ingestion/) | Ingestion, idempotency, webhooks, rate limiting, endpoints, replay, submissions, export |
| [API Reference](https://hymical.github.io/forms/api/authentication/) | Every route, its parameters and responses, and the complete error table |
| [Operations](https://hymical.github.io/forms/operations/worker/) | Worker, migrations, reverse proxy, every configuration variable |
| [Operations](https://hymical.github.io/forms/operations/worker/) | Worker, migrations, retention, reverse proxy, every configuration variable |
| [Architecture](https://hymical.github.io/forms/architecture/overview/) | Transactional outbox, delivery semantics, concurrency, security |
| [Data handling](https://hymical.github.io/forms/reference/data-handling/) | Where submitted values go, and where they never go |
| [Limitations](https://hymical.github.io/forms/reference/limitations/) | An honest list of what this build does not do yet |

## Project status
Expand All @@ -165,18 +170,24 @@ concurrency.
| Delivery inspection and manual replay | Implemented |
| Public ingestion rate limiting | Implemented |
| Schema migrations | Implemented |
| Submission retrieval, export, retention | **Not implemented** |
| Submission retrieval and filtering | Implemented |
| Submission export, JSON and CSV | Implemented |
| Retention cleanup, operator-run | Implemented |
| Scheduled retention | **Not implemented** |
| Submission search | **Not implemented** |
| Endpoint deletion | **Not implemented** |
| Spam handling, CAPTCHA | **Not implemented** |
| Dashboards | **Not implemented** |

Three things are worth knowing before you deploy it:
Four things are worth knowing before you deploy it:

- **Delivery is at-least-once, not exactly-once.** Deduplicate on the submission
`id` in the signed payload.
- **Rate limiting is traffic protection, not spam protection.** It bounds volume
and has no opinion about content.
- **SSRF protection is partial.** Webhook hostnames are not resolved.
- **Nothing is deleted until you delete it.** Retention is a command an operator
runs, and it never removes a submission a delivery could still need.

The full list is in
[Limitations](https://hymical.github.io/forms/reference/limitations/).
Expand Down
11 changes: 7 additions & 4 deletions docs/api/deliveries.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ Paging works exactly as it does for endpoints. See
| Field | Meaning |
| --- | --- |
| `id` | Delivery identifier |
| `submission_id` | The submission this delivery carries |
| `endpoint_id` | The endpoint that submission was addressed to |
| `submission_id` | The submission this delivery carries, or `null` once retention has removed it |
| `endpoint_id` | The endpoint the submission was addressed to, recorded on the delivery |
| `state` | `pending`, `processing`, `delivered` or `failed` |
| `destination_url` | The URL snapshotted when the submission was accepted |
| `attempt_count` | Every request ever made for this delivery |
Expand Down Expand Up @@ -78,8 +78,10 @@ Attempts are ordered by `attempt_number`, ascending.
!!! note "What is never in these responses"

Submitted field values, the snapshotted signing secret, the request headers,
and the response body. The first three are never returned by any route; the
last is never stored.
and the response body. The signing secret and the headers are never returned
by any route, and the response body is never stored. Field values are
returned only by the authenticated
[submission routes](submission-management.md).

## `POST /deliveries/{delivery_id}/replay`

Expand Down Expand Up @@ -107,4 +109,5 @@ and leaves `attempt_count` and every historical attempt row untouched.

- [Delivery inspection and replay](../guides/delivery-replay.md)
- [Webhook delivery](../guides/webhooks.md) for the retry schedule
- [Submission Management](submission-management.md) for what a delivery carried
- [Errors](errors.md) for the full error table
20 changes: 19 additions & 1 deletion docs/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ names or SQL.
| 404 | `invalid_endpoint_id` | Submission path is not a well-formed endpoint ID |
| 404 | `endpoint_not_found` | Endpoint ID is well formed but no such endpoint exists |
| 404 | `delivery_not_found` | No delivery with that ID exists |
| 404 | `submission_not_found` | No submission with that ID exists |
| 404 | `not_found` | Unknown path |
| 405 | `method_not_allowed` | Wrong method for a known path |
| 409 | `endpoint_inactive` | Endpoint exists but is not accepting submissions |
Expand All @@ -40,6 +41,9 @@ names or SQL.
| 422 | `invalid_endpoint_id` | Endpoint ID in a request body breaks the ID rules |
| 422 | `invalid_request` | Request body or query parameters failed schema validation |
| 422 | `invalid_webhook_url` | Webhook destination is malformed or not permitted |
| 422 | `invalid_time_range` | `received_after` is not strictly earlier than `received_before` |
| 422 | `export_too_large` | An export matches more than `FORMS_EXPORT_MAX_SUBMISSIONS` |
| 422 | `unsupported_export_format` | Export `format` is not `json` or `csv` |
| 422 | `file_upload_not_supported` | A multipart part carried a file |
| 422 | ingestion rule codes | See below |
| 429 | `rate_limit_exceeded` | A public ingestion rate limit was exhausted |
Expand Down Expand Up @@ -86,6 +90,20 @@ not permitted, which needs a permission model this build does not have.
alike, so that a guesser cannot sort their attempts into "nearly right" and
"wrong". The credential a request sent is never echoed back in an error.

### `export_too_large` is a `422`, not a `413`

The request is well formed, and what has to change is the filter, which is part
of the request. It is refused rather than truncated so that an export is either
everything that matched or an error, never a quietly incomplete file. See
[Exporting submissions](../guides/exporting-submissions.md#size-limit).

### `invalid_time_range` is refused, an empty match is not

A filter matching nothing is an empty page, because that is a fact about the
data. A range where `received_after` is on or after `received_before` can never
match anything at all, which is a mistake in the request; answering it with an
empty page would hide the mistake.

### `storage_unavailable` rather than `500`

A database failure returns `503`, because the request itself was fine and
Expand All @@ -101,5 +119,5 @@ published.

## Related

- [Submissions](submissions.md), [Endpoints](endpoints.md) and [Deliveries](deliveries.md) for which codes each route can return
- [Submissions](submissions.md), [Endpoints](endpoints.md), [Deliveries](deliveries.md) and [Submission Management](submission-management.md) for which codes each route can return
- [Authentication](authentication.md) for the `401` cases
157 changes: 157 additions & 0 deletions docs/api/submission-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Submission Management API

Every route on this page requires a management API key. See
[Authentication](authentication.md).

These are the only routes that return what somebody typed into a form. Public
ingestion answers with an acknowledgement, and the delivery routes report
operational state. Reading a submission back is authenticated, always.

For what these routes are for, see
[Browsing submissions](../guides/submission-management.md) and
[Exporting submissions](../guides/exporting-submissions.md).

## `GET /submissions`

Lists stored submissions, newest first.

A listing is metadata. It reports how many values a submission carried, never
what they were, so walking a busy endpoint does not spread form content across
pages nobody asked for. Use the detail route or an export for the values.

**Query parameters**

| Parameter | Default | Meaning |
| --- | --- | --- |
| `endpoint_id` | none | Only submissions for one endpoint |
| `received_after` | none | Only submissions received **strictly after** this instant |
| `received_before` | none | Only submissions received **strictly before** this instant |
| `limit` | `50` | Page size, 1 to 100 |
| `cursor` | none | The previous page's `next_cursor` |

Both time bounds are ISO 8601 and both are exclusive: a submission received at
exactly the given instant is not returned by either. That is what lets you page
forward through a range by passing the last timestamp you saw back as
`received_after` without re-reading the row you took it from.

Paging works exactly as it does for endpoints. See
[Pagination](endpoints.md#pagination).

**Item fields**

| Field | Meaning |
| --- | --- |
| `id` | Submission identifier |
| `endpoint_id` | The endpoint the submission was addressed to |
| `received_at` | When the API accepted the body |
| `field_count` | Number of name/value pairs, counting a repeated field once per value |
| `idempotent` | Whether it was sent with an `Idempotency-Key` |
| `delivery` | The webhook delivery it owes, or `null` |

`delivery`, when present, carries `id`, `state` and `attempt_count`. A submission
owes at most one delivery.

**Responses**

| Status | Code | Cause |
| --- | --- | --- |
| `200` | | A page of submissions |
| `401` | `authentication_required`, `invalid_api_key` | Credential missing or unusable |
| `422` | `invalid_time_range` | `received_after` is not strictly earlier than `received_before` |
| `422` | `invalid_cursor` | The cursor does not continue from a known row |
| `422` | `invalid_request` | Unparseable timestamp, or `limit` outside 1 to 100 |
| `503` | `storage_unavailable` | The database could not be reached |

A filter that matches nothing is not an error. It is a filter that selected no
rows, and the answer is an empty page. A range that *cannot* match anything, such
as `received_after` on or later than `received_before`, is refused instead:
that is a mistake in the request rather than a fact about the data.

## `GET /submissions/{submission_id}`

One submission, including the values it carried.

**Fields**

Everything a listing item carries, plus:

| Field | Meaning |
| --- | --- |
| `fields` | The submitted field names and their ordered values |

`fields` is exactly what was stored, and every value is a list:

```json
{
"email": ["dev@example.com"],
"topics": ["billing", "api"]
}
```

A field submitted once is a one-element list rather than a bare string, and a
repeated field keeps its values in the order they were sent. That is the same
shape the signed webhook payload uses, so a receiver and an operator see the same
thing.

**Responses**

| Status | Code | Cause |
| --- | --- | --- |
| `200` | | The submission and its fields |
| `401` | `authentication_required`, `invalid_api_key` | Credential missing or unusable |
| `404` | `submission_not_found` | No submission with that ID exists |
| `503` | `storage_unavailable` | The database could not be reached |

A submission that [retention](../operations/retention.md) has deleted answers
`404`, the same as one that never existed. From outside, both mean this service
does not hold it.

!!! note "What is never in these responses"

The payload fingerprint, the `Idempotency-Key` the submission was sent with,
any webhook signing secret, and any management credential. The fingerprint is
an internal detail of how a retry is recognised. The idempotency key is a
secret in practice: anyone holding it can resolve it to a submission through
the public ingestion route.

## `GET /submissions/export`

Exports the submissions a filter matches, as a downloadable file.

**Query parameters**

The same `endpoint_id`, `received_after` and `received_before` as the listing,
with the same exclusive bounds, plus:

| Parameter | Default | Meaning |
| --- | --- | --- |
| `format` | `json` | `json` or `csv` |

There is no `limit` and no `cursor`. An export is the whole filtered set or an
error, never a page.

**Responses**

| Status | Code | Cause |
| --- | --- | --- |
| `200` | | The export, as an attachment |
| `401` | `authentication_required`, `invalid_api_key` | Credential missing or unusable |
| `422` | `export_too_large` | The filter matches more than `FORMS_EXPORT_MAX_SUBMISSIONS` |
| `422` | `unsupported_export_format` | `format` is not `json` or `csv` |
| `422` | `invalid_time_range` | `received_after` is not strictly earlier than `received_before` |
| `503` | `storage_unavailable` | The database could not be reached |

Both formats are sent with `Content-Disposition: attachment` and a generated
filename such as `hymical-submissions-2026-08-25.json`. Nothing a caller supplied
reaches the filename.

The format and the size limit are described in
[Exporting submissions](../guides/exporting-submissions.md).

## Related

- [Browsing submissions](../guides/submission-management.md)
- [Exporting submissions](../guides/exporting-submissions.md)
- [Retention](../operations/retention.md)
- [Data handling](../reference/data-handling.md)
- [Errors](errors.md) for the full error table
11 changes: 8 additions & 3 deletions docs/api/submissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,17 @@ whether to restart the process. It is not a readiness check.

## Reading submissions back

There is **no route that returns a stored submission**. Submitted values are not
exposed by any endpoint, including the delivery views. Retrieval, export and
retention are not implemented. See [Limitations](../reference/limitations.md).
**No public route returns a stored submission.** This one answers with an
acknowledgement, and the delivery views carry no submitted values either.

Reading a submission back is authenticated, on the routes in
[Submission Management](submission-management.md). See
[Data handling](../reference/data-handling.md) for everywhere a submitted value
does and does not go.

## Related

- [Form ingestion](../guides/form-ingestion.md) for content types, limits and repeated fields
- [Idempotency](../guides/idempotency.md)
- [Submission Management](submission-management.md) for reading submissions back
- [Errors](errors.md) for the full error table
6 changes: 4 additions & 2 deletions docs/architecture/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,10 @@ it is optional rather than required.
- The credential a request sent is never echoed back.
- An idempotency conflict says the content differs. It never describes the
earlier submission, so a key cannot be used to read back somebody else's form.
- Submitted field values are never returned by any route, including the delivery
views.
- Submitted field values are never returned by a public route, and never by a
delivery view. The only routes that return them are the authenticated
submission detail and export routes, where returning them is the request. See
[Data handling](../reference/data-handling.md).

## SSRF guardrails

Expand Down
11 changes: 8 additions & 3 deletions docs/guides/delivery-replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,15 @@ An unknown `state` is refused with `422 invalid_request`. An `endpoint_id` that
matches nothing is not an error: it is a filter that selected no rows, and the
answer is an empty page.

!!! note "Submitted field values are never returned"
!!! note "A delivery view carries no submitted field values"

Not in the listing and not in the detail. There is no route that reads a
submission back, and a delivery view is not a way around that.
Not in the listing and not in the detail. To see what a delivery was
carrying, take its `submission_id` to
[`GET /submissions/{id}`](../api/submission-management.md).

`submission_id` is `null` when [retention](../operations/retention.md) has removed
the submission. That only ever happens to a delivery that already succeeded: a
failed delivery keeps its submission precisely so that it stays replayable.

## One delivery, with its attempt history

Expand Down
Loading
Loading