# Poily Docs > Integration docs for developers and AI agents connecting a product to Poily — people sync, forms, blog/content, and email consent. Every page has a raw Markdown twin at the same path plus .md. # Connect your product to Poily *Written for the developer — or AI agent — of a product joining a Poily portfolio. Everything here is self-serve: no Poily-side engineering is involved in connecting you.* Poily runs your portfolio's marketing, CRM, email, and entitlements across products. Your product keeps its own stack and stays the system of record for its own data; each **connection point** below is one seam where your product and Poily exchange something. Connect only the ones you need, in any order. **Base API:** `https://ju1sxvytc1.execute-api.us-east-1.amazonaws.com/dev` **Console:** `https://app.poily.com` (your operator works here; API keys are issued here) --- ## 1. People — push your members (live) Send Poily the members you choose to share. They appear in the portfolio's User Manager and CRM Contacts, with **`existing_relationship`** consent (an account is a relationship — Poily may send them transactional mail; marketing mail still requires an opt-in, see §4). **Get a key:** your operator issues one in Console → Settings → Connections (value shown once). > ⚠️ **The key is server-side only.** Store it as a server secret and send it only from backend code. Never put it in a client bundle or a browser-exposed env var (`VITE_*`, `NEXT_PUBLIC_*`, `REACT_APP_*`) — `/u/identify` authenticates by key alone and ignores Origin, so a key shipped to the browser is a full identity-feed credential for anyone who views source. If a key leaks, revoke it in the same Connections tab and issue a new one. **On signup — and once as a backfill — POST your users.** Single and batch are the same call; it upserts on `external_id`, so repeats are always safe: ``` POST /u/identify Authorization: Bearer Content-Type: application/json { "users": [ { "external_id": "u_123", // required — your stable user id "email": "ada@example.com", "name": "Ada Lovelace", "created_at": "2026-10-31T12:00:00Z", "last_active_at": "2026-11-01T09:30:00Z", // optional — when THIS USER last did something in your product "plan": "pro", // optional — whatever you call it "status": "active", // optional "attributes": { "role": "editor" } // optional free-form JSON } ] } ``` Response: `200 {"accepted": 1, "rejected": []}` · max 1000 users per call (loop for bigger backfills) · `401` means the key is wrong or revoked. Update a user (plan change, rename) by sending them again. **`last_active_at` drives the Active / Dormant split** on the Poily dashboard (Active = within 30 days) and dormancy-based lifecycle automation. Rules: - Send the user's **real last activity in your product** — a session, a meaningful action, whatever you consider "active". Never send "now" as a default: a backfill that stamps everyone `now` reads as a fully active user base and makes dormancy targeting meaningless. - **Omit it when you don't know** — omitting never clears a previously sent value, and users without one show as "Unknown activity" (which is honest), not as dormant. - Include it in your nightly re-push so it stays fresh; it's also fine to send it alone with just `external_id` as a lightweight activity ping. **`attributes` powers segmentation.** Whatever you put there becomes targetable in Poily's campaign segments (e.g. `minutes_watched_30d ≥ 120 AND has_shared = false AND email_news = true`). Conventions: - **Flat keys, JSON types preserved** — send booleans as booleans and numbers as numbers (`"true"` ≠ `true`; a type change silently shrinks any segment filtering on it). - **Send computed aggregates, not raw events** — `minutes_watched_30d: 240` beats a stream of watch events; you own the computation, Poily owns the targeting. - **Keep them fresh** — a nightly re-push plus an immediate push when something consequential flips (a preference toggle, a plan change) is the right cadence. Segments are re-evaluated at every send, so freshness of the data is the only staleness that exists. - **Preference flags** (like `email_news`) are enforced by **targeting**: campaigns for that content segment on the flag, so flipping it off removes the member at the next send. Unsubscribe/suppression (§4) is the harder, transport-enforced layer underneath. ### Where to wire it, by stack The call itself is identical everywhere — an HTTPS POST with a Bearer header. Only the secret's home and the trigger differ: - **Supabase:** store the key in Edge Functions → Secrets (e.g. `POILY_API_KEY`). Trigger an Edge Function from an auth webhook or a database trigger/webhook on your users table; read the secret with `Deno.env.get("POILY_API_KEY")`. Backfill = a one-off script or scheduled function paging through your users. - **Vercel / Netlify:** store the key as an environment variable **without** a public prefix. Call from a server route, API route, or background/scheduled function — never from component code (even server components leak into client bundles if you pass the key down). - **AWS:** key in SSM Parameter Store or Secrets Manager; call from the Lambda (or container) that handles your signup path. A scheduled Lambda (EventBridge) works for the periodic re-sync variant. - **Traditional backend (Rails, Django, Express, Laravel, …):** key in your usual server env/secret store; call from your signup handler or an after-create hook, plus a management command/cron for the backfill. - **No backend at all** (static site + third-party auth): don't call from the browser. Use your auth provider's webhooks (Auth0, Clerk, Firebase Functions, Supabase as above) as the server-side trigger instead. In every case: fire-and-forget with a short timeout and a retry or queue — your signup flow should never block or fail because Poily was briefly unreachable, and the upsert semantics make redelivery safe. **Alternatives** (for teams that prefer not to push): hosted-store read (you hand Poily read credentials in the console) and an API contract Poily pulls from — both planned; ask your operator. ## 2. Forms — capture signups on your site (live) Four ways, pick one: - **Hosted page:** `https://forms.poily.com/f/` — link to it, zero code. - **Scan-to-create (recommended):** build your own `
`, tag it, include the SDK — Poily creates the form from your markup and captures submissions. No endpoint wiring: ```html
``` Your domain must be registered in your brand kit (`custom_domain`) — origin is how Poily knows the form is yours. - **Embed:** `
` + the `embed/forms.js` script, or a plain iframe. - **Direct REST:** `POST /f//submit` (JSON → `{id}`; form-encoded → 303). Reserved keys: `_attribution`, `_placement`, `_gotcha` (honeypot — any non-empty value silently drops the submission), `_started_at` (epoch ms, first interaction → completion time). Form submissions create leads with **`form_optin`** consent — immediately mailable for marketing. ## 3. Blog / content — two ways to serve it (live) Pick ONE per site and tell your operator which, so nothing shadows anything: - **3a. Proxy (zero build work):** Poily renders complete pages; you add one rewrite rule. Presentation is Poily's. - **3b. Headless (your presentation):** your app queries Poily's public content API and renders the blog itself. This is how poily.com's own site works. ### 3a. Proxy — serve Poily-rendered pages under your domain Poily publishes your blog to a CDN origin keyed by your registered domain. You proxy `/blog` to it — on Netlify: ```toml [[redirects]] from = "/blog" to = "https://d1kjbg4330oa4p.cloudfront.net/" status = 200 force = true [[redirects]] from = "/blog/*" to = "https://d1kjbg4330oa4p.cloudfront.net//:splat" status = 200 force = true ``` RSS at `/blog/rss.xml`, sitemap alongside. Any host that can proxy (Vercel, nginx, Cloudflare) works the same way. The same proxy trick serves `forms.` if you want form links on your own hostname: proxy `/*` to `https://forms.poily.com/:splat`. URL shapes are handled origin-side (no index rewrites needed on your end): `/blog` and `/blog/` → index; `/blog/` (extensionless, **no trailing slash**) → article; `rss.xml` / `sitemap.xml` / `tag/` served directly. Do not link articles with a trailing slash. ### 3b. Headless — Poily as your content API Public GraphQL (JSON over POST, no auth) at `POST /graphql`: ```graphql # Listing (metadata only — no body): query { getPublicPosts(tenantId: "", limit: 100) { slug title excerpt subtitle coverImageUrl publishedAt updatedAt readingTimeMin wordCount tags authorName authorBio metaTitle metaDescription ogTitle ogDescription ogImage canonicalUrl } } # One post by slug (adds the rendered body): query { getPublicPost(tenantId: "", slug: "") { ...sameFieldsAsAbove showToc structuredData bodyHtml content } } ``` What to know as a consumer: - **`bodyHtml`** is the rendered article body (headings, embeds, tables, form CTAs with working URLs), wrapped in a `.poily-article` scope with a self-contained `