# Levy & Loom — Integration Playbook

*For merchants, their engineers, and the coding agents working on their behalf.*

Levy & Loom is a payment loom. Your business is the warp, each subscription payment is the weft, and the platform is the frame that holds the two in tension. This playbook is the pattern card: everything an engineer or an AI agent needs to weave a merchant application into Levy & Loom, in the order they need it, with nothing left to guess.

Three promises before we start, because they shape every decision below:

- **Your customers pay you, not us.** Every payment lands in your own Stripe account. Levy & Loom collects a 1% platform fee at the moment of payment and never holds your money.
- **Every message is signed.** What we send you carries an HMAC signature made with a secret only you hold. What we publish to the world carries an Ed25519 signature anyone can check. Nothing has to be taken on trust.
- **Amounts are integers.** Minor units, always. `1250` is £12.50. There are no floats anywhere in this system, and there should be none in yours.

`BASE` throughout means the gateway host, in production `https://api.levyandloom.com`.

---

## Part 1 · The 10-second integration

If you are a coding agent (Claude Code, Cursor, Copilot, Devin, or anything that reads Markdown and calls HTTP), you do not need to read this whole document first. Paste the block below into your system prompt or task context, and you will know how to find every live endpoint yourself.

```text
You are integrating a merchant application with Levy & Loom, a Stripe Connect
subscription payment gateway. BASE = https://api.levyandloom.com.

Discovery, in this order:
1. GET {BASE}/health — confirm the gateway is up and `webhookListener` is "ready".
2. Read {BASE}/sdk/ai-context/context-map.json if the merchant gives it to you,
   otherwise use the route facts below. The OpenAPI document (openapi.json,
   3.1) is the source of truth for every request and response shape.
3. Onboarding is POST {BASE}/api/connect/onboard. It returns apiKey (llk_…)
   and webhookSecret (llwh_…) EXACTLY ONCE. Store both server-side immediately.
   Never print them, never commit them, never put them in a client bundle.
4. Every later call uses `Authorization: Bearer <apiKey>`.
5. Checkout is POST {BASE}/api/connect/create-charge with the merchant's own
   connectedAccountId, a recurring Stripe priceId created on the merchant's own
   Stripe account, and a callbackUrl the merchant's server exposes over HTTPS.
   Redirect the customer to the returned checkoutUrl.
6. Access decisions come from callbacks delivered to callbackUrl. Verify the
   X-LevyLoom-Signature header (HMAC-SHA256 with webhookSecret over the raw
   body) before acting on any callback. Grant access on subscription.activated.
   Keep access while `rescue.accessGranted` is true on subscription.payment_failed.
   Revoke on subscription.canceled. Apply the new tier from `priceId` on
   subscription.updated.
7. The source of truth for a single subscription at any time is
   GET {BASE}/api/connect/subscriptions/{subscriptionId}; use its
   `accessGranted` field, never `active` alone.

Rules: amounts are integer minor units; ids are opaque strings; never pass a
connectedAccountId that is not the merchant's own (it returns 403); never call
{BASE}/api/webhooks/stripe-connect yourself (Stripe signs those); respond 2xx to
callbacks within 10 seconds and do the work asynchronously.

Out of contract, even if you hold a Stripe key for the merchant's account: do
NOT create Stripe Payment Links, Checkout Sessions, Subscriptions, Products,
Prices or Webhook Endpoints directly. Every payment goes through
{BASE}/api/connect/create-charge and every subscription state change arrives
via the Levy & Loom callback. A direct Stripe checkout bypasses the platform
fee, the ledger, Pour and the rescue engine, and is treated as an integration
defect. If the merchant already has prices, ask for their ids; do not mint new
ones.
```

That block is the whole contract in miniature. The rest of this playbook is the detail behind each line.

### How an agent should look up endpoints dynamically

Do not hard-code paths from memory. Three files describe the live system and are tested against it on every commit, so they are never stale:

