# Connected services

The call route that lets a generated app use the owner's connected services without holding a secret, and the limits on it.

## What the call route is

A generated app has no server, so it cannot hold an API key. Instead it posts to Ripping, which forwards the request to the provider with the secret the owner stored on their Connections page:

```http
POST /api/apps/<projectId>/call
Authorization: Bearer <token>
Content-Type: application/json

{ "provider": "resend", "action": "send", "to": "ada@example.com", "subject": "Hi", "text": "…" }
```

Only the actions listed on this page are allowed. CORS is open, because apps run on other origins. Every failure is `{ "error": "<message>" }`.

The `pay` provider (the app's own catalogue and checkout) is documented in [Taking payments](payments.md); it is the one provider a visitor may call without signing in.

## Who may call, and how often

1. Per IP: 60 calls a minute. Per app: 300 a minute. Over either: `429 Too many requests. Slow down and try again in a minute.`
2. Everything but `pay` needs a signed-in app user whose account is active: `401 Sign in to this app first.`
3. Per user per app: 60 a minute and 500 a day: `429 You have made too many requests from this app. Try again later.`
4. The owner can pause connected services for the app: `403 This app's access to connected services is paused by its owner.`
5. Each app has a monthly allowance set by the owner's plan. Past it: `429 This app has used its <cap> connected-service calls for the month.`

A body that is not JSON: `400 bad json`. An unknown project id: `404 unknown app`. A provider or slot the owner has not connected: `400 <provider> (<slot>) isn't connected for this app's owner`. A provider the owner connected more than once is picked with `"slot": "<name>"`; the default slot is `""`.

A provider that fails while being called returns `502` with the provider's message (secrets scrubbed).

## Providers and actions

Unless noted, the provider's own JSON reply is passed straight back with the provider's status code.

### resend

```json
{ "provider": "resend", "action": "send", "to": "ada@example.com", "subject": "Your receipt", "html": "<p>…</p>", "text": "…", "reply_to": "hello@example.com" }
```

`from` is the address the owner set on the connection. `subject` is cut to 200 characters. Reply: Resend's, for example `{ "id": "…" }`.

### openai

```json
{ "provider": "openai", "action": "chat", "model": "gpt-4o-mini", "messages": [ { "role": "user", "content": "…" } ], "max_tokens": 1000, "temperature": 0.7 }
```

`model` defaults to `gpt-4o-mini`, `max_tokens` to 1000 and is capped at 4000, `temperature` to 0.7. Reply: the chat completion as OpenAI returns it.

### anthropic

```json
{ "provider": "anthropic", "action": "chat", "model": "claude-haiku-4-5-20251001", "system": "…", "max_tokens": 1000, "messages": [ { "role": "user", "content": "…" } ] }
```

`model` defaults to `claude-haiku-4-5-20251001`, `max_tokens` to 1000 and is capped at 4000. Reply: the message as the API returns it.

### google

The owner's Google account (Gmail and Calendar), through a refreshed access token. Without a connection: `400 Google isn't connected`. An action not listed: `400 Unsupported Google action: <action>`.

```json
{ "provider": "google", "action": "gmail.send", "to": "ada@example.com", "subject": "…", "html": "<p>…</p>" }
```

Sends from the owner's Gmail. `to` is required (`400 to is required`); `subject` is cut to 200 characters; `html` wins over `text`. Reply: Gmail's message resource.

```json
{ "provider": "google", "action": "gmail.list", "q": "is:unread", "max": 20 }
```

```json
{ "threads": [ { "id": "…", "from": "…", "subject": "…", "date": "…", "snippet": "…" } ] }
```

`max` is capped at 50, and details are fetched for the first 20 threads.

```json
{ "provider": "google", "action": "calendar.list", "from": "2026-09-20T00:00:00.000Z", "to": "2026-09-27T00:00:00.000Z", "max": 50 }
```

```json
{ "events": [ { "id": "…", "title": "…", "start": { "dateTime": "…" }, "end": { "dateTime": "…" }, "location": "", "link": "https://…" } ] }
```

`from` defaults to the start of today, `to` to seven days from now, `max` to 50 (capped at 100). The primary calendar only.

```json
{ "provider": "google", "action": "calendar.create", "title": "Kick-off", "description": "…", "location": "…", "start": { "dateTime": "…" }, "end": { "dateTime": "…" }, "attendees": [ { "email": "…" } ] }
```

`title` is cut to 200 characters and defaults to `Event`. Reply: the created event.

### supabase

```json
{ "provider": "supabase", "action": "public" }
```

```json
{ "url": "https://xyz.supabase.co", "anon_key": "…" }
```

The connected project's URL and anon key, for a client the app builds itself (the `supabase-store` recipe).

### rest

A generic JSON API the owner connected with a base URL and, optionally, a header name and secret.

```json
{ "provider": "rest", "action": "call", "path": "/v1/items", "method": "POST", "body": { "name": "…" } }
```

`path` must start with `/` and contain no `..` (`400 path must be relative`). `method` is one of GET, POST, PUT, PATCH, DELETE, default GET (`400 bad method`). `body`, when present, is sent as JSON. The request times out after 15 seconds. Reply: the JSON the API returned, or `{ "body": "<text>" }` (cut to 20,000 characters) when it was not JSON, with the API's status code. `action` is ignored for this provider.

### stripe

The owner's own Stripe key, creating a Checkout Session directly.

```json
{
  "provider": "stripe", "action": "checkout", "mode": "payment",
  "success_url": "https://myapp.example/#/thanks", "cancel_url": "https://myapp.example/#/",
  "line_items": [ { "price": "price_…", "quantity": 1 } ]
}
```

```json
{ "id": "cs_…", "url": "https://checkout.stripe.com/…" }
```

Every line must name a `price` id (`price_…`) from the owner's Stripe account; `quantity` is clamped to 1–99 and defaults to 1; `mode` defaults to `payment`. A line with an amount instead of a price is refused with `400`: "Each line item needs a Stripe price id (price_…) from the owner's account; amounts from the browser are refused. To sell products set on Ripping, use provider \"pay\"." On a Stripe error the error body is returned with Stripe's status. Prefer the `pay` provider: it takes prices from the owner's catalogue rather than the request.

### payments

Ripping Payments (the owner's connected Stripe account) with prices that live in Stripe. Needs a signed-in user. Without a connected account that can take charges: `400 <Site> Payments isn't set up for this app's owner (Payments tab).` An unknown action: `400 unknown payments action; use checkout, subscription or portal`.

```json
{ "provider": "payments", "action": "checkout", "mode": "subscription", "success_url": "https://…/#/billing?upgraded=1", "cancel_url": "https://…/#/billing", "line_items": [ { "price": "price_…", "quantity": 1 } ] }
```

```json
{ "id": "cs_…", "url": "https://checkout.stripe.com/…" }
```

Every line must carry a `price` from the owner's Stripe account; otherwise: "Every line item needs a `price` from your Stripe account. Create the product and price in Stripe, then pass its price id; an amount sent from the browser is a number the buyer controls." Up to 20 lines (`line_items is required` when empty); `success_url` and `cancel_url` must be absolute. A subscription checkout uses the caller's email unless `customer_email` is given, so the subscription can be found afterwards.

```json
{ "provider": "payments", "action": "subscription" }
```

```json
{ "subscription": { "active": true, "status": "active", "priceId": "price_…", "subscriptionId": "sub_…", "currentPeriodEnd": "…", "cancelAtPeriodEnd": false } }
```

The signed-in person's most useful subscription (an active one if any, else the latest), or `null`.

```json
{ "provider": "payments", "action": "portal", "return_url": "https://…/#/billing" }
```

```json
{ "url": "https://billing.stripe.com/…" }
```

Stripe's billing portal for the person's customer record. `return_url` must be absolute (`return_url must be an absolute URL`); with no subscription: `No subscription to manage yet.`

### Anything else

`400 Unsupported: <provider>/<action>`.

## Slack

`src/lib/slack.ts` ships in every app. It needs the owner to have connected Slack on Connections → App Functions, and a signed-in app user to call it; a public visitor cannot post to the owner's workspace.

```ts
function notify(text: string, opts?: { channel?: string }): Promise<{ ts: string; channel: string }>;
function useChannels(): { channels: { id: string; name: string; private: boolean }[]; ready: boolean; error: string | null };
```

`text` may use Slack's mrkdwn. `channel` is a channel id or a name; left out, the message goes to the channel the owner chose on the app's Settings → Notifications. On the wire these are the `slack` provider's `post` and `channels` actions on the call route, with the bot token added server-side.

The owner's own events (a visitor's inbox message, a sign-up, a sale, a refund, a chargeback) are posted by Ripping itself when the owner sets a channel and ticks the events on Settings → Notifications. An app needs no code for those.

## Notion

`src/lib/notion.ts` ships in every app. It needs the owner to have connected Notion on Connections → App Functions and shared databases with the Ripping integration; every call needs a signed-in app user.

```ts
function useDatabases(): { databases: { id: string; title: string; url: string }[]; ready: boolean; error: string | null };
function queryDatabase(database: string, limit?: number): Promise<{ id: string; url: string; created: string; values: Record<string, unknown> }[]>;
function addRow(database: string, values: Record<string, unknown>): Promise<{ id: string; url: string }>;
```

Rows are flattened to plain values keyed by the database's property names: title and text become strings, number a number, checkbox a boolean, select and status their option name, multi-select an array, date the ISO start, url, email and phone their strings. `addRow` maps the same way back, using the database's own schema; a key that matches no property is ignored, and `None of the values match a property in that database` is the error when nothing matched. On the wire these are the `notion` provider's `databases`, `query` and `add` actions.

The owner can also have every inbox message logged into a database from the app's Settings → Notifications, with no code in the app.

## HubSpot

`src/lib/hubspot.ts` ships in every app. It needs the owner to have connected HubSpot on Connections → Marketing, and a signed-in app user to call it.

```ts
function upsertContact(email: string, props?: Record<string, unknown>): Promise<{ id: string; created: boolean }>;
function createDeal(o: { name: string; amount?: number; email?: string; stage?: string; pipeline?: string }): Promise<{ id: string }>;
function useRecentContacts(limit?: number): { contacts: Contact[]; ready: boolean; error: string | null };
```

`upsertContact` looks the email up first and updates the contact if it exists. Standard property names are `firstname`, `lastname`, `phone`, `company`, `website`, `jobtitle`, `lifecyclestage`; a custom property works when it exists in the portal. `createDeal` with an `email` ties the deal to that contact, creating the contact if needed. On the wire these are the `hubspot` provider's `contact.upsert`, `deal.create` and `contacts.recent` actions.

The owner can also have every inbox message become a contact from the app's Settings → Notifications; the message's email, name, phone and company fields are matched by name.

## Mailchimp

`src/lib/mailchimp.ts` ships in every app. It needs the owner to have connected Mailchimp on Connections → Marketing, and a signed-in app user to call it.

```ts
function subscribe(email: string, o?: { audience?: string; firstName?: string; lastName?: string; tags?: string[] }): Promise<{ id: string; status: string }>;
function useAudiences(): { audiences: { id: string; name: string; members: number }[]; ready: boolean; error: string | null };
```

`subscribe` adds the address as subscribed, or re-subscribes it, sets first and last name as merge fields, and applies the tags. With no `audience` it uses the one the owner chose on Settings → Notifications; with none set there either, the call fails with `Pick an audience`. On the wire these are the `mailchimp` provider's `subscribe` and `audiences` actions.

The owner can also have every inbox email join an audience automatically from Settings → Notifications, tagged with the app's name.

## Discord

`src/lib/discord.ts` ships in every app, with the same shape as Slack: `notify(text, { channel? })` and `useChannels()`. It needs the owner to have added the Ripping bot to their server on Connections → App Functions, and a signed-in app user. `channel` is a channel id or name; left out, the message goes to the channel the owner chose on Settings → Notifications. Markdown works in `text`. On the wire these are the `discord` provider's `post` and `channels` actions.

The owner's own events go to Discord the same way they go to Slack, from Settings → Notifications; an app can have both.

## Airtable

`src/lib/airtable.ts` ships in every app. It needs the owner to have connected Airtable on Connections → App Functions and granted the bases when connecting; every call needs a signed-in app user.

```ts
function useBases(): { bases: { id: string; name: string }[]; ready: boolean; error: string | null };
function tablesOf(base: string): Promise<{ id: string; name: string; fields: { name: string; type: string }[] }[]>;
function queryTable(base: string, table: string, limit?: number): Promise<{ id: string; created: string; fields: Record<string, unknown> }[]>;
function addRow(base: string, table: string, fields: Record<string, unknown>): Promise<{ id: string }>;
```

`table` is a table id or name. `addRow` keeps only keys that name a field of the table (when the schema can be read) and asks Airtable to typecast values, so a number in a string or an option name for a single select both land. `None of the values match a field in that table` is the error when nothing matched. On the wire these are the `airtable` provider's `bases`, `tables`, `query` and `add` actions.

The owner can also have every inbox message logged into a table from the app's Settings → Notifications, with fields matched by name.

## Twilio SMS

`src/lib/sms.ts` ships in every app: `sendSms(to, text)` sends a text from the owner's Twilio number, for a signed-in app user, with `to` in international form (`+14155551234`) and the body cut at 1,600 characters. Each text is billed to the owner's Twilio account, so send on an action, never in a loop. On the wire it is the `twilio` provider's `sms.send` action. The owner can also get their own events by text from Settings → Notifications.

## Google Sheets

`src/lib/sheets.ts` ships in every app. It needs the owner to have connected Google Sheets on Connections → App Functions (its own Google connection, with the Sheets permissions), and a signed-in app user.

```ts
function useSpreadsheets(): { spreadsheets: { id: string; name: string }[]; ready: boolean; error: string | null };
function sheetsOf(spreadsheet: string): Promise<{ id: number; title: string }[]>;
function readSheet(spreadsheet: string, sheet: string, limit?: number): Promise<{ headers: string[]; rows: { row: number; values: Record<string, string> }[] }>;
function appendRow(spreadsheet: string, sheet: string, row: Record<string, unknown>): Promise<{ range: string }>;
```

A tab's first row is its header. `readSheet` returns the last `limit` rows as objects keyed by those headings, with each row's sheet row number. `appendRow` places values under matching headings (any case); on an empty tab it writes the header row from the keys first; `None of the keys match the sheet's headings` is the error when nothing lines up. On the wire these are the `google_sheets` provider's `spreadsheets`, `sheets`, `read` and `append` actions.

The owner can also have every inbox message appended to a sheet from Settings → Notifications, with a `received` time added.

## Calendly

`src/lib/calendly.ts` ships in every app: `useEventTypes()` returns the owner's active event types with `schedulingUrl` (link to it; booking happens on Calendly's page), and `upcoming(limit?)` returns coming bookings with their invitees. Both need the owner's Calendly connection and a signed-in app user. On the wire these are the `calendly` provider's `event_types` and `upcoming` actions.

