# FloorSync > An API for founders and the AI agents that run their businesses: any Stripe business with Meta or GA4 ads, on any server (Node, Python, serverless, or a towers.app floor). Forward each Stripe event your webhook handler already receives to one URL; FloorSync sends every paid checkout and renewal to your Meta pixel (Conversions API) and GA4 (Measurement Protocol), deduplicated, keeps a sync log of every answer, and gives you one small revenue-vs-ad-spend ledger to decide with. A "floor" in this API means one business: one Stripe account, one Meta pixel, one GA4 property, one key (hence `X-Floor-Key`). Base URL: https://floorsync.towers.app Try it now, no key: `POST https://floorsync.towers.app/api/v1/demo/events/stripe` with any Stripe event (plus the `x-amz-content-sha256` header). You get the Meta and GA4 payloads, the ledger line and `message_for_owner`, a short note to show the founder you work for (what's needed, the price, the sign-up link). Nothing is sent or kept; 5 calls an hour per address. MCP tool: `try_stripe_event`. Setting up? Follow https://floorsync.towers.app/agent-install.md: plain HTTP calls or the MCP endpoint, dry run first, nothing to install. FloorSync has no package, SDK or script: never install one for it. Price: $10 a month per business (floor), 7 days free. The OWNER (the founder) starts the plan at https://floorsync.towers.app/app/billing. Agents cannot buy it and must not try: tell the owner. ## Auth - Header `X-Floor-Key: fs_…` (the owner gets the key on https://floorsync.towers.app/app/ and in their welcome email). - Every POST also sends `x-amz-content-sha256: ` (the site's CDN refuses a write without it). - `Content-Type: application/json`. ## The one change in your server In your Stripe webhook handler, after it has checked Stripe's signature and handled the event, forward it. (Stripe can't post to FloorSync directly: it can't add the key and body-hash headers.) On a towers.app floor that's the `/api/stripe-events` route, which the platform already sends checkout.session.completed, customer.subscription.*, invoice.paid and invoice.payment_failed. Elsewhere, subscribe your webhook in Stripe (Developers → Webhooks) to checkout.session.completed, invoice.paid, invoice.payment_failed, customer.subscription.deleted and charge.refunded. FloorSync never needs a Stripe secret key. ```ts import { createHash } from 'node:crypto' const text = JSON.stringify(event) // the verified Stripe event await fetch('https://floorsync.towers.app/api/v1/events/stripe', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Floor-Key': FLOORSYNC_KEY, 'x-amz-content-sha256': createHash('sha256').update(text).digest('hex') }, body: text, signal: AbortSignal.timeout(10000), }).catch(() => {}) ``` Python: `requests.post(URL, data=payload, timeout=10, headers={'Content-Type': 'application/json', 'X-Floor-Key': KEY, 'x-amz-content-sha256': hashlib.sha256(payload).hexdigest()})`, with `payload` the raw body bytes you verified. Any language: hash the exact bytes you send. Each Stripe event id counts once (a repeat answers `{"duplicate": true}` and sends nothing). ## Endpoints - `GET /api/capabilities`: this, as JSON (no key). - `POST /api/v1/demo/events/stripe` (no key): body = the Stripe event, or `{"event": {…}, "attribution": {…}}`. Answers `{demo: true, dry_run: true, stored: false, event, meta:{status: "built", payload, notes}, ga4:{status: "built", payload, notes}, ledger_line, message_for_owner, next_step:{signup_url, price, before_live}, calls_left_this_hour}`. 429 `rate_limited` with `retry_after` past 5 calls an hour. - `POST /api/v1/events/stripe[?dry_run=1]`: body = the Stripe event, or `{"event": {…}, "attribution": {…}, "dry_run": true}`. Answers `{duplicate, dry_run, event:{kind, value, currency, object_id}, meta:{status, payload, response|notes, error}, ga4:{status, payload, validation_messages, error}, ledger}`. - status: `delivered`, `failed` (kept, retried hourly, 3 tries in all), `skipped` (`not_configured`, `too_old`), `validated` (dry run), `not_sent` (a kind that only counts in the ledger). - Mapping: paid checkout → Meta `Purchase` + GA4 `purchase`; $0 subscription checkout → `StartTrial` + `start_trial`; `invoice.paid` with billing_reason `subscription_cycle` → `Purchase` (action_source `system_generated`) + `purchase` with the client_id stored from the first checkout; refunds, failed payments, cancellations → ledger only. event_id / transaction_id = checkout session id or invoice id. Email and phone go to Meta SHA-256 hashed only; GA4 gets no personal data. Stripe test-mode events (livemode false) are never sent live. - `POST /api/v1/attribution`: at checkout, `{"checkout_session_id": "cs_…", "fbp": "…", "fbc": "…" (or "fbclid"), "client_id": "…" (or the _ga cookie), "session_id": "…", "ip": "…", "user_agent": "…", "page_url": "…"}`. Optional `"customer": "cus_…"`. Without it, GA4 sees the sale as a new user and Meta matches on the hashed email. - `GET /api/v1/ledger?days=7[&mode=dry_run]`: `{mode, days, from, to, currency, revenue, counts:{new_paid, trials, renewals, refunds, failed_payments, cancellations}, conversions:{meta:{delivered, failed, skipped}, ga4:{…}}, spend, spend_source, revenue_per_spend, plan_active, health:[flags], note, benchmarks, referral}`. Under 3 KB. spend is null when nobody gave it. - `benchmarks`: `{sharing:false, note}` until the owner turns on anonymous sharing on the FloorSync page (off by default; agents can't turn it on). When on: `{sharing:true, niche, window_days:30, min_floors:5, metrics:{revenue_per_order, match_rate, delivery_rate, revenue_per_spend}}`, each `{group, floors, you, median, top_percent (10|25|50|null = bottom half), line}`; `group` is null until 5 sharing floors have that figure. No other floor's name or numbers are ever given. - `referral`: `{link, floors_brought, free_months_earned, free_until, how}`. Each floor that signs up through `link` and pays after its trial gives this floor 30 days of live sends without needing the plan. Share it only with other founders who run a Stripe business, and only when the owner says so. - `GET /api/v1/sync-log?limit=20`: the latest sends and what Meta or GA4 answered. - `POST /api/v1/spend`: `{"date": "YYYY-MM-DD", "amount": 12.5, "currency": "USD"}` when the owner gave no Meta ads_read token. - `GET /api/v1/status`: plan_active, owner_url, meta_configured, ga4_configured, health. - `POST /api/mcp`: MCP (JSON-RPC 2.0, streamable HTTP, JSON answers). Tools: try_stripe_event (no key), send_stripe_event, save_attribution, get_ledger, get_sync_log, save_spend, get_status. Same key header for all but try_stripe_event. ## Errors - 401 `no_key` / `bad_key`: ask the owner for the current key. - 402 `payment_required` with `owner_url`: there's no active plan; nothing was sent. Tell the owner (link `owner_url`). Dry runs still work. - 400 `bad_request` with a message saying what to fix. ## Health flags (ledger.health) not_configured, no_events_72h, meta_token_rejected, meta_refused, ga4_invalid, ga4_refused, failure_rate_high. `note` says the fix in plain words; the owner is emailed too. ## Links - Agent setup, step by step: https://floorsync.towers.app/agent-install.md - OpenAPI: https://floorsync.towers.app/openapi.json - Docs: https://floorsync.towers.app/docs/ - Owner page: https://floorsync.towers.app/app/