Build the form in your own markup, with your own design and validation, and post it straight to Origami. You keep the front end; Origami keeps the submissions, notifications, file storage, exports and webhooks.
Three things, all of them in the form builder.
Open your form and look at the Share & embed panel. The hosted link ends
in the slug: in origami.arcticleaf.com/f/w8ne32s3n the slug is w8ne32s3n. That same
path accepts your posts, and /embed/w8ne32s3n behaves identically on POST, so
integrations already pointed at the embed path keep working.
Every field has a data key, and that key, not the label, is the name you post.
You'll find it on each field card in the builder and again on the field's edit page as
Data key. A field labelled "What are you hoping to solve?" has the key
what_are_you_hoping_to_solve.
A key is set once, when the field is created, and renaming the label afterwards leaves it alone. Copy the key from the builder rather than deriving it from the current label.
Post application/x-www-form-urlencoded or multipart/form-data,
whatever a normal browser form sends. File uploads must be multipart. A JSON request body is
not accepted; ask for a JSON response with an Accept header instead.
Three ways in. They're alternatives, not steps: choose on how much control you need over what the visitor sees next, and whether you have a secret to protect.
The browser posts the form the way forms have always worked.
Post from the page, read a real answer, never navigate away.
Your backend receives the submission and forwards it.
Point a normal form at the endpoint. No JavaScript, nothing to deploy.
<form method="post" action="https://origami.arcticleaf.com/f/w8ne32s3n">
<label for="full_name">Full name</label>
<input id="full_name" name="full_name" type="text" required>
<label for="work_email">Work email</label>
<input id="work_email" name="work_email" type="email" required>
<label for="message">What are you hoping to solve?</label>
<textarea id="message" name="what_are_you_hoping_to_solve"></textarea>
<button type="submit">Send</button>
</form>
The id is yours to choose; the name has to be the Origami key. Add
enctype="multipart/form-data" to the form tag if any field is a file upload.
By default a successful post lands on Origami's thank-you page. Under Form settings → Redirect URL after submit, set a page on your own site and Origami sends them there instead.
If a required field is missing or a value is malformed, the visitor lands on an Origami-hosted copy of your form showing the error, not on your page. Mirror the form's required fields and types in your markup so that never happens.
Add one header and the endpoint answers JSON instead of a page. Cross-origin requests are allowed, so this works from any domain with no backend of your own.
const res = await fetch("https://origami.arcticleaf.com/f/w8ne32s3n", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form), // or a URLSearchParams
});
const result = await res.json();
if (result.ok) {
// Origami stored it. Follow the form's Redirect URL if it has one.
if (result.redirect) location.href = result.redirect;
else showThanks();
} else {
// result.field is the data key of the offending input, when there is one.
showError(result.error, result.field);
}
Every JSON response is one of two shapes:
// stored
{ "ok": true, "redirect": "https://yoursite.com/thanks" | null }
// rejected
{ "ok": false, "error": "\"Budget\" has an invalid value.", "field": "budget" }
A form with a Redirect URL answers 303 to a browser, but JSON callers get
ok: true and the target in redirect instead. A cross-origin 303
would send fetch() chasing a page it isn't allowed to read.
Send the body as FormData or URLSearchParams and don't set a
Content-Type yourself. Accept and a form content-type are
CORS-safelisted, so the request goes straight through without a preflight; if you do add a
custom header, the endpoint answers OPTIONS correctly anyway.
Worth the extra hop when you have something to protect: a bot-verification secret, an API key, your own audit log.
// 1. Your own checks first — Turnstile, reCAPTCHA, rate limits.
const submitted = await request.json();
// 2. Build a normal form body, keyed by Origami's data keys.
const body = new FormData();
body.append("full_name", submitted.fullName);
body.append("work_email", submitted.workEmail);
body.append("what_are_you_hoping_to_solve", submitted.message);
// Deliberately no _ts and no _website — see "The two spam fields" below.
const res = await fetch("https://origami.arcticleaf.com/f/w8ne32s3n", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = await res.json();
if (!result.ok) {
console.error("Origami rejected the submission:", result.error, result.field);
return Response.json({ error: result.error }, { status: 502 });
}
return Response.json({ success: true });
Ask for JSON here too. Without the Accept header you get HTML back, a form with a
Redirect URL answers 303, and fetch follows it to your own thank-you
page and reports whatever that returns. If you must stay on HTML, set
redirect: "manual" and treat a 3xx as success.
Leave optional fields out rather than appending empty strings, and drop a value the field type can't accept instead of letting one optional answer fail the whole submission.
What each field type expects, and what ends up stored. Anything posted under a key Origami doesn't recognise is dropped without a word.
| Field type | What to send | Watch for |
|---|---|---|
| Short text, Long text, URL, Phone | The string | Stored as typed |
| A valid address | The first email field on the form becomes the submitter: it's the reply-to on your notification and where the auto-reply goes | |
| Number | Something Number() can parse | (204) 555-0134 is rejected outright. Strip the formatting, or use a Phone field |
| Date | YYYY-MM-DD | The same format a native date input posts |
| Dropdown, Multiple choice | Exactly one configured option | Character for character, including case and spacing. Editing the options invalidates values your form still sends |
| Checkboxes (with options) | Repeat the key once per selection | Arrives as one value: Design, SEO |
| Single checkbox | Any non-empty value, or omit it | Present becomes yes, absent becomes no |
| File upload | Multipart; repeat the key for several files | 25 MB per file, counted against the workspace's storage |
| Heading, Paragraph, Page break | Nothing | Layout only, they hold no data |
A field set to appear only when another answer matches is checked on the server too. Send a value for a field whose condition isn't met and it's discarded, so replicate the same show and hide rules in your form or those answers quietly go missing.
Page breaks are a front-end nicety. The endpoint takes every field in a single request, however many pages the Origami version shows.
Origami's hosted form ships two anti-bot fields. Both are optional for you, but sending them wrongly makes submissions vanish.
| Field | What it does | What you should do |
|---|---|---|
_website |
A honeypot. If it arrives with anything in it, the submission is dropped and the sender is shown success anyway. | Leave it out, or include it as a hidden field kept empty. |
_ts |
Unix seconds from when the page rendered. If the gap to submit is under the form's minimum fill time, the submission is dropped, again with a fake success. | Leave it out entirely. Only send it if you set it when the page loaded. |
Both traps answer exactly like a success, in HTML and in JSON, because telling a bot it was
caught teaches it how to get through. That's precisely why a server-side integration should
omit _ts: a fresh timestamp from your backend looks like an instant fill, and
you'd drop real submissions with no error anywhere to find.
With Accept: application/json you get the JSON shapes above. Without it, every
response is HTML meant for a browser and you read the status code.
| Status | JSON | Meaning |
|---|---|---|
| 200 | ok: true | Stored. redirect carries the form's Redirect URL, or null. |
| 303 | — | HTML only: stored, and the Redirect URL is in the Location header. |
| 400 | ok: false | Rejected. error says why and field names the input. Also what a JSON request body gets. |
| 403 | closed: true | The form is closed or over its monthly limit. In HTML this is a 200 with a friendly page instead. |
| 404 | ok: false | No form with that slug, or it's archived. |
| 405 | — | Wrong method. The endpoint takes GET, POST and OPTIONS. |
| 500 | ok: false | An uploaded file couldn't be saved. Nothing was stored; safe to retry. |
| 503 | ok: false | The workspace is out of file storage. Retrying won't help until space is freed. |
A closed form, a form at its monthly limit and both spam traps all answer 200 with a friendly page. JSON mode separates the first two out as a 403; the traps stay indistinguishable on purpose. If a submission matters, confirm it with a webhook.
Once a submission is stored, four ways to reach it, one of them built for your code.
Add an endpoint under Webhooks in the builder and Origami posts JSON to it on every submission. Each endpoint gets its own secret and the body is signed, so you can prove the call came from Origami.
x-origami-event: submission.created
x-origami-signature: sha256=<hmac-sha256 of the raw body, lowercase hex>
{
"event": "submission.created",
"form": { "id": "frm_…", "slug": "w8ne32s3n", "title": "New Sales Request" },
"submission": {
"id": "sub_…",
"submitted_at": 1786980689,
"data": { "full_name": "Ada Lovelace", "work_email": "ada@example.com" },
"files": [{ "field": "attachments", "filename": "brief.pdf",
"size": 18244, "url": "https://origami.arcticleaf.com/files/fil_…" }]
}
}
Verify the signature over the raw body before parsing, with a constant-time
comparison. Two things to plan for: delivery is attempted once, with a
ten-second timeout and no automatic retry, so keep your handler fast and idempotent; and the
url on a file needs a signed-in Origami session with access to that form. It is
not a public download link.
Six things worth confirming with a real submission.
name matches a data key in the builder, copied not guessed. Unknown keys are dropped in silence._ts is sent from a server, and _website is absent or empty.Reply to your welcome email with the form slug and the exact values you're posting, and we'll tell you which field is unhappy.