Quick start for Forms
A form's action attribute and a key. No script tag, no library, no build step, and it keeps working the moment JavaScript does not load at all.
Before you start
A form key, and nowhere else to get one.
Create a form on the project's Forms settings screen: give it a name, "contact" or "newsletter", and it mints a key. That is a different shape from Monitor, where one DSN covers a whole project, and from Flags, where a key belongs to an environment: a Forms key belongs to a single named form, because a form's key sits in the page source of a public site and gets scraped sooner or later. When that happens, revoking it takes down only that one form rather than every form the project has.
It is safe to put in a page anyone can view. The key names one form and lets it be posted to, nothing else, and it is shown once, at creation, then stored only as a hash. Losing it means making a new one and revoking the old, the same as any other key on this platform.
Point the form at it
A plain HTML form, no JavaScript required
The action attribute is the whole integration. Nothing here is fetched, loaded, or run, which is what makes this work for a visitor whose browser never executes a line of the page's script.
<form action="https://vps-7eadf7ac.vps.ovh.us/f/uof_..." method="post">
<input type="text" name="name" placeholder="Name" required>
<input type="email" name="email" placeholder="Email" required>
<textarea name="message" placeholder="Message" required></textarea>
<!-- Honeypot. A person never sees this field, so anything filled into it
marks the submission as automated. Positioned off screen rather than
display:none, so a screen reader does not skip past it and treat it
as hidden from a sighted visitor only. -->
<input type="text" name="_uo_website" autocomplete="off" tabindex="-1"
style="position:absolute;left:-9999px" aria-hidden="true">
<!-- Optional. Stamps when the page finished loading, in milliseconds. A
submission that arrives less than 1.5 seconds later is flagged as
automated. Leave this out and that check simply does not run for
this form; nothing else about the endpoint depends on it. -->
<input type="hidden" name="_uo_started_at" id="uo-started-at">
<script>document.getElementById("uo-started-at").value = Date.now();</script>
<button type="submit">Send</button>
</form>Only the action, the honeypot, and, if you want it, the timing field matter to the endpoint. Replace every visible field with whatever the form actually needs to ask.
Or send JSON, if the form is not plain HTML
The endpoint reads both. A content-type of application/x-www-form-urlencoded is read as form fields; anything containing json is parsed as an object of field names to values.
await fetch("https://vps-7eadf7ac.vps.ovh.us/f/uof_...", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: "Ada Lovelace",
email: "ada@example.com",
message: "Hello.",
}),
});What comes back
ok is true for anything accepted, even a submission the endpoint flagged as automated.
Telling a script it was caught teaches whoever wrote it to try the next thing, and a false positive means telling a real person their enquiry bounced. Both are worse than answering {"ok":true} and sorting it out on the Forms screens instead. The one case that answers a failure is the submission failing to store, because somebody on the other end of that form is waiting for a reply, and saying it worked when it did not is the one thing a form must never do.
Automated submissions are stored, not dropped
Two checks, both optional, and both wrong sometimes.
A filled in _uo_website is certain: a person never sees that field, so a script that fills in every field it finds is the only thing that fills that one in. A submission under 1,500 milliseconds after _uo_started_at is a heuristic: nobody reads a form and completes it that fast. Neither is a hard boundary, and neither is meant to be one. A script written to target this endpoint specifically can leave the honeypot empty and wait out the timer, the same way a determined caller gets past any filter built for the ordinary case. What these catch is the much larger volume that never looked at the page at all.
A flagged submission is still stored in full and shown on a separate Filtered screen rather than the Inbox, and it is never forwarded to an email or webhook destination. It is never discarded, because both checks have false positives, and a person whose real message was flagged has no way to tell you it is missing unless it is still somewhere to be found.
The bounds on a submission
- At most 40 fields. Past that, additional fields are dropped rather than the submission refused, which is what lets a script walking a field name generator cost you nothing worse than a trimmed record.
- A field name longer than 64 characters is truncated. Names become column headings on the Forms screens, and nothing legible is longer than that.
- A value longer than 5,000 characters is truncated, not refused. Losing an entire enquiry over one long message would cost more than keeping the first 5,000 characters of it.
Getting a submission to a person
Stored first, delivered after, because the two must not share a failure.
Add a destination, an email address or an https url, on the project's Delivery screen, either for one form or for every form in the project. A submission is not emailed or posted inside the request that took it: it is stored, a delivery row is queued for each destination, and a separate loop drains that queue. An email provider having a slow minute would otherwise become a form that times out on somebody's phone, and a provider that is down would look like a form that is broken, with nowhere left to retry from once that request had already ended.
A failed attempt is retried with a delay that doubles from a minute up to a six hour ceiling, and it stops after seven attempts, which spans roughly half a day. A destination still refusing after half a day is misconfigured rather than briefly unwell, and every attempt, including the failed ones, is listed on the Delivery screen with why it failed rather than left in a log nobody would go looking for.
Verifying a webhook delivery
Every webhook request carries a signature over the exact bytes it sends.
The body is the submission:
{
"form": "contact",
"submissionId": "3f1a6e2c-9b7d-4e3a-9d2f-2a6c1e9b7d4e",
"receivedAt": "2026-09-03T14:02:11Z",
"fields": {
"email": "ada@example.com",
"message": "Hello.",
"name": "Ada Lovelace"
}
}The x-unrealops-signature header is shaped t=<unix seconds>,v1=<hex>. The hex value is an HMAC-SHA256 of the exact string <timestamp>.<raw body>, keyed with the signing secret shown once when the destination was created. The timestamp is inside the signed string as well as sitting next to it in the header, on purpose: a request somebody captured and replays later, with the clock moved on, carries a signature that no longer matches once you check the timestamp against it, so rejecting anything too old is a real check rather than a value taken on trust.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(signingSecret, header, rawBody) {
const [tPart, vPart] = header.split(",");
const timestamp = tPart.slice(2);
const given = vPart.slice(3);
const expected = createHmac("sha256", signingSecret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
if (given.length !== expected.length) return false;
if (!timingSafeEqual(Buffer.from(given), Buffer.from(expected))) return false;
// The timestamp is inside the signed string, not just next to it, so a
// captured delivery cannot be replayed later with the clock moved on:
// changing the timestamp invalidates the signature. Reject anything too
// old and this line is a real check rather than a value taken on trust.
return Date.now() / 1000 - Number(timestamp) < 300;
}Verify against the exact bytes the request arrived with, not a re-serialized copy of the parsed JSON. Re-serializing is the usual way to make this check fail on a payload that was never tampered with at all.
What to do next
- Add a destination under Delivery once you have watched a submission arrive in the Inbox. Nothing is emailed or posted anywhere until you do.
- Shipping a feature flag alongside this form? The Flags quick start is the other endpoint this platform runs.