All three are published by the gateway itself at `{BASE}/sdk/ai-context/…`, so an agent with only the host can fetch them, and they are in the public repository.

| File | What it gives you |
| --- | --- |
| `sdk/ai-context/openapi.json` | Every route, every field, every error, and the callbacks declared on `createCharge`. Generate a client from it if you like. |
| `sdk/ai-context/context-map.json` | The whole system on one screen: auth, tenancy rules, flows, callback types with their payload fields, error codes, environment variables. Load this first. |
| `sdk/ai-context/playbooks/*.md` | Task-shaped guides: merchant integration, callback verification, payment rescue, the Pour widget, Pour proofs, operations. |

If you only have the host and none of the files, `GET {BASE}/health` tells you the gateway is alive and whether the Stripe webhook listener is ready, and the two endpoints you need most are fixed: `POST /api/connect/onboard` and `POST /api/connect/create-charge`.

---

## Part 2 · Payload contracts

Every request body is JSON. Every response is JSON. Errors always look the same:

```json
{ "error": { "code": "invalid_request", "message": "priceId is required and must be a Stripe price id (price_...)" } }
```

`code` is stable and machine-readable. Rate-limited responses (`429`) add `retryAfterSeconds`.

### 2.1 Onboarding: `POST /api/connect/onboard`

One call registers the merchant, creates their Stripe connected account, and returns the only copy of their credentials. If the platform operator has configured a platform key, the call must carry `X-Platform-Key`; otherwise it is open.

**Request**

```http
POST {BASE}/api/connect/onboard
Content-Type: application/json
X-Platform-Key: <only if the platform requires it>
```
```json
{
  "name": "Adisa Studio",
  "email": "owner@adisa.example",
  "webhookUrl": "https://api.adisa.example/levy-loom/callback",
  "country": "GB",
  "returnUrl": "https://app.adisa.example/billing/connected",
  "refreshUrl": "https://app.adisa.example/billing/connect-again"
}
```

| Field | Required | Meaning |
| --- | --- | --- |
| `name` | yes | Business name shown to Stripe and on your certificates. |
| `email` | yes | Contact email Stripe uses for the connected account. |
| `webhookUrl` | no | Default callback address for this merchant. `create-charge` sets a per-checkout `callbackUrl` that takes precedence; this is the fallback. Must be HTTPS in production and must resolve to a public address. |
| `country` | no | ISO 3166-1 alpha-2. Defaults to the platform's country. |
| `returnUrl`, `refreshUrl` | no | Where Stripe sends the merchant's browser after onboarding, or when an onboarding link expires. Defaults are branded Levy & Loom pages. |

**Response `201`**

```json
{
  "merchantId": "mer_3c8ae33a9da6f43021c64a3c",
  "connectedAccountId": "acct_1UJHejJV9vzAcCVd",
  "onboardingUrl": "https://connect.stripe.com/setup/s/acct_1UJHejJV9vzAcCVd/…",
  "expiresAt": 1790270000,
  "apiKey": "llk_…",
  "webhookSecret": "llwh_…",
  "platformFeePercent": 1
}
```

Two fields appear here and nowhere else, ever again: `apiKey` and `webhookSecret`. Store both server-side before you do anything else. The API key is kept by Levy & Loom only as a one-way hash; if you lose it, rotate it with `POST /api/connect/keys/rotate`.

Send the merchant's browser to `onboardingUrl`. Stripe verifies their business and returns them to your `returnUrl`. Readiness is a read away:

```http
GET {BASE}/api/connect/merchants/{merchantId}
Authorization: Bearer llk_…
```

Proceed when `chargesEnabled` is `true`. If the link expired before the merchant finished, `POST /api/connect/onboard/refresh` mints a fresh one.

### 2.2 Dynamic tier pricing

Levy & Loom does not own your prices. Your tiers are Stripe Prices on **your own connected account**, and the gateway sells whichever one you name. This is what makes tiers dynamic: create a price, and it is sellable immediately, no platform change needed.

