Reference
FORX documentation
Create an endpoint, point a form at it, and FORX validates, stores, and forwards every submission. No SDK.
Quickstart
Four steps. Replace YOUR_ENDPOINT with the id from the dashboard.
- Create an account.
- Create an endpoint in the dashboard.
- Copy that URL. It looks like
https://forx.voryn.xyz/f/YOUR_ENDPOINT. - Paste it into your form, fetch call, or cURL command.
<!-- Replace YOUR_ENDPOINT with the id from your dashboard -->
<form action="https://forx.voryn.xyz/f/YOUR_ENDPOINT" method="POST">
<input name="email" type="email" required />
<textarea name="message"></textarea>
<input type="hidden" name="_gotcha" value="" />
<button type="submit">Send</button>
</form>await fetch("https://forx.voryn.xyz/f/YOUR_ENDPOINT", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "dev@example.com",
message: "Hello"
})
})curl -X POST "https://forx.voryn.xyz/f/YOUR_ENDPOINT" \
-H "Content-Type: application/json" \
-d '{"email":"dev@example.com","message":"Hello"}'Demo
https://forx.voryn.xyz/f/demo is a reserved public endpoint. It accepts email and message, returns a real 201 payload, and does not write into a workspace or send email.
await fetch("https://forx.voryn.xyz/f/demo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "you@example.com",
message: "Hello from the docs"
})
}) demo is reserved. You cannot create an endpoint with that id. It is rate limited like any other public endpoint.
Endpoints
Every endpoint follows the same shape: {origin}/f/{id}. The id is generated when you create the endpoint and never changes.
POST/f/:idSubmit a form or JSON payloadGET/f/:idEndpoint health and accepted fieldsHTML forms, JSON, URL-encoded fields, and multipart form data are accepted. Browser requests are CORS-enabled, including preflight.
Add a hidden honeypot field named _gotcha and leave it empty. Bots that fill it get 202 and the submission is not stored or delivered.
Public endpoints are rate limited. Treat 429 rate_limited as a signal to retry later.
Optionally lock an endpoint to one domain in endpoint settings. Browser requests are checked against Origin or Referer. Leave it blank to allow any origin. Server-to-server requests without those headers still work.
Payloads
Successful submissions return the stored event so you can confirm the shape immediately.
{
"id": "evt_01H8XA",
"event": "user.submit",
"endpoint": "contact-form",
"created_at": "2024-03-12T14:22:01Z",
"data": {
"email": "dev@example.com",
"message": "Hello from FORX"
}
}Custom redirects
After a successful HTML form submission, add a hidden _next field to send the visitor to your thank-you page instead of the JSON response.
<form action="https://forx.voryn.xyz/f/YOUR_ENDPOINT" method="POST">
<input name="email" type="email" required />
<textarea name="message"></textarea>
<input type="hidden" name="_next" value="https://yoursite.com/thank-you" />
<button type="submit">Send</button>
</form> Use a full https:// URL, or a root-relative path such as /thank-you. Root-relative paths redirect on the FORX domain. Redirects apply to browser form submissions only. JSON requests still get 201 Created.
<form action="https://forx.voryn.xyz/f/YOUR_ENDPOINT?_next=https%3A%2F%2Fyoursite.com%2Fthank-you" method="POST">
<input name="email" type="email" required />
<button type="submit">Send</button>
</form>If both the hidden field and the query param are present, the hidden field wins.
Webhooks
FORX POSTs a signed JSON body to each destination. The header is X-Forx-Signature: sha256=<hex>. Verify it with the secret shown when you create the destination. Compare against the raw body, not a re-serialized object.
import { createHmac, timingSafeEqual } from "node:crypto"
export function verifyForxSignature(rawBody: string, header: string | null) {
if (!header?.startsWith("sha256=")) return false
const expected = createHmac("sha256", process.env.FORX_WEBHOOK_SECRET!)
.update(rawBody)
.digest("hex")
const actual = header.slice("sha256=".length)
if (actual.length !== expected.length) return false
return timingSafeEqual(Buffer.from(actual), Buffer.from(expected))
}export async function POST(request: Request) {
const rawBody = await request.text()
const signature = request.headers.get("X-Forx-Signature")
if (!verifyForxSignature(rawBody, signature)) {
return Response.json({ error: "invalid_signature" }, { status: 401 })
}
const event = JSON.parse(rawBody)
if (event.event === "user.submit") {
await saveSubmission(event.data)
}
return Response.json({ received: true })
} Failed deliveries retry up to 3 times. Check X-Forx-Event-Id if you need to ignore duplicates.
Billing
Free is for testing. Starter is the plan for a live form. Pro adds capacity and priority support.
Plan limits are enforced before new resources or submissions are accepted.
Troubleshooting
Submission accepted but no email: check notification settings, the verified sending domain, and the delivery status on that submission. Webhook URLs must be public HTTPS and should return 2xx quickly. FORX retries transient failures and stores the last HTTP status.
Browser forms can use _next for a thank-you redirect. JSON requests always get the event response.
Homepage demo failing: POST to /f/demo with both email and message. That id is reserved and will not appear in your dashboard.
Errors
403domain_not_allowedOrigin does not match the allowed domain403endpoint_pausedEndpoint is paused404endpoint_not_foundNo endpoint exists for that id413payload_too_largeBody is over 1 MB422invalid_payloadRequired fields missing or malformed422invalid_redirect_next is not an http(s) URL or root-relative path429rate_limitedToo many submissions; retry later503storage_unavailableSubmission could not be saved