Building apps
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:
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; it is the one provider a visitor may call without signing in.
Who may call, and how often
- 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. - Everything but
payneeds a signed-in app user whose account is active:401 Sign in to this app first. - Per user per app: 60 a minute and 500 a day:
429 You have made too many requests from this app. Try again later. - The owner can pause connected services for the app:
403 This app's access to connected services is paused by its owner. - 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
{ "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
{ "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
{ "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.
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>.
{ "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.
{ "provider": "google", "action": "gmail.list", "q": "is:unread", "max": 20 }
{ "threads": [ { "id": "…", "from": "…", "subject": "…", "date": "…", "snippet": "…" } ] }
max is capped at 50, and details are fetched for the first 20 threads.
{ "provider": "google", "action": "calendar.list", "from": "2026-09-20T00:00:00.000Z", "to": "2026-09-27T00:00:00.000Z", "max": 50 }
{ "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.
{ "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
{ "provider": "supabase", "action": "public" }
{ "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.
{ "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.
{
"provider": "stripe", "action": "checkout", "mode": "payment",
"success_url": "https://myapp.example/#/thanks", "cancel_url": "https://myapp.example/#/",
"line_items": [ { "price": "price_…", "quantity": 1 } ]
}
{ "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.
{ "provider": "payments", "action": "checkout", "mode": "subscription", "success_url": "https://…/#/billing?upgraded=1", "cancel_url": "https://…/#/billing", "line_items": [ { "price": "price_…", "quantity": 1 } ] }
{ "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.
{ "provider": "payments", "action": "subscription" }
{ "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.
{ "provider": "payments", "action": "portal", "return_url": "https://…/#/billing" }
{ "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.
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.
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.
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.