Create prices with Stripe's API on the connected account (note the `Stripe-Account` header) or in your Stripe dashboard. A recurring price is the only requirement:

```http
POST https://api.stripe.com/v1/products
Stripe-Account: acct_1UJHejJV9vzAcCVd

name=Adisa Pro
```
```http
POST https://api.stripe.com/v1/prices
Stripe-Account: acct_1UJHejJV9vzAcCVd

product=prod_…&currency=gbp&unit_amount=1500&recurring[interval]=month
```

A sensible tier map for a three-tier product lives in your own configuration, not ours:

```json
{
  "tiers": {
    "starter": { "priceId": "price_1Starter…", "label": "Starter", "monthly": 500 },
    "pro":     { "priceId": "price_1Pro…",     "label": "Pro",     "monthly": 1500 },
    "studio":  { "priceId": "price_1Studio…",  "label": "Studio",  "monthly": 4900 }
  }
}
```

Per-seat pricing is `quantity` on the checkout call. Trials are `trialPeriodDays`. Yearly plans are simply another price with `recurring[interval]=year`. Because the fee is a percentage taken at payment time, nothing about the fee changes when you change prices.

### 2.3 Checkout: `POST /api/connect/create-charge`

Creates a Stripe Checkout Session in subscription mode on your connected account, with the 1% platform fee attached, and records where to send callbacks for the resulting subscription.

**Request**

```http
POST {BASE}/api/connect/create-charge
Authorization: Bearer llk_…
Content-Type: application/json
```
```json
{
  "connectedAccountId": "acct_1UJHejJV9vzAcCVd",
  "priceId": "price_1Pro…",
  "quantity": 1,
  "trialPeriodDays": 14,
  "customerEmail": "amina@example.com",
  "clientReferenceId": "user_84213",
  "callbackUrl": "https://api.adisa.example/levy-loom/callback",
  "successUrl": "https://app.adisa.example/billing/success?session={CHECKOUT_SESSION_ID}",
  "cancelUrl": "https://app.adisa.example/billing/cancelled"
}
```

| Field | Required | Meaning |
| --- | --- | --- |
| `connectedAccountId` | yes | Must be **your** account. Any other id returns `403 forbidden`; tenants cannot reach each other. |
| `priceId` | yes | A recurring Stripe price on your account. |
| `callbackUrl` | yes | HTTPS endpoint on your server that receives every subscription event for this checkout. Stored in Stripe metadata and in the gateway, so it survives everything. |
| `successUrl`, `cancelUrl` | yes | Absolute URLs. Stripe substitutes `{CHECKOUT_SESSION_ID}` if you include it. |
| `clientReferenceId` | recommended | Your own user id (≤ 200 chars). It is echoed in every callback, so you never have to map Stripe ids back to users. |
| `customerEmail` | no | Pre-fills the checkout. |
| `quantity` | no | Seats. Default `1`. |
| `trialPeriodDays` | no | Free trial, in days. |
| `allowPromotionCodes` | no | `true` shows a promotion-code field on the checkout. Create coupons and promotion codes on your own Stripe account; the gateway never sees them. |

**Response `201`**

```json
{
  "sessionId": "cs_live_a1B2c3…",
  "checkoutUrl": "https://checkout.stripe.com/c/pay/cs_live_a1B2c3…",
  "connectedAccountId": "acct_1UJHejJV9vzAcCVd",
  "merchantId": "mer_3c8ae33a9da6f43021c64a3c",
  "platformFeePercent": 1
}
```

Redirect the customer to `checkoutUrl`. Do not grant access when they come back to `successUrl`; browsers get lost, tabs get closed. Grant access when `subscription.activated` arrives at your `callbackUrl`, which happens within seconds of payment and is signed.

### 2.4 Self-service: the customer portal

When a customer wants to update their card, switch plan or cancel, do not build that screen. Mint a Stripe Customer Portal link on your own account and redirect them:

