Appearance
Public API
Run funnel sessions, capture user information, and connect a purchase to your app. For funnel UI, use funnelSdkService from @funnelsgrove/runtime; the shared @funnelsgrove/payments package handles checkout surfaces and confirmation.
Authentication
Use the project SDK publishable key from Project Settings. This key may be included in browser or mobile code. It identifies the project; your app must still verify the user's identity before looking up access by email.
http
X-SDK-Publishable-Key: <project-sdk-publishable-key>All public HTTP paths below use this base URL:
text
https://sdk-api.funnelsgrove.comBackend operations using a secret key or an API key belong to the API reference.
Choose your task
| I want to… | Start here |
|---|---|
| Connect a purchase to my app | Verify access and track login |
| Build checkout or an upsell | Payments |
| Save user identity and progress | Runtime methods and email capture |
| Upload a photo | Direct photo upload |
| Upload a voice note, screenshot, or other temporary file | Temporary file upload |
| Handle a failed request | Errors & retries |
Runtime Methods
funnelSdkService exposes these public methods:
| Method | Purpose |
|---|---|
bootstrapUser | Start or restore a funnel user session. |
captureEmail | Save the user's email during the funnel. |
getCurrentUser | Load the current funnel user. |
getUserSubscriptionStatus | Read subscription status for already-published bundles; new code uses verify subscription status. |
claimUserSubscription | Claim a paid user for already-published bundles; new code uses track App Login. |
updateFunnelUser | Save user profile, progress, attribution, or subscription data. |
preparePhotoUpload | Create a short-lived direct-upload URL. Prefer uploadPhotoFile unless implementing a custom uploader. |
uploadPhotoFile | Upload a user photo directly to R2 through a short-lived signed URL. |
uploadPhoto | Deprecated. Base64 upload for already-published bundles; new code uses uploadPhotoFile. |
prepareTempUpload | Create a short-lived upload URL for a temporary file. Prefer uploadTempFile unless implementing a custom uploader. |
uploadTempFile | Upload an image or audio file directly to temporary storage; the file is deleted after 7 days. |
createHostedCheckoutSession | Create a hosted checkout session. |
createSubscriptionCheckout | Start embedded subscription checkout. |
createOneTimeCheckout | Start embedded one-time checkout. |
updateSubscriptionCheckoutPlan | Change the plan in an active subscription checkout. |
verifySubscriptionCheckout | Verify subscription checkout completion. |
chargeOneClickPayment | Charge a saved customer payment method. |
updateSubscription | Perform a supported subscription action. |
listSubscriptions | List subscriptions for a user or Stripe customer. |
getUserSubscriptionStatus, claimUserSubscription and uploadPhoto keep working for already-published bundles; use the replacements above in new code.
Current identity
User and subscription reads keep the requested user ID, including an ID with historical merge metadata. They do not collect other profiles’ subscriptions. Email capture returns the selected identity separately; persist that ID for subsequent requests. This switch leaves previous history and billing ownership with their recorded owners. Subscription management requires the selected owner.
Email Capture
captureEmail submits the version loaded by the visitor through POST /sdk/public/users/email-capture. Direct callers supply that version's funnelVersionId; the typed runtime supplies it from its configuration. A newer publication does not replace the version on a new capture.
A well-formed version ID that is absent or belongs to another funnel returns HTTP 404 with Funnel version not found, before answers, identity, consent or events change. Use a version belonging to the requested funnel; scoped preview versions are supported. Existing capture retries retain the original event.
For the Meta email conversion, analyticsMetadata.metaLeadEventName accepts Lead or CompleteRegistration; missing or unsupported values resolve to Lead. The runtime supplies the published project setting and overrides that field in caller metadata. Direct HTTP callers supply the matching name used by their browser Pixel; the API does not load the current project setting for this field. The first accepted capture stores the name for later delivery and retries.
When a lifecycle email returns a visitor to the funnel, the runtime passes the opaque signed email_return value to bootstrapUser. Invalid, expired, or repeated values do not interrupt bootstrap.
Payments V2
Follow the Payments guide for published offer selection, subscription and one-time checkout, verification, upsells, and subscription management. Current payment requests use V2.
Direct Photo Upload
Use uploadPhotoFile to prepare a short-lived upload URL and send the Blob directly to object storage. For a custom uploader, call preparePhotoUpload first (POST /sdk/public/uploads/photos/prepare) with camelCase metadata.
Preparation has a 25-second client budget; the direct upload has a separate 60-second budget. Both accept an AbortSignal and execute once. A timeout does not prove that the object was never written. See request budgets.
Temporary File Upload
Use temporary storage for visitor files the funnel needs for a short time: voice recordings, photos, chat screenshots, cover images. The browser sends the bytes directly to object storage; your funnel keeps only the returned URL. Files are temporary: the storage lifecycle policy deletes them 7 days after upload.
In funnel code, call apiService.uploadTempFile(file, { purpose }) (@funnelsgrove/runtime 0.30.0 or later); it returns { key, url, contentType, sizeBytes, expiresAt }. funnelSdkService.uploadTempFile is the lower-level equivalent that takes { file, purpose, userId } and returns publicUrl. Store url and expiresAt in answers, never the file bytes or a data URL.
Allowed files
| Kind | Content types | Maximum size |
|---|---|---|
| Image | image/jpeg, image/png, image/webp, image/heic, image/heif, image/gif | 10 MB |
| Audio | audio/webm, audio/ogg, audio/mp4, audio/mpeg, audio/wav, audio/x-m4a, audio/aac | 25 MB |
Content-type parameters such as audio/webm;codecs=opus are accepted; the upload is signed for the base type. A file without a type is rejected.
Prepare request
http
POST /sdk/public/uploads/temp/prepare
Content-Type: application/json
X-SDK-Publishable-Key: <project-sdk-publishable-key>json
{
"funnelId": "4bd7f645-e104-4d77-81c1-401dc8ac7d49",
"userId": "funnel-user-id",
"contentType": "audio/webm;codecs=opus",
"sizeBytes": 482113,
"purpose": "voice"
}| Field | Required | Rules |
|---|---|---|
funnelId | Yes | UUID of a funnel in the key's project. |
userId | No | Funnel user ID, 1–255 characters. Omitted uploads are stored as anonymous. |
contentType | Yes | One of the allowed types above; a missing or empty value returns 415. |
sizeBytes | Yes | Exact byte size of the file, a positive integer. |
purpose | No | Diagnostic slug matching ^[a-z0-9-]{1,32}$, such as voice or cover. |
publishableKey | No | Body fallback for clients that cannot send the header. |
Unknown fields are rejected.
Prepare response
201 Created:
json
{
"key": "tmp/sdk-temp-uploads/<workspace>/<funnel>/<user>/20261006_<random>.webm",
"uploadUrl": "https://<account>.r2.cloudflarestorage.com/...&X-Amz-Signature=...",
"uploadHeaders": {
"Content-Type": "audio/webm",
"Cache-Control": "private, max-age=86400"
},
"publicUrl": "https://<public-storage-host>/tmp/sdk-temp-uploads/.../20261006_<random>.webm",
"contentType": "audio/webm",
"sizeBytes": 482113,
"expiresAt": "2026-10-13T10:00:00.000Z"
}uploadUrl is valid for 5 minutes. expiresAt is 7 days after preparation; treat the file as unavailable after it. Storage removes expired files asynchronously, so a URL may keep working briefly past expiresAt. publicUrl is publicly readable by anyone who has it; its random component is a UUIDv4 (122 random bits).
Upload the bytes
Send one PUT with exactly the returned headers and the original bytes. The signature covers Content-Type, Cache-Control, and the declared size, so any other header value or byte count is rejected by storage.
ts
const prepared = await funnelSdkService.prepareTempUpload({
contentType: file.type,
sizeBytes: file.size,
purpose: 'voice',
});
const response = await fetch(prepared.uploadUrl, {
method: 'PUT',
headers: prepared.uploadHeaders,
body: file,
});
if (!response.ok) throw new Error(`Upload failed (${response.status})`);
// Persist prepared.publicUrl and prepared.expiresAt.Storage accepts browser uploads from funnel origins through the bucket's CORS policy (PUT with Content-Type and Cache-Control headers), the same policy used by direct photo upload.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | SDK_INVALID_REQUEST | Malformed body, unknown field, invalid purpose, or missing key. |
| 401 | SDK_UNAUTHORIZED | Invalid publishable key. |
| 404 | SDK_NOT_FOUND | Funnel outside the key's project. |
| 413 | SDK_PAYLOAD_TOO_LARGE | Image above 10 MB or audio above 25 MB. |
| 415 | SDK_UNSUPPORTED_MEDIA_TYPE | Missing, empty, or unlisted content type. |
| 429 | — | More than 30 preparations per minute from one identified client IP; honor Retry-After. Clients whose IP the edge cannot verify are bounded by the general SDK ingress limits instead. |
| 503 | SDK_UNAVAILABLE | Storage unavailable or the 20-second server budget elapsed. |
Preparation has a 25-second client budget and runs once; uploadTempFile gives the PUT a separate 120-second budget. A failed preparation writes nothing, so a new attempt is safe. A timed-out PUT may still have written the object; a retry prepares a new key.
Verify Subscription Status
Use the project SDK publishable key with either a known funnelsgrove userId or a verified email. funnelId is optional.
Verify with a known user ID:
http
POST /sdk/public/users/verify_subscription
Content-Type: application/json
X-SDK-Publishable-Key: <project-sdk-publishable-key>
{
"userId": "u_123"
}Or verify with an email only after the app has confirmed ownership through OTP or equivalent authentication:
http
POST /sdk/public/users/verify_subscription
Content-Type: application/json
X-SDK-Publishable-Key: <project-sdk-publishable-key>
{
"email": "verified-user@example.com"
}The endpoint is read-only. It returns the access flag, a test flag, and the stable public user ID; it never returns the user's email, internal database ID, or subscription records. test is true when the selected active subscription belongs to Stripe test mode.
json
{
"active": true,
"test": false,
"user": {
"user_id": "u_123"
}
}If no matching user has active access, active is false. A known user without active access still has a user.user_id; only an unresolved identity returns user: null. Grant production access only when active is true and test is false.
json
{
"active": false,
"test": false,
"user": null
}Track App Login
Track app login only after the user has verified ownership of the funnel email through OTP or your backend has already proven the app-account ↔ funnelsgrove-user association. Call this endpoint after registration/login regardless of paid access, and persist the returned user.user_id on the authenticated account. Verify paid access separately.
Pass the funnelsgrove user ID in the request body:
http
POST /sdk/public/users/track_login
Content-Type: application/json
X-SDK-Publishable-Key: <project-sdk-publishable-key>
{
"userId": "u_123",
"runtimeEnvironment": "test"
}The endpoint records registration; it does not verify access or receive your app account ID.
runtimeEnvironment is optional. An explicit live or test selects only that user identity. When omitted, the endpoint looks for the live user first and falls back to test only if that user is absent. It never looks up subscriptions. If the user ID is ambiguous across funnels, provide funnelId.
Registration does not require a subscription, payment, or a published funnel. HTTP 200 means the registration is durably queued for background analytics and lifecycle delivery. The compact response is:
json
{
"user": { "user_id": "your-funnel-user-id" },
"login": { "tracked": true, "newlyTracked": true, "trackedAt": "2026-09-09T12:00:00.000Z" }
}Repeat and concurrent requests retain the first event and timestamp and return newlyTracked: false. No subscription is claimed or profile repaired. Use verify_subscription separately for paid access; the response contains no active, activeUntil, subscription, payment, or event history fields.
Acceptance does not wait for analytics or email delivery. An uncertain write may already be committed; a later explicit login reuses the original receipt. See Errors & retries before adding automatic retries.
Retryable Responses
See Errors & retries for operation-specific retry rules, cancellation, request budgets, and checkout diagnostics.