The visual editor
Every Pendro project opens to the same four-pane editor: a rail for switching between Sections / Theme / Navigation, an outline listing the page's sections, the canvas with a live preview iframe, and the inspector for editing the currently-selected section.
Every edit autosaves after a short pause. The save indicator in the top-right shows "Saving…" mid-flight and "Saved HH:MM" once the round-trip completes. Force-save is ⌘S.
The device segmented control at the top of the canvas toggles the preview width between Desktop, Tablet (768 px), and Mobile (375 px). Your choice persists across sessions.
Section library (29 types)
Click Add section in the outline to open the picker. 29 section types ship — Hero, Features, Pricing, Testimonials, FAQ, CTA, About, Services, Process, Benefits, Use cases, Case studies, Stats, Logo cloud, Trust strip, Blog preview, Team, Gallery, Video, Newsletter, Announcement bar, Quote, Rich text, Spacer, Embed, Integrations, Awards, Timeline, Comparison, Contact.
Section types with multiple curated presets (Hero, Features, Pricing, Testimonials, Services, Process, About, Newsletter, Stats, Team, Gallery, Video, Trust strip, Logo cloud, Benefits, Use cases, Case studies, Announcement bar, Blog preview, Awards, Timeline, Comparison, Quote, Integrations) drill into a second picker showing two to three pre-populated variants. Click any preset to drop a fully-built section in.
Reorder, duplicate, hide
Each outline row carries a kebab menu with Move up, Move down, Duplicate, Copy (to clipboard, paste-able into any project), Toggle visibility. Hover reveals the grip handle for drag-to-reorder.
Multi-select: ⌘-click or Ctrl-click rows to add them to the selection, Shift-click to select a range. A dark batch action bar appears at the top of the outline when 2+ rows are selected with the same actions (move / duplicate / hide / delete) acting on the whole block. Drag any selected row to move them together.
Keyboard duplicate: ⌘D on the selected section. Esc clears a multi-selection.
Visibility scheduling
Every section has a Visibility inspector section (collapsed by default). Two datetime-local inputs: Visible from hides the section until the chosen moment; Visible until hides it after.
Both are optional. Useful for flash-sale announcement bars, event RSVP sections that should auto-hide after the date, holiday promos. The schedule is server-side — published pages flip near the window edge with up to a 5-minute lag on cached pages.
Cross-project section copy / paste
The kebab-menu Copy on any section writes a versioned snapshot to localStorage shared across all tabs of the dashboard. Open any other project and the outline header's Paste button appears with the source section's type — click to drop it in.
IDs are re-minted on paste so the same section can be pasted multiple times without collisions.
Keyboard shortcuts
- ⌘S — flush autosave immediately
- ⌘Z / ⌘⇧Z — undo / redo canvas-level edits (in-tab stack, 50 entries)
- ⌘\ — toggle the inspector pane
- ⌘⇧\ — toggle the outline pane
- ⌘. — preview-only mode (collapse both side panels)
- ⌘⇧P — full-screen preview (hides every chrome element); Esc exits
- ⌘D — duplicate the selected section
- ⌘K — open the command palette (navigate, create, theme toggle, admin actions)
- ⌘/ or ? — open the shortcuts help dialog
Theme & colours
The Theme rail opens a per-project theme editor: six colour tokens (primary, foreground, background, muted, border, accent), font pairing, and spacing. Changes apply live to the preview iframe.
Dark mode: enable from the Theme tab. Pendro auto-derives a dark palette from your light palette — surfaces invert to neutral greys while brand colours pass through. A bespoke dark palette editor is available for fine-tuning each token; reset returns to the auto-derived values.
Visitors get a sun / moon toggle in the site header to switch themes. The choice persists in localStorage; an inline no-flash script applies the saved choice before paint so dark-mode visitors don't see a white flash on navigation.
Animations
The Theme tab's Animations section picks one of four presets:
- Off — no transitions, no reveals.
- Subtle — atmospheric blob drift silenced, hover lifts removed, underline-grow dampened.
- Standard (default) — the everything's-on baseline.
- Playful — bounce easing on hover, -4px card lift, 3px underline grow on link hovers.
All four presets respect prefers-reduced-motion: reduce at the OS level — animations silence regardless of the picked preset when the visitor has reduced motion on.
Blog editor
Each project gets a blog at /blog on its published subdomain. Manage posts at Projects → Blog in the dashboard.
The editor is a Tiptap-based rich-text surface: headings (H2, H3), bold, italic, strikethrough, inline code, lists, blockquotes, fenced code, links, images, and callouts (info / tip / warning / success). Auto-save runs every five seconds.
Type / on an empty line to open the slash menu — pick from headings, callouts, video embeds, or images without leaving the keyboard.
AI writing assistant
The AI button on the blog editor opens a side panel with two modes:
- Draft — generate a fresh post from a title + outline. Streams in real time.
- Improve — rewrite a selection in one of five modes: tighten / expand / friendly / professional / simplify. Compare view shows original above, rewrite below; click Replace to commit (Ctrl-Z reverts atomically).
Highlight any text in the editor and a floating Improve with AI bubble appears above the selection — click to open the AI panel pre-set to Improve mode.
Brand voice: each project carries an optional tone description + up to five voice samples (real paragraphs you've written). The AI matches the samples on every draft + rewrite. Configure at Projects → Brand voice. Plan-gated to Intermediate+.
Publishing & scheduling
Posts have three states: draft, scheduled, published. Publish now flips a draft live immediately; Schedule… picks a future datetime and a daily cron publishes the post within ~5 minutes of the target time.
When a scheduled post goes live, the author receives an email confirmation with a link to view the post + a deep-link back to the editor.
Preview link: drafts and scheduled posts have a Copy-preview-link button. The link carries a signed token, expires in 24 hours, and shows the post content on the published subdomain without revealing the post to search engines or the blog index.
Inline AI in section editors
Section editors with prose fields carry an inline AI improve button. Currently shipped on the Hero section's subheadline; other sections gain the button incrementally.
Click Tighten next to a textarea to rewrite the current value in a more concise voice. The rewrite uses the same API path as the blog editor's improve mode + applies any configured brand voice.
Contact forms & submissions
Every contact section on a published page accepts submissions and routes them to the project's Submissions inbox (Projects → Submissions). The global aggregator at /inbox shows submissions across every project.
Each submission can be marked read, deleted, or expanded inline. The filter strip at the top supports free-text search across every value in the submission and an "Unread only" toggle. Per-project pages also offer Mark all read.
CSV export: per project, click Download CSV to get a wide-format file (one row per submission, columns = the alphabetically-sorted union of every data key across the project's submissions). Spam-flagged rows are filtered out automatically.
Spam protection: every submission carries a honeypot field + an IP-hash rate limit (5 per minute per visitor). Optional Cloudflare Turnstile.
SDK overview
The Pendro SDK ships drop-in widgets that work on any external site. One script tag loads the runtime; one <div data-pendro-*> element mounts a widget into it. The same SDK is also importable from npm as @app/sdk for React / Vue / Vite projects that want named-export ergonomics.
Canonical URL. The script is self-hosted at a version-pinned path:
https://pendro.co/sdk/v1/pendro.jsServed with Cache-Control: public, max-age=31536000, immutable (safe because the URL is version-locked — breaking changes ship at /sdk/v2/... and existing integrations keep working) plus Access-Control-Allow-Origin: * and Cross-Origin-Resource-Policy: cross-origin so any embedder origin can load it without browser warnings.
Size. ~4.6 KB gzipped IIFE, no external dependencies, ES2018 target — runs on every evergreen browser without polyfills.
What's in the bundle today. Newsletter signup widget + contact-form widget. Lead-capture, an A/B test client, and a React companion package are on the roadmap; the dispatcher API (mount(type, container, options)) is sized so each new widget lands without an SDK redesign.
Auto-mount, no init call required. Set data-project="<project-id>" on the script tag and the SDK reads it, then walks the DOM on DOMContentLoaded for any [data-pendro-newsletter] or [data-pendro-contact] element and mounts the matching widget. Re-mounts are idempotent — React hydration / re-renders won't double-mount.
Programmatic API. Prefer to mount from your own script after a route change in an SPA? Skip the auto-mount selector and call:
<script src="https://pendro.co/sdk/v1/pendro.js"
data-project="proj_xxx" async></script>
<script>
// window.Pendro is set as soon as the script evaluates.
Pendro.mount('newsletter', document.querySelector('#nl'), {
heading: 'Get the weekly digest',
accentColor: '#7C3AED',
});
</script>mount() returns an unmount() function — call it on route change to detach the widget cleanly. Same fn signature for both widget types; TypeScript narrows the options shape based on the type string.
Contact-form widget
Multi-field contact form with the canonical name / email / message defaults out of the box. Submits to the same /api/forms/[projectId] route the in-site Contact sections use — same honeypot check, same rate limit, same downstream notifications (email + Slack + web push + the project's Submissions inbox).
Drop-in snippet (default name + email + message fields):
<script src="https://pendro.co/sdk/v1/pendro.js"
data-project="proj_xxx" async></script>
<div data-pendro-contact></div>Re-label the default fields via the same data-* pattern as the newsletter:
<div
data-pendro-contact
data-heading="Talk to sales"
data-description="Tell us about your team."
data-button="Request a call"
data-success="Thanks — we'll reach out shortly."
data-accent="#0EA5A4"
></div>Custom field lists (booking enquiry, RFQ, lead-qualification questions, etc.) can't ride on data-* — JSON-in-attribute escaping is fragile. Drop the auto-mount selector and call Pendro.mount('contact', el, { fields: [...] }) from your own script:
<div id="booking"></div>
<script>
Pendro.mount('contact', document.getElementById('booking'), {
heading: 'Book a strategy call',
buttonLabel: 'Request booking',
fields: [
{ name: 'name', label: 'Full name', type: 'text', required: true },
{ name: 'email', label: 'Work email', type: 'email', required: true },
{ name: 'company', label: 'Company', type: 'text' },
{ name: 'phone', label: 'Phone', type: 'tel' },
{ name: 'message', label: 'Details', type: 'textarea', required: true, rows: 5 },
],
});
</script>Supported field types: text, email, tel, textarea. Submissions arrive in the project's Submissions inbox tagged with _source: 'sdk-contact-form' in Sentry context (stripped from the persisted submission's data column — the leading underscore tells the route to skip it).
Webhooks
Configure outbound webhooks per project at Projects → Webhooks. Pick from four events:
project.published— fires on publish / re-publishform-submission.created— fires on a contact-form submit (non-spam only)newsletter-subscription.created— fires on a genuinely-new subscriber (re-subscribes don't refire)blog-post.published— fires on direct click or scheduled-cron publish
Each subscription gets a unique signing secret. Every delivery carries an X-Pendro-Signature: t=<unix>,v1=<hex> header — the same shape Stripe uses, so any Stripe-compatible HMAC-verify code reuses without change. The signed payload is <timestamp>.<request-body>.
Per-row health: last-delivery timestamp, last-delivery status, failure count. Failures bump the counter; successes reset it to zero. Auto-disable after N failures is a future polish — for now, manually pause a misbehaving subscriber from the same UI.
Public REST API (v1)
Pendro ships a public REST API at /api/v1/* with read + write access to your projects and blog posts. Authenticate with a Personal Access Token from /settings/api-tokens — the same token the developer CLI uses, so any token works for both surfaces.
Quick start.
# Discovery — no auth, returns the version + endpoint map
curl https://app.pendro.co/api/v1
# OpenAPI spec — paste into Swagger UI / Stoplight / Redoc
curl https://app.pendro.co/api/v1/openapi.json
# --- Reads ---
curl https://app.pendro.co/api/v1/projects \
-H "Authorization: Bearer pendro_pat_…"
curl https://app.pendro.co/api/v1/projects/acme \
-H "Authorization: Bearer pendro_pat_…"
# --- Writes (require the matching write:* scope on the token) ---
curl -X POST https://app.pendro.co/api/v1/projects \
-H "Authorization: Bearer pendro_pat_…" \
-H "Content-Type: application/json" \
-d '{"name":"Acme","template_id":"clean-business"}'
curl -X PATCH https://app.pendro.co/api/v1/projects/acme \
-H "Authorization: Bearer pendro_pat_…" \
-H "Content-Type: application/json" \
-d '{"status":"published"}'
curl -X DELETE https://app.pendro.co/api/v1/projects/acme \
-H "Authorization: Bearer pendro_pat_…"
# Blog posts: list / create / update / delete by slug
curl "https://app.pendro.co/api/v1/projects/acme/blog-posts?status=published" \
-H "Authorization: Bearer pendro_pat_…"
curl -X POST https://app.pendro.co/api/v1/projects/acme/blog-posts \
-H "Authorization: Bearer pendro_pat_…" \
-H "Content-Type: application/json" \
-d '{"title":"Launch notes","slug":"launch","content":{"type":"doc","content":[]}}'Response envelope. Every success response follows the shape { "data": …, "meta": { count, next_cursor } }. Errors are { "error": { "code": "…", "message": "…" } } with a stable machine-readable code.
Pagination. Cursor-based. List endpoints return meta.next_cursor when more pages exist; pass it back as the ?cursor= query parameter for the next page. Cursors are opaque base64 — don't try to parse them client-side.
Rate limit. 60 requests per minute per token, enforced via a DB-backed request log so the cap stays consistent across Vercel regions. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (unix-seconds). A 429 also includes Retry-After in seconds.
Token scopes. Each token carries one or more scopes from a closed set:
read:projects— list + retrieve projects.write:projects— create, update metadata (name / subdomain / status), and soft-delete.read:blog— list + retrieve posts.write:blog— create, edit, schedule, and soft-delete posts.
Read endpoints accept any valid bearer; write endpoints require the matching write:* scope and return 403 insufficient_scope otherwise. Tokens issued before the writes shipped automatically carry all four scopes for backward compatibility. Pick the minimum set per token — narrow scopes reduce blast radius if a token leaks. OAuth client-credentials flow is intentionally out of scope for v1 (filed in ROADMAP; bearer tokens cover the same surface for the foreseeable feature set, matching the v1 GA shape Stripe / Linear / Notion shipped).
Write semantics. Project content-tree replacement is deliberately not exposed — PATCH /projects/{id} only accepts name, subdomain, and status. The editor's debounced autosave + revision history is the safe path for content edits; a raw REST overwrite would risk corruption without that safety net. Blog-post writes carry the full Tiptap JSON content field — that schema is smaller and already validated server-side. Soft-deletes are idempotent (a 200 no-op on already-deleted rows).
Stability. v1 is a stable contract. Additive changes (new fields, new endpoints) land in-place; breaking changes ship under /api/v2with a 12-month overlap. Existing v1 clients will keep working — but field-level subscribers should write code that tolerates unknown JSON properties.
OpenAPI. /api/v1/openapi.json serves a hand-curated OpenAPI 3.1 spec — paste it into Swagger UI / Redoc / Stoplight for interactive docs. Keeping the spec hand-written (vs scraping from route handlers) means it's the authoritative description of intent rather than implementation.
Developer CLI
Pendro ships a developer CLI with operator commands plus a section scaffolder. It runs through the repo's existing tsx dev dep — no extra lockfile entries, no global install.
Two modes. The CLI auto-detects which one it's in:
- Remote mode (the power-user path) — sign in with a personal API token from
/settings/api-tokens. All commands run as you against the hosted API; no DB credentials needed. - Local mode (the maintainer path) — when
DATABASE_URLis set inapps/dashboard/.env.local, the CLI talks directly to Postgres for global views likeusers listanddb tablesthat the remote API doesn't expose.
Quick start (remote mode).
# 1. Create a token at /settings/api-tokens, then:
npm run cli -- auth login
# Paste the token when prompted. It's stored at
# ~/.pendro/config.json (chmod 0600).
# 2. Confirm you're signed in:
npm run cli -- auth whoami
# 3. List your projects:
npm run cli -- projects list --limit 5 --published
# 4. Show one by id or subdomain:
npm run cli -- projects show acme
# Show usage:
npm run cli -- --helpAuth commands — power-user remote mode:
auth login— paste a token. Optional flags:--url https://app.pendro.coto point at a different API host (useful for local dev againsthttp://localhost:3000),--token pendro_pat_…to skip the interactive prompt.auth whoami— show who the CLI is signed in as (email + plan + role).auth logout— delete the local config file. The token itself stays valid on the server; revoke it at/settings/api-tokensto kill it everywhere.
CI usage. Set PENDRO_API_TOKEN (and optionally PENDRO_API_URL) in your CI secrets — the env vars override the config file, so a runner doesn't need an interactive auth login. The recommended pattern is a dedicated token named "CI" with a short expiry that can be rotated without touching laptops.
Operator commands — work in both modes unless flagged local-only below. In remote mode they hit the dashboard's /api/cli/* route handlers and are always scoped to your account:
status— in remote mode, signed-in user + project count summary; in local mode, env check + DB ping + migration status + row counts.projects list— paginated list with filters:--limit N,--published,--draft,--all(includes trashed).projects show <id|subdomain>— project header + per-project blog / submission / webhook counts.webhooks list— every webhook (scoped to your projects in remote mode; global in local mode), event count, last-delivery status, failure count. Filters:--project <id>,--inactive.db status(local-only) — connectivity + Postgres version.db tables(local-only) — live-tuple row count per table (estimate, but fast).db migrate(local-only) — show drizzle migration status (X of Y applied; falls back to a "schema may be in sync viadrizzle-kit push" hint when the migrations table is empty).users list(local-only) — filters:--limit N,--plan free|beginner|…,--admins.users show <id|email>(local-only) — plan + feature flags + Stripe customer + pending-deletion warning.
Local-only commands need direct DB access — the remote API deliberately doesn't expose them. If you run one in remote mode the CLI bails with a clear "set DATABASE_URL or use the dashboard UI" message.
Scaffolders — no DB needed:
section new <name>— kebab-case name; generates the three new files (Zod schema / renderer / inspector editor) and prints paste snippets for the five existing files that still need a hand-applied insert. Cuts the 5-file "Adding a new section type" ritual from ~10–15 min to ~3 min.
Why scaffolders don't fully patch existing files. The five insertion sites are hand-edited by humans daily — anchor lines drift, blocks reorder, and a CLI that silently corrupted a hand-authored import block would be worse than no CLI. Scaffolds-only + paste snippets is the right safety/utility trade-off.
DB connectivity safety net. Every DB command wraps the query in a 5s timeout — a stopped Postgres or a wrong host fails fast with a clear "DB ping timed out" message rather than hanging the terminal. The CLI exits cleanly (no lingering connection pool) so it behaves like a normal one-shot command.
Adjacent scripts — the CLI doesn't duplicate what's already a runnable script: use npm run secrets:audit for credential rotation (CR1) and npm run alerts:check for Sentry alert-rule coverage (ALR1).
Token security. Tokens follow the Stripe / GitHub convention pendro_pat_<64 hex> so secret-scanners can flag accidental commits. Only the SHA-256 hash lands in the DB; the plaintext is shown to you exactly once at create time. The settings UI lets you label, list, rotate, and revoke — revoking takes effect on the next request (typically < 1s). A leaked token can read your account but never enumerate other users' data.
Source. scripts/pendro-cli.ts — a single TypeScript file. Same single-file pattern audit-secrets and sentry-alerts use, so a new operator recognises the shape on first read. API surface lives at apps/dashboard/src/app/api/cli/*.
MCP server (AI agents)
Pendro ships a Model Context Protocol server so AI agents — Claude Desktop, Claude in Chrome, ChatGPT, Cursor, Zapier MCP, anything that speaks MCP — can drive Pendro through natural-language tool calls. Ask Claude "create a project for my pottery studio using the wellness-spa template" and the agent calls create_project for you. Ask "publish the Climbird draft I wrote yesterday" and it chains list_blog_posts → update_blog_post.
Eleven tools, all wrapping the public REST API:
discover— connectivity / API-version probelist_projects,get_project,create_project,update_project,delete_projectlist_blog_posts,get_blog_post,create_blog_post,update_blog_post,delete_blog_post
Install + auth. Same personal access token as the CLI + REST API — generate at /settings/api-tokens (token prefix pendro_pat_). 60-req/min/token rate limit inherited from the REST surface.
Easiest path — HTTP transport (recommended). No install, no Node, no PATH issues. Open Claude Desktop → Settings → Custom Connectors (beta) → Add, paste this URL (with your token embedded so the connector can authenticate):
https://app.pendro.co/api/v1/mcp?token=pendro_pat_...That's it. 17 tools available immediately. The token is in the URL fragment so it never leaves the connector storage; rate limits + per-scope authorisation apply exactly as on the REST API.
Stdio path (for offline / developer use). @app/mcp is currently an internal workspace package and is not yet published to npm (planned name: @pendro/mcp). If you saw older docs telling you to run npx -y @app/mcp serve, that command returns a 404 from the npm registry. Until we publish, use this absolute-path config — Claude Desktop's~/Library/Application Support/Claude/claude_desktop_config.json on macOS, %APPDATA%\Claude\claude_desktop_config.json on Windows:
{
"mcpServers": {
"pendro": {
"command": "/absolute/path/to/node",
"args": [
"/absolute/path/to/saas_landing_page/packages/mcp/dist/cli.js",
"serve"
],
"env": {
"PENDRO_API_KEY": "pendro_pat_...",
"PENDRO_API_URL": "https://app.pendro.co"
}
}
}
}Find your two absolute paths via which node (e.g. /opt/homebrew/bin/node on Homebrew Apple Silicon) and the result of pwd after cd packages/mcp + npm run build. Restart Claude Desktop fully (⌘Q on macOS — refresh isn't enough). The pendro server appears in the bottom-left of the chat window with 17 tools next to it.
Debug — when Claude Desktop says "server isn't responding," run the doctor probe in a terminal from packages/mcp:
PENDRO_API_KEY=pendro_pat_... ./dist/cli.js doctor
# Expected:
# pendro-mcp doctor — probing https://app.pendro.co …
# ✓ Connected. Server responded with API discovery doc.
# ✓ Auth recognised (no 401).
# → Ready to serve.Doctor probes the API directly (no MCP layer) so it isolates the failure: auth, network, rate-limit, or wire-format. Common causes when Doctor passes but Claude Desktop doesn't connect: stale Claude process (full quit needed), Claude launching the binary with a minimal PATH that doesn't find node (the absolute-path config above is the fix), or the API key sitting in your shell env instead of the JSON config's env block (Claude doesn't inherit shell env for MCP servers).
Architecture — the MCP package (@app/mcp) carries zero business logic. Every tool resolves to a single fetch against the same /api/v1/* endpoints the REST API surface exposes. Splits cleanly from the API so future ChatGPT plugin / Zapier integration / custom wrapper can target the same REST surface without touching Pendro itself.
Roadmap: phase 2 adds submissions/subscribers read tools; phase 3 adds AI tools (generate_project_from_prompt, regenerate_section); phase 4 adds HTTP-streamable transport for Zapier and remote agents; phase 5 adds a watch_event tool so agents subscribe to webhooks instead of polling. See the package README and ROADMAP.md → MCP1 for the full plan.
Custom domains
Every project gets a free subdomain on pendro.co (e.g. acme.pendro.co) the moment you publish. Paid plans can attach a custom apex or subdomain — go to Projects → Domains.
Add the domain in Pendro, copy the displayed A or CNAME record, paste it into your DNS provider, then click Verify in Pendro. Once verified, SSL provisions automatically (a few minutes for the first cert).
A daily cron re-verifies every active custom domain. If a record was changed at the DNS provider, the project's domains page flags the domain as broken so you can fix it before visitors notice.
"Powered by Pendro" badge
Free-tier sites carry a small "Powered by Pendro" badge in the footer. Paid plans (Beginner+) get a toggle in the Theme tab to hide it.
Clicks on the badge route through a tracker that attributes the click for our referral program before redirecting to pendro.co.
Pageview analytics
Pendro tracks pageviews on every published tenant page via a 1×1 transparent image beacon — no third-party JavaScript, no cookies, no IP storage. Same numbers Plausible / Fathom collect, without the extra script tag.
What's stored: path, referrer host (when not stripped), two-letter ISO country (from request headers), and a timestamp. Nothing visitor-identifying.
View aggregates at /analytics: page views, form submissions, new subscribers, blog posts published, and projects published — each with a 30-day count and a delta vs the prior 30 days. Traffic breakdown cards show top 10 pages, top 5 referrers, and top 5 countries.
The dashboard's KPI band (/dashboard) carries 4 always-visible numbers: Projects count, Posts last 7 days, Submissions last 7 days, Pageviews last 7 days — each with a delta chip.
Account settings
Visit /settings to manage your account.
Display name is editable inline — Enter to save, blank to reset to your provider's default (Google profile name or email local-part). Length cap 80 chars.
Email change: click Change, enter the new address, hit Send link. A verification email lands in the new inbox; clicking it swaps your account to the new address, invalidates every active session for safety (sign out everywhere), and deletes any pending magic-link tokens for the old email. The link expires in 24 hours.
Sign out everywhere
Sessions card on the settings page → Sign out everywhere. Invalidates every active sign-in for your account: the current tab redirects to /login; other devices get bounced to /login on their next navigation.
Useful when you signed in on a shared computer, lost a device, or suspect a stolen session. Cookies on other devices keep working at the cookie layer, but every protected route does a server-side session-version check that fails for any token issued before the sign-out.
Data export & account deletion
Export my data on the settings page downloads every row your account owns as a ZIP:
manifest.json+README.txtat the rootprojects/<subdomain>/project.jsonper project (full content tree)blog-posts.json+ form-submissions / newsletter CSVs per project
Asset bytes aren't bundled — Pendro keeps them on durable storage; the JSON points at the URLs which stay valid for your account's lifetime.
Delete account: click Schedule deletion. Your account gets a 30-day grace window during which you can sign in and click Cancel deletion to recover. After 30 days, a daily cron hard-deletes the account, cascades through projects / posts / submissions / subscribers, cancels Stripe subscriptions, releases custom domains, and purges assets. Admin accounts require another admin to perform the deletion.
Plans & pricing
Four tiers: Free, Beginner, Intermediate, Advanced. See the full feature breakdown at /pricing.
Annual billing: the Yearly toggle on the pricing page swaps to yearly Stripe prices (when configured) with a "Save 20%" badge per card. The billing cadence threads through Stripe metadata so webhook handlers can distinguish monthly from yearly subscriptions.
Upgrade-pill nudges appear next to plan-gated features (AI assistant, brand voice, custom domains, analytics tracking IDs) so you can see what's locked before you click.
Referral program
Share your personal referral link from the settings page. Anyone who signs up via your link is attributed to you. When they upgrade to a paid plan, you earn a Stripe coupon on your customer account.
Stats card on settings shows total signups, total who paid, and total rewarded. Attribution survives the OAuth round-trip via a signed cookie (httpOnly, SameSite=Lax, Secure-in-prod) so Google sign-in doesn't drop it.
Rewards are credited by a daily sweep. Pending counts normally drain to zero overnight; if they don't, check the admin gauge on /admin/system (admins only).
Command palette (⌘K)
Press ⌘K (or Ctrl+K on Windows/Linux) anywhere in the dashboard to open the command palette. Fuzzy-search across:
- Navigate — every dashboard route (Dashboard, Projects, Inbox, Subscribers, Settings, etc.)
- Create — New project (alias
np) - Theme — toggle light / dark mode (alias
tt) - Admin — admin pages (admins only)
Recent commands surface at the top of the unfiltered view (rolling 5).
Onboarding & tips
New signups land on a three-step welcome modal: Welcome → Templates → Publishing. Skippable any time; it never reappears once you have your first project.
Surface-specific Did you know? tip pills surface the highest-leverage feature on Blog (slash menu), Inbox (CSV export), Brand voice (real examples beat adjectives), and Analytics (no third-party JS). Dismiss any tip with the × in the corner; it stays dismissed.
Project overview hub
The project overview page (/projects/[id]) opens to a 2×3 quick-action grid: Edit, Domains, Blog, Brand voice, Analytics, Settings. Click any tile to jump straight to that surface — middle-click and ⌘-click both open in new tabs.
Below the grid sit detail cards (theme summary, SEO summary, recent blog posts, recent assets, legal pages, social links, custom code) so the overview doubles as a single-page audit of the project's current state.
Asset manager
Each project has an Assets page listing every uploaded image with a reference count ("Used in 3 sections + 1 blog post" / "Unused"). Expand any asset to see the per-section + per-post breakdown.
Replace button on each asset puts the grid into swap mode — click any other asset to swap every reference of the source with the target in one transaction. Spans the project's section content + every non-deleted blog post.
Deletes are data-driven: the confirm dialog tells you exactly which sections and posts will break.
Mobile preview
The canvas toolbar carries a segmented control: Desktop / Tablet (768 px) / Mobile (375 px). Each option resizes the preview iframe so you see exactly how the page renders at that width. The selection persists across sessions.
Version history
The history dialog (rail's History button) lists every saved revision of the project's content — autosaves, manual checkpoints, and restore operations. Each row shows the author, the timestamp, and an optional message.
Click any revision to preview it; Restore promotes it to the live version (which itself becomes a new revision, so a wrong restore is one click of undo away).
Templates gallery
Browse all 100 production-ready templates at /templates. Each is a working site you can publish in minutes — sections, theme, navigation, and seed content all editable in the browser.
Grouped by category: General-purpose, Service businesses, Portfolios & studios, Custom. Pick the closest match — you can keep editing forever after.
Accessibility
Pendro targets WCAG 2.2 AA across every published tenant site and the dashboard itself.
- Focus rings on every interactive element (2px, ≥3:1 contrast)
- All drag operations have a keyboard alternative (Move-up / Move-down buttons on every reorderable list)
- Touch targets ≥24×24px (SC 2.5.8)
prefers-reduced-motion: reducerespected globally — animations silence at the OS level- Save indicators announce via
aria-live=politeso screen readers hear "Saving…" → "Saved 4:12 PM" without the user having to look