```http
POST {BASE}/api/connect/portal-session
Authorization: Bearer llk_…
Content-Type: application/json
```
```json
{ "customerId": "cus_…", "returnUrl": "https://app.adisa.example/billing" }
```

Response `201` carries a short-lived `url`. The `customerId` is the one delivered in `subscription.activated`; a customer that is not yours returns `404`. What the portal allows (plan switching between which prices, immediate or end-of-period cancellation) is configured once in your Stripe dashboard under Settings → Customer portal. Every change the customer makes there reaches you as a normal `subscription.updated` or `subscription.canceled` callback.

### 2.5 The callbackUrl contract

`callbackUrl` is mandatory on every checkout because it is where the truth is delivered. Its rules:

- HTTPS, absolute, on a public host. In production the gateway resolves the hostname and refuses private, loopback and link-local addresses before sending anything.
- It is stored in the Checkout Session's metadata and in the subscription's metadata on Stripe, and in the gateway's own record. If the gateway ever needs to reconstruct where to send an event, all three agree.
- It may differ per checkout. A merchant running two products can point each at its own receiver.
- It must answer `2xx` within 10 seconds. Queue the work and return.

---

## Part 3 · The webhook dispatch

Every event you receive is an envelope:

```json
{
  "id": "1d0c6a2e-4c1f-4f3a-9d3d-0d7c9f0a8b21",
  "type": "subscription.activated",
  "source": "levy-loom",
  "createdAt": "2026-09-24T19:02:23.667Z",
  "data": { }
}
```

with these headers:

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `User-Agent` | `LevyLoom-Webhooks/1.0` |
| `X-LevyLoom-Event` | the `type`, repeated for routing without parsing |
| `X-LevyLoom-Delivery` | the `id`, a UUID unique per delivery; use it for idempotency |
| `X-LevyLoom-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |

Delivery is retried with exponential backoff on `5xx`, `408`, `429` and network errors, up to the platform's `CALLBACK_MAX_ATTEMPTS` (3 by default). Other `4xx` responses are not retried. Redirects are never followed. The same `X-LevyLoom-Delivery` id is sent on every retry, so de-duplicate on it.

### 3.1 Verifying the signature

**The signature on merchant callbacks is HMAC-SHA256, keyed with your `webhookSecret`.** It is deliberately symmetric: only you and Levy & Loom hold the secret, so a valid MAC proves both origin and integrity with one cheap operation. (Levy & Loom's *public* artefacts, the Pour statistics feed and the quarterly Pour Ledger Proof certificates, are signed with Ed25519 instead, because anyone in the world must be able to verify those with a published key. Part 4 covers them. Do not mix the two up: your `webhookSecret` never verifies an Ed25519 signature, and no Ed25519 key ever signs a callback.)

The signed string is the timestamp, a dot, and the **raw request body bytes** exactly as received:

```
HMAC-SHA256( webhookSecret, "<t>.<raw body>" )  →  hex
```

Verify before you parse, and reject anything older than five minutes to defeat replay.

**Node.js**

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyLevyLoom(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(signatureHeader.split(',').map((kv) => kv.split('=')));
  const t = Number.parseInt(parts.t, 10);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express: capture the raw body, verify, then acknowledge fast.
app.post('/levy-loom/callback', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyLevyLoom(req.body.toString('utf8'), req.get('X-LevyLoom-Signature'), process.env.LEVY_LOOM_WEBHOOK_SECRET)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body);
  queue.push(event);          // do the real work off the request
  res.status(200).end();
});
```

**Python**

```python
import hmac, hashlib, time

def verify_levy_loom(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    try:
        t = int(parts["t"]); v1 = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(int(time.time()) - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
```

During a secret rotation (`POST /api/connect/keys/rotate`), the previous secret stays valid for 24 hours. If you rotate, verify against the new secret first and the old one second until the window closes.

### 3.2 Payment success: `subscription.activated`

Sent when the customer completes checkout. This is the moment to grant access.

