Forms
/
12 min read
Submit from your own code (UniSubmit)
Send submissions and subscriptions to Zalify Reach from a form you built yourself — a headless site, a native app, a server — with one POST to the UniSubmit endpoint.
UniSubmit is the endpoint behind every Zalify form. Hosted popups and inline forms call it for you; if you built the form yourself — a Next.js storefront, a React Native app, a Shopify Hydrogen site, a script on a server — you call it directly. One POST records a Submission, subscribes the person to a List, or both.
POST https://reach.zalify.com/v1/public/unisubmit
Content-Type: application/json
There is no API key. The endpoint is public by design, the same way the pixel is: your workspace id (wid) and list ids are safe to expose in a browser.
This article is the integration guide. It covers the request, the four ways to use it, what comes back, and the handful of details that only surface once real traffic hits it — several of which we learned the hard way on our own storefronts.
When to use UniSubmit instead of a hosted form
| You have | Use |
|---|---|
| A form you want Zalify to render and style | A popup or inline form — no code |
| A form you already built, in your own markup and framework | UniSubmit — this article |
| Leads arriving from somewhere that isn't a web page (an app, a CRM sync, a trade-show tablet, a partner's system) | UniSubmit, called from your server |
Hosted forms and UniSubmit share one submission pipeline, so everything downstream — the Submissions feed, list membership, Automations — behaves identically.
Before you begin: get a form id and a list id
Connect your own form
In Zalify, open Reach → Forms and choose Connect your own form. Give it a name and, if you're collecting subscribers, pick or create the List it should feed.
Copy the snippet
Zalify creates an API form and shows a UniSubmit snippet with wid, form_key and list_id already filled in. Copy those three values — they're all your code needs.
form_key carries the form id
The JSON field is named form_key for compatibility, but for UniSubmit its value is the API form's id — the UUID Zalify prefilled in your snippet. Create the form in the app first; UniSubmit does not auto-create forms from unknown ids.
Older integrations that send a hand-written string key (newsletter, contact_us) still resolve to the form they were first recorded against, so nothing breaks on upgrade — but start every new form from the app so the id is the one Zalify knows about.
The request
Every request needs wid. Everything else is optional and combines freely.
| Field | Notes | |
|---|---|---|
wid | required | Your workspace id. Prefilled in the snippet. |
identity | object | Who this is. email is the key that resolves to a profile; first_name, last_name and phone enrich it. Required if subscribe is present. |
submission | object | form_key (the API form id) and payload — an object of your own field names and values. Omit for a subscribe-only call. |
subscribe | object | list_id and email_marketing_consent (boolean). Omit for a submit-only call. |
context | object | Where it happened: page_url, referrer, visitor_id, UTM parameters, anything else that helps attribution. |
idempotency_key | string | A key that's stable across retries of the same submission — see Retries and duplicates. |
Submit and subscribe in one call
The shape most forms want — record the answers and add the person to a list:
await fetch("https://reach.zalify.com/v1/public/unisubmit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
wid: "YOUR_WORKSPACE_ID",
identity: {
email: emailInput.value,
first_name: firstNameInput.value,
},
submission: {
form_key: "YOUR_API_FORM_ID",
payload: {
company: companyInput.value,
message: messageInput.value,
},
},
subscribe: {
list_id: "YOUR_LIST_ID",
email_marketing_consent: marketingConsentCheckbox.checked,
},
context: {
page_url: location.href,
visitor_id: window.zalify?.vid,
},
}),
});
Submit only
Leave out subscribe. Use this for contact forms, surveys, and anything where the person hasn't asked to hear from you. identity is optional here — an anonymous submission is fine.
Subscribe only
Leave out submission. This is a pure email capture: newsletter boxes, waitlists. identity.email is required, and this call never creates a Submission record — the person simply joins the list.
Submit now, subscribe elsewhere
If the same person will hit two different forms, send each as its own call with its own form_key. UniSubmit resolves both to one profile through identity.email.
The response
On success Reach answers with a 2xx and:
{ "status": "accepted" }
Check both — response.ok and status === "accepted". Treat anything else as a failure and show the visitor a retry, not a thank-you.
| HTTP | Body | Means |
|---|---|---|
2xx | { "status": "accepted" } | Recorded. |
404 | { "error": "workspace '…' not found" } | Wrong wid. |
422 | Plain text, e.g. missing field 'wid' | The JSON didn't match the schema — a required field is missing or has the wrong type. |
422 | { "error": "…" } | The request parsed but was rejected: an unknown or inactive form id, subscribe without identity.email, or a payload key Reach won't accept (see below). |
A 422 rejects the whole submission
Reach validates every key in payload with the same rule as form keys: ASCII letters, digits and _ - . : only. One key with a space, a slash, or a non-Latin character fails the entire request — nothing is stored, nobody is subscribed. We found this when a Chinese-language lead form's question labels were forwarded verbatim as payload keys. Map your field names to snake_case before sending; keep the human label as a value if you need it.
Call it from your server, not the browser
The snippet Zalify gives you runs in the page, and for a simple newsletter box that's fine. For anything with more than an email in it, put a small route on your own server between the form and UniSubmit. Both of our production storefronts do this, for reasons that have nothing to do with hiding credentials (there are none):
- Validate before it lands in your CRM. A select or radio group is only a suggestion to a browser. Re-check every enum, length and URL on the server so nothing but the options you actually offered reaches Reach.
- Keep spam out of the list. A honeypot field and a time-to-submit check belong on the server, where a bot can't read them. When they trip, return the same success shape you'd return for a real visitor and skip the Reach call — a bot that learns the rule adapts to it.
- Own the response. Your form can return whatever it needs — a reference number, a redirect, a gift code — instead of surfacing Reach's raw envelope to the page.
- Fan out. A Slack notification, an internal email, a second system — all from one place.
A minimal route, in Next.js:
// app/api/lead/route.ts
import { NextResponse } from "next/server";
const UNISUBMIT = "https://reach.zalify.com/v1/public/unisubmit";
const WID = "YOUR_WORKSPACE_ID";
const FORM_ID = "YOUR_API_FORM_ID";
const LIST_ID = "YOUR_LIST_ID";
const ROLES = ["Founder", "Marketing", "Purchasing"] as const;
export async function POST(request: Request) {
const body = await request.json();
// Honeypot: real visitors never see this field.
if (body.website) return NextResponse.json({ ok: true });
const email = String(body.email ?? "").trim().toLowerCase();
const role = ROLES.includes(body.role) ? body.role : "";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || !role) {
return NextResponse.json({ ok: false, error: "invalid" }, { status: 422 });
}
const res = await fetch(UNISUBMIT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
wid: WID,
identity: { email },
submission: { form_key: FORM_ID, payload: { role, company: body.company } },
subscribe: { list_id: LIST_ID, email_marketing_consent: body.marketing === true },
context: {
page_url: body.pageUrl,
referrer: body.referrer,
visitor_id: body.visitorId,
},
idempotency_key: request.headers.get("x-idempotency-key") ?? undefined,
}),
});
const data = await res.json().catch(() => null);
if (!res.ok || data?.status !== "accepted") {
return NextResponse.json({ ok: false, error: "reach" }, { status: 502 });
}
return NextResponse.json({ ok: true });
}
Three things in that route are easy to get wrong and worth reading twice: the honeypot returns success, email_marketing_consent is the checkbox's real value, and visitor_id and the idempotency key are forwarded from the browser rather than invented on the server. The next sections explain why.
Details that matter in production
Consent is a field, not a gate
subscribe.email_marketing_consent must reflect what the person actually ticked. It is tempting to send true whenever you subscribe someone — don't. List membership and marketing consent are separate for a reason: a trade-show lead who declined marketing still belongs on the sales team's list, and Reach records both facts correctly when you send { list_id, email_marketing_consent: false }.
Whether to subscribe at all is your call per form. Our rule of thumb: if the person is asking you for something (a partnership, a quote, a demo), subscribing them to the working list is covered by the consent they gave to be replied to; the marketing flag stays honest on its own.
Keep the pixel and the profile connected
If the Zalify pixel is on the page, window.zalify?.vid is the visitor's id. Send it as context.visitor_id and Reach merges everything that visitor did anonymously — pages viewed, products seen, the campaign that brought them — into the profile UniSubmit just created. Skip it and the profile starts blank.
When you proxy through your server, this is the value that's easiest to lose: read it in the browser, post it to your route, forward it. Same for page_url and referrer — the server only knows its own URL.
Fire the lead event yourself
Hosted forms fire the pixel's form_submitted and lead events for you. UniSubmit doesn't — it's an API, it doesn't know about your page. After your route returns success, call the pixel:
window.zalify?.("track", "lead", { email });
Without this, the submission is in Reach but invisible to ad-platform forwarding and conversion reporting.
Retries and duplicates
idempotency_key is stored as the submission's dedupe id. A second request with the same key inserts nothing and subscribes nobody twice — which is exactly what you want when a visitor's connection drops mid-submit and they tap the button again.
That only works if the key is the same on the retry. Generate it once per form session in the browser (crypto.randomUUID() into sessionStorage), send it with every attempt, and forward it from your server route unchanged. A key generated fresh on the server for each request looks like idempotency and provides none — every retry is a new submission. We shipped exactly that once; the shared helper minted its own UUID and quietly threw away the one the page had sent.
Payload values
Keep payload flat: string, number or boolean values under snake_case keys. Join multi-selects into a string ("Reading Robot, AI Cube") rather than sending an array — every integration we run does this and it renders cleanly in the Submissions feed.
Timeouts
Give the browser-side fetch a timeout (AbortController, ~10–12 s) and a clear message when it fires. A form that hangs with no feedback gets submitted three times.
Test it without writing to your workspace
You want to see the exact body your code sends before a real lead does. Two techniques, neither of which stores anything:
Point the endpoint at a local catcher. A 10-line HTTP server that logs the body and replies { "status": "accepted" }, and a temporary override of the endpoint URL in your code. Submit, read the log, diff it against the shape above. This is how we verified subscribe toggled correctly with the consent checkbox before anything touched production.
Probe the real endpoint with a workspace that doesn't exist. Reach checks wid before anything else, so { "wid": "probe" } returns 404 { "error": "workspace 'probe' not found" } and can't persist. Useful for confirming you can reach the host and for seeing the error envelope your code has to handle.
When you do send a real test, use an address you control and delete the contact afterwards — see Viewing and exporting submissions.
Where it shows up
- The submission appears in Reach → Forms under the API form you connected, alongside hosted-form submissions in the same feed.
- If you sent
subscribe, the person is on the list — with their consent status — and any Automation attached to that list starts. identityfields update the contact's profile in your audience.
Troubleshooting
404 workspace not found. Thewidis wrong. Copy it from the snippet in the app; it isn't the same as your store slug.422with a plain-text message about a missing field. The JSON doesn't match the schema — usuallywidmissing, orsubscribepresent withoutidentity.email.422and the payload looks fine. Check your payload keys against the charset rule above. Spaces and non-Latin characters in a key fail the whole request.accepted, but the person isn't on the list. Confirm you sentsubscribe(submit-only calls never subscribe), and thatlist_idis the list you're looking at.accepted, but the profile has no browsing history.context.visitor_idwasn't sent, or the pixel isn't on the page. See Installing the Zalify pixel.- Duplicate submissions from one visitor. Your idempotency key changes between retries. Generate it once per session, not per request.
- Nothing in ad-platform reports. Fire the
leadpixel event after success; UniSubmit doesn't do it for you.
Related articles
- Connecting a form to a list — choosing the list your API form feeds
- Embed a hosted inline form — if you'd rather Zalify render the form
- Viewing and exporting submissions — where submissions land
- Send conversions to Meta, Google, TikTok & more — forwarding the
leadevent