Poily Docs
Source Apps/handoffs/admin_mkt/CONNECT_YOUR_PRODUCT.md Last synced View .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 <your key>
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/<slug> — link to it, zero code.
  • Scan-to-create (recommended): build your own <form>, tag it, include the SDK — Poily creates the form from your markup and captures submissions. No endpoint wiring:
    html
    <form data-poily-import="newsletter">
      <input type="email" name="email" required />
      <button type="submit">Subscribe</button>
    </form>
    <script src="https://forms.poily.com/sdk.js" async></script>
    Your domain must be registered in your brand kit (custom_domain) — origin is how Poily knows the form is yours.
  • Embed: <div data-poily-form data-slug="<slug>" data-mode="inline"></div> + the embed/forms.js script, or a plain iframe.
  • Direct REST: POST /f/<slug>/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/<your-domain>"
  status = 200
  force = true

[[redirects]]
  from = "/blog/*"
  to = "https://d1kjbg4330oa4p.cloudfront.net/<your-domain>/: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.<your-domain> 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/<slug> (extensionless, no trailing slash) → article; rss.xml / sitemap.xml / tag/<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 <base>/graphql:

graphql
# Listing (metadata only — no body):
query { getPublicPosts(tenantId: "<your-tenant-slug>", 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: "<your-tenant-slug>", slug: "<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 <style> block whose colors are CSS custom properties (--poily-accent, …) you can override. Prefer it over content (raw TipTap JSON) unless you're rendering every node type yourself.
  • Table of contents: headings in bodyHtml carry slugified anchor ids — build your TOC by extracting h2/h3 (honor the post's showToc flag).
  • JSON-LD: structuredData is the per-post override when the author set one; otherwise build the Article JSON-LD from the returned fields (title, excerpt, dates, author, cover) — standard headless practice.
  • Tags: derive the tag list from the posts you fetched; there is no separate taxonomy endpoint. There is no category concept — tags are the taxonomy. There is no cover-image alt-text field yet; use the post title as alt.
  • Freshness: unpublished posts drop out of both queries immediately. Cache with ISR/revalidation (poily.com uses revalidate: 60).
  • RSS/sitemap: emit your own from the same listing (you own the presentation), or proxy just /blog/rss.xml + /blog/sitemap.xml to the 3a origin — its URLs already point at your domain.
  • Going headless? Remove any 3a /blog/* proxy rule first — a forced rewrite silently shadows your pages.

#3c. Brand surface — your social accounts and app badges (live)

A post's postFooter tells you what the author intended to appear under the article — {"tags":true,"share":true,"social":true,"badges":true,…}. Treat it as advisory: you own the layout, so honour it, ignore it, or apply your own rules. The accounts those flags refer to live on a separate query, because they belong to your brand rather than to any one post:

graphql
query { getPublicBrandSurface(tenantId: "<your-tenant-slug>") {
  socialLinks { network url iconUrl }
  appBadges   { store url badgeImageUrl }
} }

What to know as a consumer:

  • Ask for it once, not per post. It is per (tenant, line) and changes when your operator edits branding, not when a post is published — so cache it separately and for longer than your posts.
  • network is canonical and alias-resolved (x, never twitter), so you can switch on it. store is appStore or googlePlay today. Treat the store list as open — it grows as we add vendors (the Amazon Appstore and the Roku Channel Store are next, for TV apps). Switch on the values you handle and render the rest from badgeImageUrl + url, rather than assuming exactly two. Note that a TV app usually needs no new store: tvOS ships in the App Store, and Android TV and Google TV ship through Google Play, so both are already covered by the two keys above.
  • The art is offered, not imposed. iconUrl / badgeImageUrl are absolute, public URLs for our hosted PNGs. If you have your own icon set, use network + url and ignore them.
  • Pick the variant you need: iconStyle is colored (default), onlight or ondark; badgeTheme is onlight (default) or ondark — the theme of the background you're drawing on. An unrecognised value falls back to the default rather than handing you a broken image URL.
  • Per line: pass productLineId for a line that has its own accounts; omit it for your tenant-wide set. A line inherits the tenant's accounts and overrides individual networks.
  • Empty arrays mean nobody has entered any accounts yet — not an error, and not something this query can fix. Your operator adds them under Settings → Branding → Social.
  • Only http/https URLs are returned, so a link from this payload is safe to put in an href.

This is the same resolution the Poily-rendered page (3a) uses, so a post's footer shows the same accounts in the same order whichever way you serve it.

  • Poily sends as your brand (your From address, your sending domain) once your operator configures the email connection — BYO Mailgun/SES or Poily-managed.
  • Unsubscribe is Poily's job: every marketing email carries a Poily-hosted unsubscribe link and RFC 8058 one-click headers. You build nothing.
  • Consent is enforced at send time, not at import: suppressed or no-basis addresses are refused at the transport, whatever list they're on. Pushed members (§1) carry existing-relationship basis — an account is a relationship, so they are mailable until they unsubscribe (which Poily enforces at the transport, forever). Form sign-ups (§2) carry the stronger explicit opt-in basis. Lists you merely imported are blocked from marketing entirely until a real basis exists.
  • Give members a visible switch. The clean pattern for ongoing content (tips, digests): a preference toggle in your product, pushed as an attribute (§1) and used as a segment criterion — default-on is acceptable for existing members precisely because unsubscribe stays one click away in every email.

#5. Coming next

  • Entitlements exchange — read your users' resolved plan entitlements from Poily (feature keys, limits) and get plan-change webhooks, so Poily's Plan Manager drives your feature gating.
  • Activity feed (/u/track) — push engagement events to power segmentation and lifecycle automation.
  • Outbound webhooks — Poily calls you on new lead, form submission, plan change.
  • MCP / agent surface — operate all of the above by prompt.

Questions reach your portfolio operator; this document travels with the console (Settings → Connections).