For authenticated API calls, replace
YOUR_API_KEY with a tenant key kept on your server. Public headless form submissions do not need an API key.## Nautilus API
Base URL: https://app.nautilus.co/api
### Authentication
Canonical data reads and the legacy message, email, and analytics endpoints require a Bearer token in the Authorization header. The public contract endpoint and headless form submission endpoint do not.
Authorization: Bearer YOUR_API_KEY
Use the `Bearer ` prefix exactly as shown.
Two key types exist:
- **Tenant-scoped keys** — bound to a specific organization. SMS sends via the tenant's configured phone number; email sends from `{slug}@mail.nautilus.co`.
- **Platform keys** — legacy keys that send as the Nautilus brand (shortcode for SMS, `contact@mail.nautilus.co` for email).
Most integrations use tenant-scoped keys.
Analytics and canonical data endpoints require tenant-scoped keys. Platform keys are accepted only by the SMS and email send endpoints.
Analytics date filters are interpreted in UTC. For members, retention offers, cancellations, and cancellation survey, date-only lower bounds start at 00:00:00.000Z and date-only upper bounds include the full UTC day. Cohort start/end values are truncated to their month.
### Canonical read API
The canonical API is read-only and tenant-scoped. Its full OpenAPI 3.1 contract is available without authentication at GET https://app.nautilus.co/api/v1/openapi.json — fetch it when you need exact parameters and response schemas.
All canonical routes use GET. Collections:
/v1/sites, /v1/products, /v1/checkout-links, /v1/purchases, /v1/contacts, /v1/contact-lists, /v1/marketing/signups, /v1/forms, /v1/forms/submissions, /v1/vouchers, /v1/vouchers/redemptions, /v1/voucher-groups, /v1/links, /v1/links/analytics, /v1/automations, /v1/automations/runs, /v1/surveys, /v1/surveys/responses, /v1/cases, /v1/reviews, /v1/cancellations/records
Detail routes append the resource ID (e.g. /v1/products/{productId}). Nested collections: /v1/contact-lists/{listId}/contacts, /v1/forms/{formId}/submissions, /v1/voucher-groups/{groupId}/vouchers, /v1/links/{linkId}/analytics, /v1/automations/{automationId}/runs, /v1/surveys/{surveyId}/responses.
POS routes: /v1/pos/transactions, /v1/pos/customers, /v1/pos/recurring-accounts, /v1/pos/recurring-account-details, each with a /{id} detail route. The tenant API key selects the data source; supported organizations use Sonny's Shared, shared ODBC, or SiteWatch. Unsupported organizations receive 404. Never supply a provider or organization ID.
For shared ODBC and SiteWatch transactions, customerId requires completedAfter; missing this lower bound returns 400. Membership billing/status arrays contain only the latest 24 events of each kind. Timestamps retain recorded UTC values. Missing monetary values are unknown, not zero: a missing sale total fails the request and unavailable gross line amounts are null. Legacy /v1/pos/sonnys/* routes remain compatible for existing Sonny's integrations; migrate to neutral routes and restart pagination.
**Conventions:**
- Collection responses use the envelope { organizationId, filters, data, pagination: { limit, hasMore, nextCursor } }.
- limit defaults to 100, capped at 500 (links capped at 100). Pass pagination.nextCursor back as ?cursor= to page. Cursors are opaque and bound to the endpoint that issued them; reusing one elsewhere returns 400.
- Timestamps are UTC ISO 8601. Timestamp filters (createdAfter, createdBefore, etc.) require an explicit offset, e.g. 2026-07-01T00:00:00Z.
- Money fields ending in Cents are integer cents.
- Voucher/purchase codes are excluded by default; pass includeCodes=true to get them.
- The API key's organization is the only tenant scope — you cannot pass an organization ID. Detail routes return 404 for both missing and cross-tenant IDs.
- Google reviews (/v1/reviews) filter by siteId, rating (1-5), replied (true/false), createdAfter, createdBefore. reviewId is the Nautilus UUID; googleReviewId is Google's. Includes reviews for child organizations of the tenant (organizationId is set per review). Preview placeholders and deleted reviews are excluded.
- Cancellation records (/v1/cancellations/records) include signupDiscounts, hadSignupDiscount, firstYearPayments, and firstYearPaymentCoverage.
- Errors: 400 invalid filter/cursor/limit or unsupported provider, 401 bad key, 404 missing resource, 500 internal, 502 upstream unavailable (link resources). Body is { "error": "..." }.
### Headless forms — POST /forms/{formId}/submissions
Full URL: https://app.nautilus.co/api/forms/FORM_ID/submissions
Use published forms that support headless intake. Copy the endpoint and saved field IDs from Share > Use your own form. Forms requiring email verification, POS enrollment, file uploads, or unsupported definitions remain hosted-only.
Render the form in your own frontend. Copy the published form's endpoint, field IDs, and consent text from Contact Forms > Share > Use your own form.
- No tenant API key or staff session is required. Never expose a tenant API key in browser form code. Password-protected forms require _password in the body.
- Send flat JSON, URL-encoded fields, or text-only FormData. Keys must be saved field IDs; do not wrap answers in data. Unknown keys, nested objects, and file bodies are rejected.
- Include _gotcha as an empty string. A filled trap receives an acknowledgment without a submissionId and creates no records or side effects. No new rate limiting or CAPTCHA is provided.
- The total body limit is 65,536 bytes. Multiple checkboxes use arrays in JSON or repeated field names in HTML. Dates use YYYY-MM-DD. Phone answers remove non-digits and keep the last ten digits if longer, matching hosted forms.
- Server validation applies saved requiredness, choices, and conditional visibility. Your frontend must implement corresponding show/hide behavior.
- Use credentials: "omit" and Accept: application/json for fetch. JSON bodies always receive JSON. CORS preflight supports POST and OPTIONS without credentials.
- _redirect must match the submitting Origin (scheme, host, and port). Native HTML gets a 303; JSON gets redirectUrl for your frontend to follow.
- Optional tracking keys: _utm_source, _utm_medium, _utm_campaign, _utm_term, _utm_content, _gclid, _gbraid, _wbraid, _fbclid, _referrer, _landing_url. Strings are trimmed to 450 characters when stored; empty or malformed optional values are ignored without rejecting answers. The complete body must still fit within 65,536 bytes.
- A saved receipt is { ok: true, submissionId, title, message, redirectUrl? }. It does not guarantee completed email delivery or workflow execution. Notifications are scheduled after the response, so a slow email provider does not delay confirmation. Do not automatically retry uncertain requests: no request idempotency key is supported.
- Errors are { ok: false, code, error, fieldErrors? }. Statuses: 400 malformed body, 401 form password, 404 missing/unpublished form, 409 hosted_form_required, 413 body too large, 415 unsupported content/file, 422 validation, 500 processing failure.
- Email-verification forms, POS enrollment, file uploads, and unsupported saved definitions remain hosted-only. Null optional field settings are treated as omitted; malformed definitions and comparison rules remain unsupported. Use the hosted flow if Share says it is required.
- This endpoint does not create authentication sessions or expose separate email/phone verification or hosted completion steps. Reading configuration/submissions still uses the authenticated /api/v1/forms endpoints.
Example JSON (replace EMAIL_FIELD_ID with the saved ID):
{ "EMAIL_FIELD_ID": "customer@example.com", "_gotcha": "" }
### Send SMS — POST /v1/message
Send an SMS (or MMS) to one or more phone numbers.
**Request body (JSON):**
| Field | Type | Required | Description |
|--------------|-----------------------|----------|------------------------------------------------------------------------------------------------------|
| to | string or string[] | Yes | One phone number or an array of phone numbers. |
| body | string | Yes | The text content of the message. |
| attachments | { url: string }[] | No | Media URLs to attach (sends as MMS). |
| useShortcode | boolean | No | Tenant-scoped keys only. When true, routes via Nautilus shortcode instead of the tenant's configured phone number. Ignored on platform keys. |
Phone numbers can be E.164 (+15551234567), 10-digit (5551234567), or dashed (555-123-4567). All are normalized to E.164 before sending.
**Example — single recipient:**
POST https://app.nautilus.co/api/v1/message
Content-Type: application/json
Authorization: Bearer YOUR_API_KEY
{
"to": "+15551234567",
"body": "Your wash is ready!"
}
**Example — multiple recipients with attachment:**
POST https://app.nautilus.co/api/v1/message
Content-Type: application/json
Authorization: Bearer YOUR_API_KEY
{
"to": ["+15551234567", "+15559876543"],
"body": "Flash sale: 50% off unlimited plans today!",
"attachments": [{ "url": "https://example.com/promo.png" }]
}
**Responses:**
- 200: All messages sent. Body: { "success": true, "results": [{ "to": "+1...", "success": true }] }
- 207: Partial success. Body: { "success": false, "results": [{ "to": "+1...", "success": true }, { "to": "+1...", "success": false, "error": "Invalid phone number" }] }
- 400: Invalid request body. Body: { "error": "Invalid request format", "details": [...] }
- 401: Bad or missing token. Body: { "error": "Unauthorized" }
- 422: Tenant-scoped key with no configured phone number. Either configure one or set useShortcode: true. Body: { "error": "Phone number sending is not configured for this tenant..." }
- 500: Server error. Body: { "error": "..." }
When sending to multiple recipients, each is processed independently. A failure for one does not block others.
### Send email — POST /v1/email
Send an email to one or more recipients.
**Request body (JSON):**
| Field | Type | Required | Description |
|---------|--------------------|----------|-----------------------------------------------------------------------------------------------|
| to | string or string[] | Yes | One email address or an array of email addresses. |
| subject | string | Yes | The email subject line. |
| html | string | Yes | The email body as HTML. |
| from | string | No | Sender address. Tenant-scoped keys: must be exactly `{slug}@mail.nautilus.co` (display name customizable); defaults to `{Tenant Name} <{slug}@mail.nautilus.co>`. Platform keys: must use `@mail.nautilus.co` domain; defaults to `Nautilus <contact@mail.nautilus.co>`. |
| replyTo | string | No | Reply-to email address. |
| attachments | object[] | No | File attachments. Each needs filename + either path (URL) or content (base64). |
Each attachment object: { "filename": "file.pdf", "path": "https://..." } or { "filename": "file.pdf", "content": "<base64>" }
**Example — single recipient:**
POST https://app.nautilus.co/api/v1/email
Content-Type: application/json
Authorization: Bearer YOUR_API_KEY
{
"to": "customer@example.com",
"subject": "Your monthly wash summary",
"html": "<h1>Hi!</h1><p>You washed 12 times this month.</p>"
}
**Example — custom sender with reply-to:**
POST https://app.nautilus.co/api/v1/email
Content-Type: application/json
Authorization: Bearer YOUR_API_KEY
{
"to": ["customer@example.com", "other@example.com"],
"subject": "Welcome to Squeaky Clean",
"html": "<p>Thanks for joining!</p>",
"from": "Squeaky Clean <squeaky@mail.nautilus.co>",
"replyTo": "support@squeakyclean.com"
}
**Responses:**
- 200: All emails sent. Body: { "success": true, "results": [{ "to": "...", "success": true, "id": "abc123" }] }
- 207: Partial success. Body: { "success": false, "results": [{ "to": "...", "success": true, "id": "abc123" }, { "to": "...", "success": false, "error": "..." }] }
- 400: Invalid request body (includes `from` that doesn't match the rules for your key type). Body: { "error": "Invalid request format", "details": [...] }
- 401: Bad or missing token. Body: { "error": "Unauthorized" }
- 422: Tenant-scoped key whose tenant has no slug configured. Body: { "error": "Email sending is not configured for this tenant..." }
- 500: Server error. Body: { "error": "..." }
When sending to multiple recipients, each is processed independently. A failure for one does not block others.
### Export members — GET /v1/members
Export membership rows for signup-date, churn, package, site, trial, amount, and raw POS tag analysis.
Requires a tenant-scoped key and an eligible tenant.
**Query parameters:**
| Field | Type | Required | Description |
|-------------|---------------------------|----------|-------------|
| status | all, active, or churned | No | Defaults to all. Invalid values are treated as all. |
| signupStart | date string | No | Inclusive signup lower bound. |
| signupEnd | date string | No | Inclusive signup upper bound. |
| limit | integer | No | Defaults to 500. Maximum 1000. |
| offset | integer | No | Defaults to 0. |
**Example:**
GET https://app.nautilus.co/api/v1/members?status=active&signupStart=2026-01-01&signupEnd=2026-01-31&limit=100
Authorization: Bearer YOUR_API_KEY
**Response shape:**
{ organizationId, filters, pagination, members: [{ membershipId, customerId, signupDate, status, churnType, cancelDate, tenureDays, planName, billingAmount, billingSiteCode, billingSiteName, creationSiteCode, creationSiteName, isOnTrial, trialAmount, tags }] }
### Signup cohorts — GET /v1/cohorts
Aggregate signup-month cohorts overall, by current plan, or by current billing site.
Requires a tenant-scoped key and an eligible tenant.
**Query parameters:**
| Field | Type | Required | Description |
|-----------|-------------------------|----------|-------------|
| start | date string | No | Inclusive lower bound on cohort month. |
| end | date string | No | Inclusive upper bound on cohort month. |
| dimension | none, plan, or site | No | Defaults to none. Invalid values return 400. |
**Example:**
GET https://app.nautilus.co/api/v1/cohorts?dimension=plan&start=2026-01-01&end=2026-06-30
Authorization: Bearer YOUR_API_KEY
**Response shape:**
{ organizationId, dateRange, source: "membership_signup", available: true, dimension, signupDateCoverage, dimensionValues, cohorts: [{ cohortMonth, dimensionValue, cohortSize, stillActive, churned, voluntary, involuntary, churnRatePct, churnWithin }] }
### Retention offers — GET /v1/retention-offers
Return retention offer views, accepts, declines, acceptance rates, and accepted-vs-declined LTV where supported.
Requires a tenant-scoped key and the retention-offer analytics capability.
**Query parameters:**
| Field | Type | Required | Description |
|-------|-------------|----------|-------------|
| start | date string | No | Inclusive lower bound on offer events. |
| end | date string | No | Inclusive upper bound on offer events. |
**Example:**
GET https://app.nautilus.co/api/v1/retention-offers?start=2026-01-01&end=2026-06-30
Authorization: Bearer YOUR_API_KEY
**Response shape:**
{ organizationId, dateRange, totals: { viewed, accepted, declined, acceptanceRatePct }, offers: [{ offerId, offerName, targetPlanName, targetPlanPrice, viewed, accepted, declined, acceptanceRatePct }], ltv }
If revenue LTV is unsupported or unavailable, ltv contains acceptedMembers: null, declinedMembers: null, coverage: null, and unavailableReason.
### Cancellations — GET /v1/cancellations
Return POS-derived cancellation analytics: voluntary versus involuntary churn, terminal-status reasons, and tenure-at-cancellation statistics.
Requires a tenant-scoped key and an eligible tenant.
**Query parameters:**
| Field | Type | Required | Description |
|-------|-------------|----------|-------------|
| start | date string | No | Inclusive lower bound on cancellation date. |
| end | date string | No | Inclusive upper bound on cancellation date. |
**Example:**
GET https://app.nautilus.co/api/v1/cancellations?start=2026-01-01&end=2026-06-30
Authorization: Bearer YOUR_API_KEY
**Response shape:**
{ organizationId, dateRange, source: "membership_status", available: true, totals, reasons, tenure }
### Cancellation survey — GET /v1/cancellation-survey
Return member-stated cancellation reasons selected in the portal cancellation flow.
Requires a tenant-scoped key. This is separate from POS-derived churn in /v1/cancellations.
**Query parameters:**
| Field | Type | Required | Description |
|-------|-------------|----------|-------------|
| start | date string | No | Inclusive lower bound on survey event timestamp. |
| end | date string | No | Inclusive upper bound on survey event timestamp. |
**Example:**
GET https://app.nautilus.co/api/v1/cancellation-survey?start=2026-01-01&end=2026-06-30
Authorization: Bearer YOUR_API_KEY
**Response shape:**
{ organizationId, dateRange, source: "exit_survey", available, totals: { selections }, reasons: [{ reason, rawCodes, count, pct }], other }
If the analytics source cannot be queried after retries, the endpoint can return 200 with available: false, totals.selections: 0, reasons: [], and other: null.

