Landing Pages API
A landing page is a public page your team builds in the Joryio editor (drag & drop or HTML), publishes to a URL, and optionally puts a form on. A submitted form identifies the person, writes attributes and list subscriptions to their profile, and records the events Landing Page Viewed and Landing Page Form Submitted, which segments, funnels, journeys and triggered campaigns can use like any other event.
All endpoints on this page are relative to the base URL: https://api-eu1.joryio.com/api - see API Overview.
Permissions: landing_pages:read, landing_pages:write, landing_pages:publish (see API Keys).
Where a page lives
A published page is served at <pages host>/<workspace handle>/<slug>. The workspace handle is the workspace's own id - opaque, permanent, never chosen or renamed (https://<workspace id>.eu1.jrypages.com/<slug> on the pages domain); the slug is unique per workspace and derived from the page name unless you set it. A workspace can serve its pages on a subdomain of its own instead (https://pages.yourbrand.com/<slug>) - see Use your own domain. Every page also has a preview link (?preview=<signature>, from previewUrl): the current draft, viewable without a Joryio login by anyone holding the link, never cached and never indexed; it stops working when the page is unpublished.
The form schema
form is what the public submit route validates against. The page markup only carries the field names.
{
"fields": [
{ "key": "email", "type": "email", "label": "Email", "required": true },
{ "key": "first_name", "type": "text", "label": "First name", "attribute": "firstName" },
{ "key": "size", "type": "radio", "options": [{ "label": "S", "value": "s" }, { "label": "M", "value": "m" }] },
{ "key": "marketing", "type": "checkbox", "label": "Email me offers", "lists": ["<listId>"] },
{ "key": "lists", "type": "subscriptions", "lists": ["<listId>", "<listId>"] },
{ "key": "src", "type": "hidden", "value": "utm_source" }
],
"submit": { "action": "message", "message": "Thanks - check your inbox." },
"identity": "email"
}
| Type | Writes | Notes |
|---|---|---|
email, phone | the contact's email / phone | at most one of each; phone with lists subscribes those lists |
text, number, date, dropdown, radio | the attribute named by attribute (default: the key) | dropdown and radio need options; radio is always required |
checkbox | a boolean attribute; with lists, subscribes when ticked | consent boxes are never pre-checked |
checkbox_group | an array attribute | options required |
subscriptions | subscribes the ticked lists | lists required |
hidden | an attribute from a fixed value, or utm_source, utm_medium, utm_campaign, utm_content, utm_term, referrer, page |
submit.eventName (optional) fires that custom event on the person on top of the platform event, with the submitted values as properties; submit.setAttributes (optional, { "lead_source": "spring_sale" }) writes fixed attribute values on every submission. Both can also be set on the submit button in the editor. submit.action is message (show message in place) or redirect (to url, https only). A form needs an email or phone field so the person can be identified; a link from a Joryio message can carry a person token instead (planned).
Endpoints
| Method | Path | Permission | What it does |
|---|---|---|---|
GET | /landing-pages | landing_pages:read | Paged list ({ data, pagination }) with status, stats, who created and last edited, and the public URL. ?templates=true lists templates (yours, then built-in); filters ?status= (comma-separated), ?q=, ?tags=, ?createdBy=, ?editedBy=; ordering across the whole list with ?sortBy= (name, status, views, submissions, createdAt, editedAt, publishedAt) and ?sortOrder=; ?limit= (max 100), ?offset= |
GET | /landing-pages/:id/history | landing_pages:read | Audit history of one page, newest first: create, update, publish, take-down and delete with the actor. ?limit= 1-200 |
POST | /landing-pages | landing_pages:write | Create a draft: name, slug, mode (dnd or html), html, css, grapejsData, form, seo (title, description, faviconUrl, ogImageUrl, noIndex), language, dir, tags, sdkEnabled, personalized |
GET | /landing-pages/:id | landing_pages:read | One page in full, with publicUrl and previewUrl |
PUT | /landing-pages/:id | landing_pages:write | Update; the live page changes only on the next publish |
DELETE | /landing-pages/:id | landing_pages:write | Delete a draft (unpublish first) |
POST | /landing-pages/:id/duplicate | landing_pages:write | Copy a page or create one from a template; { "asTemplate": true } saves the copy as a template |
POST | /landing-pages/:id/publish | landing_pages:publish | Freeze the sanitized document and serve it |
POST | /landing-pages/:id/unpublish | landing_pages:publish | Take it down; the signing token rotates |
GET | /landing-pages/:id/preview | landing_pages:read | The draft rendered as the live page (HTML); its posts are discarded |
GET | /landing-pages/:id/submissions | landing_pages:read | The submissions ledger, newest first; personal data masked without pii:view. ?limit= (max 200), ?before= |
GET | /landing-pages/:id/submissions.csv | landing_pages:read | Every submission as a streamed CSV, oldest first: submitted_at, one column per form field (every answer, mapped or not), person_id, identified_by, lists_subscribed, referrer and the utm_* values; personal data masked without pii:view |
GET | /landing-pages/:id/report | landing_pages:read | Views, first-time views and submissions per UTC day, totals and conversion rate for the range, lifetime counters. ?days= 1-365 (default 30) |
GET | /landing-pages/:id/review | landing_pages:read | Launch Review: findings (code, severity blocker or recommendation, scope, params). Blockers stop a publish; POST .../publish answers 400 with the same findings |
GET | /landing-pages/handle | landing_pages:read | The workspace's pages address: { handle, host, example, customHost }. The handle is the workspace's own id, the first label of every page URL - opaque, permanent, never chosen or renamed; customHost is the workspace's own active domain, when it has one |
GET | /landing-pages/domain | landing_pages:publish | The workspace's own domain: { domain, limit, used, pagesHost } - domain (or null) with id, hostname, status (pending, active, failed), verifiedAt, target, records (the DNS records to add), providerConfigured, lastError; limit and used count the organization's landing-page domains |
POST | /landing-pages/domain | landing_pages:publish | Add your own domain: { "hostname": "pages.yourbrand.com" }. Answers the domain as pending with its DNS records. 409 when the workspace already has one or the organization is at its limit (the message names the limit) |
POST | /landing-pages/domain/:id/verify | landing_pages:publish | Re-check DNS and the certificate at the edge; answers the domain with its current status |
DELETE | /landing-pages/domain/:id | landing_pages:publish | Remove the domain; page URLs revert to the pages domain at once |
GET | /landing-pages/capabilities | landing_pages:read | { botCheck }: whether the server can verify Cloudflare Turnstile, so a form may set botCheck: true |
Public routes (no authentication)
| Method | Path | What it does |
|---|---|---|
GET | /p/:handle/:slug | The published page |
POST | /p/:id/view | View beacon sent by the page itself |
POST | /p/:id/submit | Form submission; the body carries the signature the published document embeds, so only the page as published can post |
GET | /p/go/:workspaceId/:slug | Where {{ 'slug' | landing_page_url }} in a message points: a 302 to the page's canonical URL, query string (person token, UTM values) carried over |
GET | /p/confirm/:token | The link in a double opt-in email: subscribes the lists the token names and shows a confirmation page |
Rate limits are set by who is asking, not by IP address - a mobile carrier or an office can put thousands of people behind one address. The page, its view beacon and /p/go have only a flood cap of 30,000 requests per minute per IP. A valid confirmation link is never limited; invalid ones are refused after 300 per minute from one IP. Form submissions: 15 per minute and 60 per hour from the same person (email, else phone) on the same form, 3,000 per minute per page, and 2,000 per minute per IP; a refused submission shows a message in the page's language. A page body is limited to 500 KB and 50 fields. Every form carries a honeypot field and a two-second minimum dwell.
Use your own domain
Instead of https://<workspace id>.eu1.jrypages.com/<slug>, a workspace can serve its pages on a subdomain it owns - https://pages.yourbrand.com/<slug>. The domain is set up from Settings -> Landing pages (the "Your domain" card) or with the four routes above; it needs landing_pages:publish.
- Add the subdomain (
POST /landing-pages/domain). A subdomain you own, such aspages.yourbrand.com- not a root domain, and never a Joryio domain. Joryio registers it at the edge and answers with the domain in statuspendingand the DNS records to set. - Add the DNS records at your DNS provider: a
CNAMEfrom the subdomain to the target Joryio gives you, plus the certificate validation records (TXT). Settings -> Landing pages shows them with a copy button;recordscarries the same list. - Verify (
POST /landing-pages/domain/:id/verify). Once the CNAME resolves, the certificate is issued within a few minutes and the status becomesactive; pending domains are also re-checked every five minutes on their own. A validation that times out becomesfailed, with the reason inlastError- fix the records and verify again, or remove the domain and add it afresh.
While the domain is active, every page URL Joryio writes - the publicUrl of a page, the preview link, {{ 'slug' | landing_page_url }} in messages and the /p/go redirect behind it - uses it; the pages address keeps serving the same pages, so nothing breaks before or after. Removing the domain (DELETE /landing-pages/domain/:id) turns the URLs back to the pages address at once; the pages stay published.
How many. A workspace has at most one domain. An organization may have one landing-page domain across its workspaces by default; GET /landing-pages/domain reports the limit and how many are used, and adding one over the limit answers 409 with a message that names it. Ask us to raise the limit for more workspaces or brands.
Consent: double opt-in and the bot check
A list that requires double opt-in (List settings) is never subscribed straight from a form. The person is written and the event recorded, the response says to check their inbox, and one email (in the page's language, through the workspace's own email setup) carries a signed link valid for seven days; GET /p/confirm/:token subscribes the lists with optInSource: double_opt_in and shows a confirmation page. The ledger row lists them under listsPending; the Launch Review says which lists will ask for a confirmation.
form.botCheck: true renders a Cloudflare Turnstile widget before the submit button and refuses a submission whose token does not verify. It is available only when the server is configured for Turnstile (GET /landing-pages/capabilities). Forms inside in-app messages never need it: the SDK authenticates them.
Visitors, the Web SDK and personalization
A published page counts views, first-time views and submissions on its own; a visitor who arrives through a message link carrying their token (?jp=, see the Liquid filter below) has Landing Page Viewed written to their profile. Two per-page switches go further:
sdkEnabledloads the workspace's Web SDK on the live page (it needs an active Web app under Settings > Apps, otherwise the save is refused). Every visitor gets a session and events like any page with the SDK; the runtime tracks Landing Page Viewed through the SDK, a token visitor is identified on arrival, and a submitted form identifies the visitor as the person it wrote to, so their anonymous history merges into the profile. In-app messages targeted at the workspace's Web app can show on the page. The editor's preview never loads the SDK.personalizedrenders the page's Liquid for a visitor who arrives with a token, as a message would ({{ user.firstName | default: "there" }}, attributes,landing_page_url); without a token the same page renders with an empty person, so defaults show. A personal render is served withCache-Control: private, no-store; the catalog filter is not available on pages.
Templates, AI and links from messages
Built-in templates. ?templates=true lists the workspace's own templates and five built-in ones (newsletter signup, event registration, product waitlist, giveaway, coming soon). A built-in has a fixed id; POST /landing-pages/:id/duplicate on it creates an ordinary page in the workspace. Any page becomes a template with { "asTemplate": true }.
AI design and revise (dashboard session only, landing_pages:write): POST /ai/pages/design with brief, optional language, brandVoiceId, archetype (bold, editorial, minimal, product, event), colorStrategy (restrained, committed, drenched), goal (signup, waitlist, event, download, contest, other) returns { name, seo, design, html, css, form, language, dir } - the model plans sections and writes the copy, deterministic code builds the markup on the Brand Kit, and nothing is saved until you save. POST /ai/pages/revise with source (the page's HTML) and instruction returns { source, reply }, the whole revised document; a revision that drops the form or adds a script is refused.
Linking to a page from a message. In any email, SMS or WhatsApp body, {{ 'spring-sale' | landing_page_url }} renders a link to the published page with that slug. The link carries the recipient's token (?jp=), so the view and the form submission are written to that person even before they type an email; a second argument appends a query string: {{ 'spring-sale' | landing_page_url: 'utm_campaign=spring' }}. An unpublished slug renders an empty string.
Events. Landing Page Viewed and Landing Page Form Submitted appear in every event picker (journey triggers, triggered campaigns, segments) under the platform events, with their properties.
What a submission does
- Validates the values against the schema; unknown fields are ignored, errors come back as
{ "ok": false, "errors": [...] }. - Identifies the person: an existing profile with the same email is updated, otherwise a profile is created with a generated external id and
sourcelanding_page:<slug>. An email or phone is only filled in where the profile has none; a person named by the link's token (jp) can update their own phone, never their email. - Writes the mapped attributes, subscribes the ticked lists, records Landing Page Form Submitted with
pageId,pageSlug,pageName, the field keys,listsSubscribed,referrer, the UTM values, and the form'semailandphonewithphoneUpdated(truewhen the profile now has the form's phone,falsewhen it kept a different one; absent when the form had no phone or the same one). - Stores a ledger row (the Submissions tab) and increments the page's
submissionscount. - Answers
{ "ok": true, "message": "..." }or{ "ok": true, "redirect": "https://..." }.
See also: Landing Pages & Forms in the user guide - the same feature from the marketer's side.