Skip to content

Build a custom theme.
Liquid, sections, templates.

A Soundslot theme is a folder of Liquid, JSON and CSS in the shape Shopify developers already know. This is the contract it is held to, and the fastest route to a theme a studio can switch on.

A minimal layout/theme.liquid
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ page_title }}</title>
  <link rel="canonical" href="{{ canonical_url }}">
  {{ content_for_header }}
  {{ 'theme.css' | asset_url | stylesheet_tag }}
</head>
<body>
  {% sections 'header-group' %}
  <main>{{ content_for_layout }}</main>
  {% sections 'footer-group' %}
</body>
</html>

From folder to storefront

  1. 1

    Start from the reference theme

    npx @soundslot/cli theme init my-theme downloads it: a layout, sections with schemas, every template, a settings schema and a locale file. It is the executable spec — a test renders it on every build, so it cannot drift from the engine — and the download needs no account.

  2. 2

    Write the layout and the two pages you own

    layout/theme.liquid writes the title and canonical, then {{ content_for_header }} and {{ content_for_layout }}, as on Shopify. index and room are sections all the way down; every other page is a platform island your header and footer wrap, so you only have to ship those two.

  3. 3

    Use blocks, schemes and settings as you would on Shopify

    Theme blocks in blocks/, {% content_for 'blocks' %}, section groups, presets, visible_if and every setting type. Shopify's commerce pickers map to rooms, room types and locations; the README has the table.

  4. 4

    Check it, then push it

    soundslot theme check runs the upload's own checks offline and prints each finding under its Theme Check rule name; soundslot theme push uploads it, or drag the zip into the console. Only a missing layout or Liquid and JSON that do not parse stop an upload. Preview it on the studio's own address, edit the settings in the storefront editor, and publish when it reads right.

What a theme ships

layout/theme.liquid
sections/hero.liquid          markup + {% schema %}
sections/header-group.json
sections/footer-group.json
blocks/text.liquid            a theme block
templates/index.json          the home page
templates/room.json           one room
snippets/room-card.liquid
locales/en.default.json
assets/theme.css
config/settings_schema.json   your settings and colour schemes
config/settings_data.json

What every theme is held to

  • Refused on upload only without a layout, or with Liquid or JSON that does not parse. Everything else comes back as Theme Check findings.
  • Booking and checkout are platform-owned. Your header and footer wrap them; nothing you ship runs on the page that takes a card.
  • Output is not escaped, as on Shopify. Anything a customer typed, such as a review, is escaped before your theme sees it.
  • The hosted themes pass an accessibility check on every page and Lighthouse's Theme Store bars: 90 for accessibility, 60 for performance.

A theme is a Shopify Online Store 2.0 theme: the same folders, the same Liquid, the same section and block schemas, the same JSON templates. Soundslot renders it with LiquidJS. Where a commerce idea has no rental equivalent the difference is named below; everything else works as Shopify documents it.

_dev/ is the reference theme. It is hidden from the gallery and exists to be copied — theme blocks, a static block, plurals, a second language and every page template are demonstrated there, and a test renders it on every CI run, so it cannot drift from the engine.

Directory layout