```json
{
  "id": "…",
  "type": "subscription.activated",
  "source": "levy-loom",
  "createdAt": "2026-09-24T19:02:23.667Z",
  "data": {
    "subscriptionId": "sub_1UJ…",
    "customerId": "cus_…",
    "customerEmail": "amina@example.com",
    "clientReferenceId": "user_84213",
    "checkoutSessionId": "cs_live_a1B2c3…",
    "connectedAccountId": "acct_1UJHejJV9vzAcCVd",
    "merchantId": "mer_3c8ae33a9da6f43021c64a3c",
    "priceId": "price_1Pro…",
    "status": "active",
    "active": true
  }
}
```

Action: look up your user by `clientReferenceId`, store `subscriptionId` against them, grant the tier that `priceId` maps to. Renewal payments do not re-send this event; they are recorded silently in the ledger. You will hear again only when something changes.

### 3.3 Failed invoice and the grace period: `subscription.payment_failed`

Sent when a renewal payment fails. The subscription becomes `past_due`, and a billing grace period opens (7 days by default, set by the platform's `PAYMENT_GRACE_DAYS`). The grace window starts at the **first** failure of a billing cycle and is not extended by Stripe's automatic retries. Alongside the facts, you receive a ready-made rescue plan.

```json
{
  "type": "subscription.payment_failed",
  "data": {
    "subscriptionId": "sub_1UJ…",
    "customerId": "cus_…",
    "clientReferenceId": "user_84213",
    "invoiceId": "in_1UJ…",
    "amountDue": 1500,
    "currency": "gbp",
    "attemptCount": 1,
    "nextPaymentAttempt": "2026-10-01T09:00:00.000Z",
    "status": "past_due",
    "active": false,
    "accessGranted": true,
    "rescue": {
      "stage": "grace",
      "graceDays": 7,
      "graceUntil": "2026-10-01T19:02:23.667Z",
      "daysRemaining": 7,
      "failures": 1,
      "accessGranted": true,
      "invoice": {
        "id": "in_1UJ…",
        "amountDue": 1500,
        "currency": "gbp",
        "amountText": "15.00 GBP",
        "attemptCount": 1,
        "nextPaymentAttempt": "2026-10-01T09:00:00.000Z",
        "hostedInvoiceUrl": "https://invoice.stripe.com/i/acct_…/…",
        "invoicePdf": "https://pay.stripe.com/invoice/…/pdf",
        "lastError": "Your card was declined.",
        "declineCode": "insufficient_funds"
      },
      "customer": { "email": "amina@example.com", "name": "Amina Okafor" },
      "recovery": {
        "updatePaymentMethodUrl": "https://invoice.stripe.com/i/acct_…/…",
        "retryAt": "2026-10-01T09:00:00.000Z",
        "recommendedAction": "retry_later"
      },
      "suggestedMessage": {
        "subject": "Action needed: your Adisa Studio payment",
        "previewText": "Your access continues for 7 more days.",
        "body": "Hi Amina,\n\nWe couldn’t process your latest payment of 15.00 GBP for Adisa Studio. …"
      }
    }
  }
}
```

Action, in order of importance:

1. **Do not revoke access.** `rescue.accessGranted` is `true` while `daysRemaining > 0`. Keep serving the customer.
2. Send the customer a recovery message. Use `suggestedMessage` as-is or write your own around `recovery.updatePaymentMethodUrl`.
3. Watch for the resolution: `subscription.payment_recovered` (access continues, clear your dunning state) or, if the grace period lapses, a later `subscription.canceled`.

If you would rather not track the window yourself, read `GET /api/connect/subscriptions/{subscriptionId}` whenever you need to decide; its `accessGranted` field already honours the grace period.

### 3.4 Tier changes, including downgrades: `subscription.updated`

Sent whenever Stripe reports a change to the subscription that matters to access: price (upgrade or downgrade), quantity, a scheduled cancellation, or status. `changes` tells you exactly what moved, with the old and new values.

```json
{
  "type": "subscription.updated",
  "data": {
    "subscriptionId": "sub_1UJ…",
    "customerId": "cus_…",
    "clientReferenceId": "user_84213",
    "priceId": "price_1Starter…",
    "quantity": 1,
    "status": "active",
    "active": true,
    "cancelAtPeriodEnd": false,
    "currentPeriodEnd": "2026-10-24T19:02:23.000Z",
    "pauseBehavior": null,
    "pauseResumesAt": null,
    "changes": {
      "priceId": { "from": "price_1Pro…", "to": "price_1Starter…" }
    },
    "connectedAccountId": "acct_1UJHejJV9vzAcCVd",
    "merchantId": "mer_3c8ae33a9da6f43021c64a3c"
  }
}
```

Action: apply the tier that the new `priceId` maps to. A downgrade is just `changes.priceId.to` pointing at a cheaper price; an upgrade is the same shape the other way. `changes.cancelAtPeriodEnd.to === true` means the customer has asked to leave at `currentPeriodEnd`; keep access until then. Two neighbours of this event use the same payload: `subscription.paused` (collection paused, `active: false`) and `subscription.resumed`.

### 3.5 The rest of the family

| Type | When | What to do |
| --- | --- | --- |
| `subscription.payment_recovered` | A `past_due` subscription paid | Clear dunning state; access continues |
| `subscription.paused` / `subscription.resumed` | Collection paused or lifted | Suspend / restore access |
| `subscription.canceled` | Subscription deleted | Revoke access |
| `subscription.refunded` | A charge was refunded (partial or full) | Update your records; the access policy is yours |
| `subscription.disputed` | A chargeback opened | Submit evidence in Stripe before `evidenceDueBy` |
| `subscription.dispute_closed` | Chargeback resolved | Informational; `won` and `restoredAmount` included |

Every one of these carries `subscriptionId`, `clientReferenceId`, `connectedAccountId` and `merchantId`, so a single handler keyed on `type` covers the lot.

---

## Part 4 · What the world can verify

Two things Levy & Loom publishes are signed with **Ed25519**, so that a customer, a donor or a journalist can check them with nothing but a public key:

- **The Pour statistics feed**, `GET /api/public/sdk/widget-stats`, verified with `GET /api/public/sdk/public-key`. Embed it with the reference `<levy-loom-pour-stats>` web component, which verifies in the browser.
- **Your quarterly Pour Ledger Proof**, `GET /api/connect/merchant/pour-proof/{quarter}` (for example `2026-Q3`), a printable certificate of your contribution, verified with `GET /api/public/pour-proof/public-key` or by posting its JSON to `POST /api/public/pour-proof/verify`.

Both are covered in their own playbooks. They are the reason the signature scheme is split: symmetric HMAC where a shared secret exists, asymmetric Ed25519 where the audience is everyone.

---

## Part 5 · Checklist

Before the first real customer:

- [ ] `apiKey` and `webhookSecret` stored server-side, never in a client bundle or a repository.
- [ ] Prices created on **your** Stripe account; `priceId` values recorded in your tier map.
- [ ] `callbackUrl` live over HTTPS, verifying `X-LevyLoom-Signature` on the raw body, de-duplicating on `X-LevyLoom-Delivery`, answering `2xx` in under 10 seconds.
- [ ] Access granted on `subscription.activated`, kept during rescue, revoked on `subscription.canceled`, re-tiered on `subscription.updated`.
- [ ] `GET /api/connect/merchants/{merchantId}` shows `chargesEnabled: true`.
- [ ] One end-to-end test purchase, then one refund, watching the callbacks arrive.
- [ ] No Stripe Payment Links, direct Checkout Sessions, or Stripe webhook endpoints of your own on the merchant account. If any exist from earlier experiments, deactivate them: they bypass the gateway.

Questions, at any stage: **hello@levyandloom.com**. One mailbox reaches the whole team.
