<!-- https://docs.poily.com/connect · source: Apps/handoffs/admin_mkt/CONNECT_YOUR_PRODUCT.md · last synced: 2026-11-02 -->
# 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 `id`s — 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.

## 4. Email & consent — what Poily handles for you (live)

- 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).*