layout/theme.liquid            the document
layout/<name>.liquid           an alternate layout a template can name
sections/<name>.liquid         a section: markup + {% schema %}
sections/<name>.json           a section group (header, footer, aside, custom.*)
sections/main-<page>.liquid    a page's main section (see Templates)
blocks/<name>.liquid           a theme block: markup + {% schema %}
templates/<page>.json          one per page — index, room, rooms, classes, …
templates/<page>.<suffix>.json an alternate a studio picks per room or page
snippets/<name>.liquid         {% render 'name' %}
locales/<code>.default.json    the storefront copy in the default language
locales/<code>.json            the same copy in another language
locales/<code>.default.schema.json   the editor's labels, for t: keys in schemas
assets/**                      css, js, images, fonts
config/settings_schema.json    the theme's own settings
config/settings_data.json      the theme's saved values, and its theme styles

Custom themes upload as a zip with exactly these relative paths.

Output is not escaped

As on Shopify, {{ x }} prints x as it is. The studio's own content — room names, settings, pages — renders as written, so escape where markup would break: alt="{{ room.name | escape }}".

Two kinds of value are made safe before a theme sees them:

  • Anything a customer typed — a review's name, title and body — is HTML-escaped when the context is built. Print it as it is; | escape on top would escape it twice.
  • Richtext settings and custom fields are reduced to p br strong em b i ul ol li a[href] when saved and again when rendered.

| raw and | richtext still exist for themes written for the earlier engine; neither is needed.

The layout

<!doctype html>
<html lang="{{ request.locale.iso_code }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ page_title }}</title>
  <meta name="description" content="{{ page_description | escape }}">
  <link rel="canonical" href="{{ canonical_url }}">
  {{ content_for_header }}
  {{ 'theme.css' | asset_url | stylesheet_tag }}
</head>
<body>
  {% sections 'header-group' %}
  <main>{{ content_for_layout }}</main>
  {% sections 'footer-group' %}
</body>
</html>

content_for_header is scripts and styles: the platform's --sf-* properties, the colour scheme rules, the Google Fonts links, the structured data, the studio's analytics, the robots and verification meta, and the reveal utility. The layout writes the title, description, canonical and social card itself; any of the four it leaves out the engine adds before </head>, so a ported layout never has two of each. The hosted themes keep the social card in snippets/meta-tags.liquid.

content_for_layout is the page. On a storefront page it is the template's sections; on a platform-owned page (sign in, account, checkout) it is our own markup, rendered inside your chrome.

A template may name an alternate layout with "layout": "<name>", or render with none at all with "layout": false.

Sections

A section is markup plus a schema. The engine wraps what you write the way Shopify does, with our prefix:

<div id="ss-section-<id>" class="ss-section <class>"
     data-section-id="<id>" data-section-type="<type>">…</div>

tag picks the element — div (default), section, header, footer, article or aside. A section in a group also carries ss-section-group-<file>, and one that names a colour scheme carries color-scheme color-scheme-<id>.

<h1>{{ section.settings.heading }}</h1>
{% for block in section.blocks %}
  <a {{ block.shopify_attributes }} href="{{ block.settings.url }}">{{ block.settings.label }}</a>
{% endfor %}
{% schema %}
{
  "name": "Hero",
  "tag": "section",
  "class": "hero",
  "limit": 1,
  "settings": [
    { "type": "text", "id": "heading", "label": "Heading", "default": "Rehearse here" }
  ],
  "blocks": [
    { "type": "link", "name": "Link", "settings": [
      { "type": "text", "id": "label", "label": "Label" },
      { "type": "url", "id": "url", "label": "URL" }
    ]}
  ],
  "max_blocks": 6,
  "presets": [{ "name": "Hero", "category": "Banners" }],
  "enabled_on": { "templates": ["index"] }
}
{% endschema %}

Inside a section: section.id, section.type, section.settings.<id>, section.blocks (statics and hidden blocks left out), section.index and section.index0 (its place in the template or group), section.location (template, the group's key, or static) and section.name (the studio's own name for it). Each block is { id, type, settings, shopify_attributes, blocks, static, name }.

The schema takes Shopify's keys: name, tag, class, limit, settings, blocks, max_blocks (50 when absent), presets (with category and nested blocks), enabled_on and disabled_on (template types and group types, "*" for all, custom.* for every custom group), default (a static section's starting state) and locales.

{% section 'name' %} renders a static section: its saved state when the studio has one, else its schema's default.

A section's own CSS and JavaScript

{% stylesheet %}.hero { display: grid }{% endstylesheet %}
{% javascript %}document.querySelectorAll('.hero').forEach(setUp){% endjavascript %}

Both are raw and belong to the file, not the page: each is written once before </body>, however many times the section or block renders.

{% style %}…{% endstyle %} is a <style> element written where it stands, with Liquid inside it.

{% style %}#ss-section-{{ section.id }} { --gap: {{ section.settings.gap }}px }{% endstyle %}

A section's custom_css in a template is scoped to that section's wrapper.

One broken section

A section or block that throws while rendering draws nothing on the live storefront and the rest of the page renders. In the editor's preview it draws Liquid error (sections/<file>): <message> in its place. Every failure is logged and filed on the platform's Operations screen.

Theme blocks

A file in blocks/ is a block any section can hold. Its schema has name, settings, blocks, presets, tag (null renders no wrapper) and class.

<div class="text">{{ block.settings.text }}</div>
{% schema %}
{ "name": "Text",
  "settings": [{ "type": "richtext", "id": "text", "label": "Text" }],
  "presets": [{ "name": "Text" }] }
{% endschema %}

A section accepts theme blocks with { "type": "@theme" } (any) or { "type": "text" } (that one), and renders them where it says:

<div class="group">{% content_for 'blocks' %}</div>

Each renders in <div id="ss-block-<id>" class="ss-block <class>" …> unless its schema says "tag": null. A block with blocks of its own renders them the same way, eight levels deep. A static block renders by id, with any further arguments handed to it as variables:

{% content_for 'block', type: 'heading', id: 'title', size: 'xl' %}

Its settings live in the template under the parent's blocks with "static": true, and it never appears in block_order or section.blocks. @app blocks are accepted and render nothing.

Section groups and templates

Both are { "sections": {…}, "order": […] }. A group also declares its type — header, footer, aside or custom.<name> — and is rendered by file name: {% sections 'sidebar-group' %}.

{
  "layout": "theme",
  "wrapper": "div#page.page-width",
  "sections": {
    "hero": { "type": "hero", "settings": { "heading": "Rehearse here" } },
    "detail": {
      "type": "room-detail",
      "name": "Room facts",
      "custom_css": ["h2 { letter-spacing: .02em }"],
      "blocks": { "gear": { "type": "gear", "settings": { "heading": "Gear" } } },
      "block_order": ["gear"],
      "disabled": false
    }
  },
  "order": ["hero", "detail"]
}

order decides what renders and in what sequence; a disabled: true section or block is skipped. A studio editing the theme saves an override that replaces the whole template or group, so keep the file the sensible starting state.

Every page is a template

template the page main section
index the home page —
room one room (Shopify's product) —
rooms the room list (Shopify's collection list) main-rooms
classes classes main-classes
rentals rentals main-rentals
people the people a room can be booked with main-people
gift-cards gift cards main-gift-cards
hour-packs hour packs main-hour-packs
memberships membership plans main-memberships
account a customer's own pages main-account
auth sign in, claim, reset, verify main-auth
page a page the studio wrote main-page
policy privacy and terms main-policy
404 nothing at this URL main-404
password the lock screen main-password

Checkout has no template. It renders in the layout alone, with every theme script removed and the footer stripped.

index and room are sections all the way down. Every other page has a platform-owned island in the middle of it — Soundslot's equivalent of an app block in a main section — and the main section is where it lands:

{% if section.settings.heading %}<h1>{{ section.settings.heading }}</h1>{% endif %}
{{ content_for_main }}
{% schema %}
{ "name": "Account", "class": "sf-main", "limit": 1,
  "settings": [{ "type": "text", "id": "heading", "label": "Heading" }],
  "enabled_on": { "templates": ["account"] } }
{% endschema %}

Inside a section {{ content_for_layout }} is the same island, so a main section written either way works. The engine guarantees exactly one main section per page: a second is skipped, and a template with none gets the built-in one. A theme that ships no templates/account.json still has an account page, from the platform's own template.

Alternate templates

templates/room.gallery.json and templates/page.wide.json are alternates. A studio picks one per room or per page, and the engine renders it when the theme ships that suffix. {{ template.name }} and {{ template.suffix }} say which.

Settings

config/settings_schema.json is an array whose first entry is the theme's identity:

[
  {
    "name": "theme_info",
    "theme_name": "Marquee",
    "theme_version": "1.0.0",
    "theme_author": "Soundslot",
    "theme_documentation_url": "https://example.com/docs",
    "theme_support_url": "https://example.com/support",
    "description": "Poster type on a violet ground.",
    "mode": "dark",
    "thumbnail": "thumbnail.png",
    "hidden": false
  },
  { "name": "Colors", "settings": [ … ] }
]

mode is the theme's ground — it decides the booking pages' palette, never the visitor's OS setting. hidden keeps a theme out of the gallery.

config/settings_data.json is Shopify's: current is the values, or the name of one of presets, the theme styles a studio picks from. The studio's saved settings win over both, and a field's default sits under all of them.

{ "current": "Night",
  "presets": { "Day": { "accent": "#e8a03d" }, "Night": { "accent": "#7c5cff" } } }

Field types

Every field is { "type", "id", "label", "default"?, "info"?, "visible_if"? }. visible_if is Shopify's — "{{ section.settings.show_count }}", or a setting compared to a literal with == / != — and the editor hides the field while it is false. A type the editor has no control for stays editable as a text box.

type value in Liquid
text, textarea the string
richtext, inline_richtext sanitized HTML
html, liquid the studio's HTML, as written
number, range (min, max, step, unit) the number
checkbox true / false
select, radio (options) the chosen value
text_alignment left, center or right
color a colour: prints as hex; .red, .green, .blue, .alpha, .hue, .saturation, .lightness, .rgb
color_background the gradient, as written
color_scheme the scheme: prints as its id; .settings.<id> are colours
color_scheme_group settings.color_schemes, every scheme in order
font_picker a font: prints as the family; .family, .weight, .style, .variants
image_picker { src, width, height, alt } — .src works for a value saved as a bare URL
video the file, as image_picker reads
video_url the URL; .type is youtube or vimeo, .id the video
link_list the menu: .links, and as a key it is the handle, so linklists[section.settings.menu] works
url the link
page the studio's page, from pages
room, room_list rooms, from rooms
room_type, room_type_list room types, from tiers
location, location_list buildings, from locations
header, paragraph sidebar text, no value

Shopify's commerce pickers map onto the rental resource each stands for, so a theme written for Shopify needs no change:

Shopify Soundslot
product room
product_list room_list
collection room_type
collection_list room_type_list
blog, article, metaobject none yet — a text box

Our older names are accepted: toggle → checkbox, font → font_picker, image → image_picker, key → id, help → info.

Colour schemes

A color_scheme_group declares what every scheme carries, as any list of color and color_background settings:

{ "type": "color_scheme_group", "id": "color_schemes", "label": "Colour schemes",
  "definition": [
    { "type": "color", "id": "background", "label": "Background", "default": "#ffffff" },
    { "type": "color", "id": "text", "label": "Text", "default": "#17140f" },
    { "type": "color", "id": "button", "label": "Button", "default": "#e8a03d" }
  ] }

settings_data.json carries the schemes under color_schemes, keyed by any id (scheme-1, background-1, inverse). The first is the default and cannot be removed; nor can one a section still names. Loop them to write your own CSS:

{% style %}
  {% for scheme in settings.color_schemes %}
    .color-{{ scheme.id }} { --color-background: {{ scheme.settings.background.rgb }}; }
  {% endfor %}
{% endstyle %}

The platform's tokens

Sign-in, account and checkout are styled from --sf-bg, --sf-surface, --sf-ink, --sf-muted, --sf-accent, --sf-radius, --sf-font-display and --sf-font-body. None of them is a setting you must declare. The engine reads, in order:

  1. the settings bg_color, surface_color, ink_color, muted_color, accent_color, radius (sharp / soft / round), display_font and body_font, when the theme has them;
  2. otherwise the first colour scheme (background, text or foreground, accent, button or primary, …) and the theme's font settings (type_header_font, type_body_font, …);
  3. otherwise neutral defaults.

A colour that is not a hex is never written into CSS, and a font family not on the server's Google Fonts allowlist falls back to the default. custom_css is the studio's own stylesheet, appended after everything else.

The studio's own accounts

A theme declares one url setting per network and the engine reads them into studio.socials[]. Only http(s) values reach an href.

setting label
social_instagram Instagram
social_facebook Facebook
social_youtube YouTube
social_tiktok TikTok
social_x X
social_bandcamp Bandcamp

Languages

Every locales/<code>.json is a language the theme speaks, and the one named <code>.default.json is the default. The storefront renders in the language the studio chose, else the default, and the studio's own rewording of any string sits over the file.

{ "rooms": { "count": { "one": "{{ count }} room", "other": "{{ count }} rooms" } } }
{{ 'rooms.count' | t: count: rooms.size }}
{{ 'general.greeting' | t: name: shop.name }}

count: picks the CLDR plural form for the language (zero wins at 0 when the theme wrote one). A key nothing declares renders Translation missing: <key>. {{ review.createdAt | date: format: 'month_day_year' }} reads date_formats.* from the locale before Shopify's own named formats.

Schema labels may be t: keys. They are read from locales/*.schema.json, with a schema's own locales over them.

Liquid objects

Shopify's, under Shopify's names:

  • shop — the studio: name, url, email, currency, locale, plus everything in studio
  • routes — root_url, collections_url (the rooms), cart_url (the booker), account_url, account_login_url, plus everything in urls
  • request — page_type, path, locale.iso_code, design_mode (true in the editor's preview)
  • template — name, suffix, directory
  • settings — the theme's global settings
  • page_title, page_description, canonical_url, page_image
  • linklists — the studio's menus by handle: linklists['main-menu'].links, each link { title, url, kind, active, child_active, links[] }
  • pages[] — the studio's own pages (title, handle, url), also pages['about']
  • localization.language.iso_code
  • section, block, form, paginate where Shopify has them

Soundslot's own:

  • studio — name, slug, logoUrl, address (line1, line2, city, region, postalCode, country, full), phone, contactEmail, hoursByDay[] (key, label, opens, closes, hours, closed), hoursToday, openNow, closesAt, opensNext, policies.cancellation (a finished sentence), policies.refundMode, prices_include_tax, tax_label and socials[]
  • rooms[] — id, name, code, tier, description, photos[], sqft, dimensions, fits, hourlyRates[] (activity, cents, peakCents), gear[], amenities[], approvalRequired, location, url, bookable, ctaUrl, ctaKey, rateKey, rating, payment, nextFreeLabel, fields.<key> and custom_fields[]
  • tiers[] — room types: key, label, blurb, rooms[]
  • locations[] — every building, primary first, each with its own timezone, hoursByDay, fields and custom_fields
  • reviews[] and rating — the studio's published reviews
  • urls — home, rooms, book, sessions, signIn, reveal_script
  • seo — title, description, canonical, image, json_ld, head
  • page — template, title, url; on the page template also handle, content and published_at
  • password_message, currency, basePath

On templates/room.json: room, the same shape as an entry in rooms.

Tags

Shopify's: section, sections, content_for, render (with for and with), form, paginate, style, stylesheet, javascript, schema, doc, liquid, echo, cycle, increment, decrement, capture, tablerow, raw and the control-flow tags. layout tags in a Liquid file are not supported; a JSON template names its layout.

Forms

form does
{% form 'customer' %} (or 'newsletter') puts contact[email] on the studio's marketing list
{% form 'customer_login' %} hands customer[email] to sign-in, which sends the code

Inside the form, form.posted_successfully? and form.errors answer the last post. A form type with no handler renders nothing.

Pagination

{% paginate rooms by 6 %}
  {% for room in rooms %}…{% endfor %}
  {{ paginate | default_pagination }}
{% endpaginate %}

It pages a list the context holds, by ?page=.

Filters

  • money — money, money_with_currency, money_without_currency, money_without_trailing_zeros, all in the studio's currency, from cents
  • assets — asset_url, asset_img_url, file_url, inline_asset_content, stylesheet_tag, script_tag, preload_tag
  • images — image_url and image_tag at the width you ask for, and placeholder_svg_tag
  • colour — color_to_rgb, color_to_hsl, color_to_hex, color_extract, color_brightness, color_contrast, color_lighten, color_darken, color_saturate, color_desaturate, color_modify, color_mix, color_difference, brightness_difference
  • fonts — font_url, font_face, font_modify, all on the allowlist
  • text — t, date (strftime or Shopify's named formats, in the studio's zone), time_tag, json (safe inside a <script>), handleize, pluralize, highlight, link_to, within, url_escape, url_param_escape, default_pagination, and LiquidJS's standard filters

Booking

Booking and checkout are platform-owned; a theme never builds a booking form. A room's call to action is ctaUrl with ctaKey wording it:

<a href="{{ room.ctaUrl }}">{{ room.ctaKey | t }}</a>

ctaUrl is the hosted checkout where room.bookable, and the building's own mailto:/tel: otherwise. room.rateKey is set where there is no hourly price to print:

{% if room.rateKey %}{{ room.rateKey | t }}{% else %}{{ room.hourlyRates.first.cents | money }}{% endif %}

There is no widget and no iframe. Checkout renders inside your layout with every theme script removed, so nothing you ship runs where somebody types a card number.

The password lock

A studio can close its storefront while it builds. Pages then render the password template at HTTP 200 with noindex; a themed URL answers 307 to /password. password_message is the studio's own line; the form itself is the platform's island, so main-password writes content_for_main. Staff previewing a theme are never locked out of their own site.

The studio's own code

A studio can paste a tag manager id, a Crisp website id and raw HTML for the head and the body. The first two are loaded by sf-tracking.js, after consent where the studio asked for it; the raw HTML runs on every storefront page with the page's nonce. None of it reaches checkout, and a theme writes no embed markup of its own.

Uploading

An upload is refused only when the theme cannot render:

  • no layout/theme.liquid
  • a .liquid file that does not parse
  • a JSON file (or a section's {% schema %}) that does not parse
  • a path outside the folders above, or a file over its limit

Everything else is stored, and comes back as findings named after the Theme Check rule they match:

rule finds
RequiredLayoutThemeObject a layout that never outputs content_for_header or content_for_layout
MissingTemplate no index or room template; a section, group, snippet or block that is not there
MissingContentForMain a main section that never outputs content_for_main
ValidSchema a schema with no name, a setting type we do not know, a scheme default nothing declares
UniqueSettingId a setting id declared twice
ValidVisibleIf a visible_if that is not a {{ }} condition
ValidBlockTarget a block the section neither declares nor accepts
ValidSchemaName a schema name over 25 characters
TranslationKeyExists a t key the default locale does not have
MissingAsset an asset_url naming a file that is not in assets/
ParserBlockingScript a <script src> without defer or async, or script_tag
ImgWidthAndHeight an <img> without width and height
ValidJSON a locale that is not nested strings

Limits are Shopify's: a 50MB zip, 100,000 files, 256KB per Liquid file, 512KB per template or group, 1.5MB for settings_data.json and each locale, 20MB per asset, 25 sections per template, 50 blocks per section and 50KB per text, richtext, HTML or Liquid setting. No theme is ever refused for weight.

The themes we host

Every hosted theme ships all fifteen templates, uploads with no finding at all, passes axe with no moderate or worse violation on six pages, and is held nightly to the Theme Store's Lighthouse bars: 60 for performance and 90 for accessibility, mobile, on its home and rooms pages.

CLI

@soundslot/cli is the same work without the browser. It is a client of the public API — the one @soundslot/sdk-public is generated from — and it renders nothing itself: a preview URL always points at the server.

npx @soundslot/cli theme init my-theme
soundslot login [--key <key>] [--url <url>]
soundslot theme init [dir] [--theme <slug>]
                                start from a theme we ship; no key needed
soundslot theme check [dir]     run the upload's checks offline, findings by Theme Check rule
                                --fail-level error|warning|suggestion  (default error)
soundslot theme push [dir]      upload it as an unpublished theme and preview it
soundslot theme pull [dir]      unpack the live theme, or --theme <ref>
soundslot theme dev [dir]       watch, push to a development theme, open the preview

theme init downloads the reference theme from GET /api/v1/themes/{slug}/archive. That endpoint takes no credential, so this is the one command that works before you have an account. --theme <slug> starts from one of the other themes the product ships.

login takes an API key issued under Developers in the console. It needs storefront.manage. The key is stored in ~/.soundslot/config.json, owner-only, and is never printed; SOUNDSLOT_API_KEY overrides it and SOUNDSLOT_API_URL overrides the API the CLI talks to.

theme check runs offline, with the same checks the server runs. A refusal prints under the upload's own code (invalid_theme, liquid_parse_error) and exits 1; every finding prints with its severity and rule name, and exits 1 when one is at or above --fail-level.

theme push zips the theme folders, uploads them, prints the upload's findings and then a preview link to the theme on your storefront.

An upload writes an unpublished theme. Pushing over the theme that is serving the storefront is refused with theme_is_live; --allow-live is the only way to write it, and it takes effect on the live site immediately.

theme pull unpacks the live theme, or the one --theme names.

theme dev pushes on save, debounced, and opens that preview once. Every save goes to <theme_name> (development), a theme of its own, so it never writes a published theme.

Bring a theme, or a designer.

Upload a theme you built, or talk to us about the storefront your studio already has.

From $39/mo · 14 days free · card required · nothing charged until day 15