Paid plans are launching soon — every workspace is on Free for now. The plans below describe what Solo and Pro include once checkout is live.
Paid tiers (Solo, Pro) ship the full feature set — REST API, MCP server, CLI, webhooks, schema management, custom objects, data export. No feature gating between Solo and Pro; only volume gating.
Free is experience-only: it lets developers feel the platform via REST/MCP/CLI but blocks production extensibility (schema, webhooks, multi-key) and auto-pauses workspaces after 30 days of agent inactivity. Upgrade to Solo to actually build on Salty.
Plans
| Plan | Price | API calls / mo | Records | Sustained | Burst |
|---|
| Free | $0 | 500 | 10 | 2 req/sec | 10 req/sec |
| Solo | $20/mo | 100,000 | 25,000 | 100 req/sec | 500 req/sec |
| Pro | $99/mo | 1,000,000 | 250,000 | 1,000 req/sec | 5,000 req/sec |
Free tier locks (return 402 plan_upgrade_required):
POST /schema/.../attributes — cannot create custom attributes
PATCH/DELETE /schema/.../attributes/{key} — cannot modify or deprecate them
POST /custom-objects — cannot define custom object types
POST /webhook-endpoints — cannot register webhooks
POST /api-keys when a second active key would be created — revoke the existing key first
Overage on paid plans: 1per10,000APIcallsbeyondcap,0.50 per 1,000 records beyond cap, billed monthly.
Idle pause: Free workspaces with no agent call for 30 days flip to is_active = false. The next agent call returns 402 workspace_paused with a hint to call POST /workspaces/reactivate (JWT-only).
Upgrading + cancelling
The /pricing page in the admin web app branches on auth state:
- Logged out → marketing tier cards.
- Logged in → current plan + usage bars + Upgrade/Cancel buttons per tier.
Click Upgrade to Solo: the server action calls POST /billing/checkout {plan: "solo"}. The Dodo provider returns a checkout session (either {kind: "instant", subscription_id: …} in dev with the mock provider, or {kind: "redirect", url: "https://checkout.dodopayments.com/…"} in prod). On success Dodo fires a subscription.created webhook back to POST /webhooks/dodo, the handler flips workspaces.plan to the new tier, and the user’s next page load reflects the change.
Click Cancel subscription: POST /billing/cancel (JWT-only) tells Dodo to cancel, the subscription.canceled webhook flips the workspace back to free.
The /pricing, billing checkout, and billing cancel endpoints are JWT-only and exempt from the usage cap — so a customer over their cap can always reach billing to upgrade or cancel.
Mock vs real Dodo
Salty picks a DodoProvider implementation at boot based on the DODO_API_KEY env var:
- Unset →
MockDodoProvider. Checkout returns success synchronously and fires a real POST /webhooks/dodo to the local API with a valid HMAC signature. This exercises the production webhook handler in dev — no Dodo account needed.
- Set →
RealDodoProvider. Checkout calls Dodo’s hosted-checkout API and returns the redirect URL.
This lets you develop and test the entire billing flow locally without a Dodo account.
Webhook signature (inbound from Dodo)
Inbound from Dodo carries Dodo-Signature: t=<ms>,v1=<hex> (HMAC-SHA-256 over <t>.<body> with DODO_WEBHOOK_SECRET). The handler verifies, deduplicates by dodo_event_id (UNIQUE constraint), inserts a row into the billing_events audit table, then dispatches by event type:
| Event | Action |
|---|
subscription.created / subscription.updated | Set plan, dodo_customer_id, dodo_subscription_id on workspace |
subscription.canceled | Set plan = 'free', clear dodo_subscription_id |
payment.failed | Log only (Dodo handles retries) |
Hard caps
See Concepts → Rate limits for the full mechanics. TL;DR: PATCH /workspace {hard_cap_api_calls: 50000} sets a customer ceiling for the current calendar month, and the effective cap is the lesser of (plan cap, hard cap). Counter resets lazily on the 1st (no cron). Over the cap → all agent traffic gets 429 cap_exceeded; admin traffic (JWT) stays unaffected so the customer can raise the cap or upgrade.
Audit
Every Dodo event is recorded in the billing_events table:
select event_type, payload->'data'->>'plan' as plan, occurred_at
from billing_events
where workspace_id = '<id>'
order by occurred_at desc;
There’s no API endpoint surfacing this in v1 — it’s an internal audit table. v1.1 will add GET /billing/events.