Bookings made or cancelled reach Ripping by Calendly's webhook (paid Calendly plans) and become the owner's `booking` event on any app with "Tell this app about my Calendly bookings" on, delivered to Slack, Discord, a text or the webhook like the other events. The webhook's `data` carries `kind` (`booked` or `cancelled`), `invitee`, `event`, `start`, `end` and `reason`.

## Shopify

`src/lib/shopify.ts` ships in every app: `useProducts(limit?)` returns the store's active products with `price`, `image` and `url` (link to it; checkout happens on the store), and `recentOrders(limit?)` returns the latest orders with totals, items and customer emails, so show that to admins. Both need the owner's store connected on Connections → Finance and a signed-in app user. On the wire these are the `shopify` provider's `products` and `orders` actions.

A new order in the store becomes the owner's `sale` event on any app with "Tell this app about new Shopify orders" on; the webhook `data` carries `source: "shopify"`, `order`, `total`, `currency`, `customer_email` and `items`.

## Pictures and speech (OpenAI)

`src/lib/media.ts` ships in every app and needs the owner's OpenAI key on Connections → App Functions, and a signed-in app user.

```ts
function generateImage(prompt: string, opts?: { size?: "1024x1024" | "1024x1536" | "1536x1024"; quality?: "low" | "medium" | "high" }): Promise<{ url: string; size: string }>;
function speak(text: string, opts?: { voice?: "alloy" | "ash" | "coral" | "echo" | "fable" | "onyx" | "nova" | "sage" | "shimmer" }): Promise<{ url: string; voice: string }>;
```

The proxy calls OpenAI's image model (`gpt-image-1`, JPEG) or speech model (`tts-1`, mp3), stores the result in Ripping's public media bucket and returns its URL, so the app shows or plays a file rather than handling base64. Each call is a paid request on the owner's key: make them on a button press, never on render. On the wire they are the `openai` provider's `image` and `speak` actions. Anthropic's Claude has no image or speech generation, so the Anthropic card stays text and vision only.
