Your agent asks before it acts, so the same real-world action is attempted at most once.
Ratchet MCP Server (com.ratchetgate/ratchet)
This MCP server provides an effect-gate design for AI agents to help manage side effects. It aims to ensure the same real-world action is attempted at most once, enforce a declared spend limit, and produce signed, auditable receipts. The included excerpt discusses agent retries, ambiguity in failures, and process death mid-action.
π οΈ Key Features
Effect gate for AI agents
At-most-once attempted side effects
Spend limit budgeting
Signed receipts and auditable record
Addresses duplicate emails, double charges, and repeated writes
π Use Cases
Preventing duplicate real-world actions caused by non-deterministic control flow
Mitigating ambiguous network failures that lead to retries
Reducing repeated writes when processes die mid-action
β‘ Developer Benefits
Durably answers whether an effect can be attempted
Call this when a begin returned "approval_required" or "denied" and the reason mentions a circuit breaker. A breaker opens when an effect type is being performed far more often than its configured hourly ceiling β usually because something is looping.
What to do with the answer:
- If a breaker is open, STOP creating effects of that type. Retrying will not help and each attempt is recorded.
- resets_at tells you when it closes itself. If it is null, a human opened it deliberately and only a human will close it β do not wait, and do not poll.
- Report the reason to your operator and stop. Do not attempt to work around it by renaming the effect type, splitting the work across keys, or using a different idempotency key: that defeats a safety control that exists to protect the people your actions reach.
- effect_type "*" means every effect type in the workspace is stopped.
Call this IMMEDIATELY BEFORE performing any side effect that touches the outside world (sending a message, charging a card, creating a resource, writing to someone else's system). Returns a decision you MUST obey. If the response carries budget_warning, a spend ceiling exists but nothing was counted toward it β surface that to the operator rather than ignoring it. If it carries integration_warning, you have been beginning effects without reporting them: call ratchet_report_effect after every action, and tell the operator, because the effects already begun will start being blocked.
Decisions:
- "execute": you hold the lease. Perform the action now, then call ratchet_report_effect.
If the response carries vendor_idempotency_key, send that key to the vendor as ITS own idempotency key (the response says where it goes). Where enforced is true the vendor itself will then refuse a duplicate, which protects the action even if some other caller skips this gate entirely.
- "duplicate": this action ALREADY HAPPENED. Do NOT perform it. Use the returned `result` as though you had just done the work.
- "in_flight": another process is doing it right now. Do NOT perform it. Wait `retry_after_seconds` and ask again.
- "blocked": an earlier attempt may or may not have taken effect. Do NOT perform it. Tell the user what is unresolved, or verify at the vendor and call ratchet_resolve_effect.
- "approval_required": a human must approve. Do NOT perform it.
- "denied": policy or budget refused it. Do NOT perform it.
The idempotency_key must be derived deterministically from the work itself so that a retry of the same logical action produces the same key. Never use a random value or the current time.
Parameters12
effect_type
string
required
Namespaced kind of side effect, e.g. "email.send", "payment.charge", "github.pr.create". Policy is configured per type.
idempotency_key
string
required
Deterministic identifier for this specific logical action, e.g. "welcome-email:user_123" or "invoice:2026-08:acct_88123". The SAME action retried must produce the SAME key.
payload
object
optional
The action's parameters. Only a hash is stored β the raw content never persists. Reusing a key with different parameters is rejected, which catches key collisions.
estimated_cost_micros
integer
optional
What this action will cost at the third party, in micro-USD (1000000 = $1). ALWAYS SEND THIS when the action costs money. Spend ceilings are computed from it, and a ceiling with nothing declared against it never fires β the operator would be relying on a limit that cannot trigger. If the response contains budget_warning, that is exactly what has happened: tell the operator. Ratchet does not collect this money; it only counts it. It can also route the action to a human: an operator may set an approval threshold, and an action whose declared cost reaches it comes back approval_required instead of execute β so declaring accurately is what keeps large actions reviewable. Where a threshold is set, omitting this is refused with cost_required rather than allowed.
dimensions
object
optional
Who or what this action is aimed at, most often the destination: {"counterparty":"acct_1234"}. SEND THIS whenever the action targets a specific recipient, account or customer. It is how a per-destination ceiling can exist at all β "no more than $200 to any one counterparty per day" β and only a keyed hash of the value is stored, so Ratchet counts the destination without ever being able to read it. Declaring can only tighten: it never removes a limit. If begin is refused with dimension_required, the operator has made a dimension mandatory for this effect type and you must send it.
agent_id
string
optional
Identifier for you, the calling agent.
run_id
string
optional
Groups all effects from one task or run.
lease_seconds
integer
optional
How long you expect the action to take. Report before this elapses or the effect becomes indeterminate.
vendor
string
optional
Which vendor performs this effect (e.g. "stripe", "square", "adyen"). Shapes vendor_idempotency_key so it satisfies that vendor's rules.
group_key
string
optional
Use when this action is one step of a multi-step workflow that must succeed or fail as a whole, e.g. "booking:trip_8812". Lets the whole unit be rolled back later.
compensation
object
optional
How to undo THIS step if the workflow has to be rolled back. Declare it now, while you still know what undoing means β it cannot be worked out later. Steps without one are permanent.
compensates_effect_id
string
optional
Set when THIS call IS an undo, naming the effect it reverses. Comes from ratchet_unwind_group.
Raw schema
{
"type": "object",
"properties": {
"effect_type": {
"type": "string",
"pattern": "^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$",
"description": "Namespaced kind of side effect, e.g. \"email.send\", \"payment.charge\", \"github.pr.create\". Policy is configured per type."
},
"idempotency_key": {
"type": "string",
"minLength": 1,
"maxLength": 255,
"description": "Deterministic identifier for this specific logical action, e.g. \"welcome-email:user_123\" or \"invoice:2026-08:acct_88123\". The SAME action retried must produce the SAME key."
},
"payload": {
"type": "object",
"additionalProperties": true,
"description": "The action's parameters. Only a hash is stored β the raw content never persists. Reusing a key with different parameters is rejected, which catches key collisions."
},
"estimated_cost_micros": {
"type": "integer",
"minimum": 0,
"description": "What this action will cost at the third party, in micro-USD (1000000 = $1). ALWAYS SEND THIS when the action costs money. Spend ceilings are computed from it, and a ceiling with nothing declared against it never fires β the operator would be relying on a limit that cannot trigger. If the response contains budget_warning, that is exactly what has happened: tell the operator. Ratchet does not collect this money; it only counts it. It can also route the action to a human: an operator may set an approval threshold, and an action whose declared cost reaches it comes back approval_required instead of execute β so declaring accurately is what keeps large actions reviewable. Where a threshold is set, omitting this is refused with cost_required rather than allowed."
},
"dimensions": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "Who or what this action is aimed at, most often the destination: {\"counterparty\":\"acct_1234\"}. SEND THIS whenever the action targets a specific recipient, account or customer. It is how a per-destination ceiling can exist at all β \"no more than $200 to any one counterparty per day\" β and only a keyed hash of the value is stored, so Ratchet counts the destination without ever being able to read it. Declaring can only tighten: it never removes a limit. If begin is refused with dimension_required, the operator has made a dimension mandatory for this effect type and you must send it."
},
"agent_id": {
"type": "string",
"description": "Identifier for you, the calling agent."
},
"run_id": {
"type": "string",
"description": "Groups all effects from one task or run."
},
"lease_seconds": {
"type": "integer",
"minimum": 5,
"maximum": 3600,
"description": "How long you expect the action to take. Report before this elapses or the effect becomes indeterminate."
},
"vendor": {
"type": "string",
"maxLength": 32,
"description": "Which vendor performs this effect (e.g. \"stripe\", \"square\", \"adyen\"). Shapes vendor_idempotency_key so it satisfies that vendor's rules."
},
"group_key": {
"type": "string",
"description": "Use when this action is one step of a multi-step workflow that must succeed or fail as a whole, e.g. \"booking:trip_8812\". Lets the whole unit be rolled back later."
},
"compensation": {
"type": "object",
"description": "How to undo THIS step if the workflow has to be rolled back. Declare it now, while you still know what undoing means β it cannot be worked out later. Steps without one are permanent.",
"required": [
"effect_type",
"payload"
],
"properties": {
"effect_type": {
"type": "string",
"description": "e.g. \"booking.cancel\", \"payment.refund\""
},
"payload": {
"type": "object",
"additionalProperties": true,
"description": "What the undo will need β booking ids, charge ids."
}
}
},
"compensates_effect_id": {
"type": "string",
"description": "Set when THIS call IS an undo, naming the effect it reverses. Comes from ratchet_unwind_group."
}
},
"required": [
"effect_type",
"idempotency_key"
]
}
ratchet_report_effect
Call this IMMEDIATELY AFTER performing an action that ratchet_begin_effect authorised. Pass the lease_token you were given.
Report "succeeded" with a result β future duplicate callers replay that result instead of repeating the action.
Report "failed" ONLY when you are certain the action did NOT reach the outside world (for example, a validation error before the request was sent). That permits a clean retry.
If you are UNSURE whether it went through β a timeout, a dropped connection, an ambiguous error β do NOT report anything. Say so to the user. Letting the lease lapse records an honest "indeterminate", which is far safer than a false "failed" that licenses a duplicate.
Parameters6
effect_id
string
required
lease_token
string
required
outcome
string
required
result
object
optional
What the action produced (ids, confirmation numbers, links). Replayed verbatim to duplicate callers, so include what a retry would need.
failure_reason
string
optional
Required when outcome is "failed".
actual_cost_micros
integer
optional
What it really cost, if different from the estimate.
Raw schema
{
"type": "object",
"properties": {
"effect_id": {
"type": "string"
},
"lease_token": {
"type": "string"
},
"outcome": {
"type": "string",
"enum": [
"succeeded",
"failed"
]
},
"result": {
"type": "object",
"additionalProperties": true,
"description": "What the action produced (ids, confirmation numbers, links). Replayed verbatim to duplicate callers, so include what a retry would need."
},
"failure_reason": {
"type": "string",
"description": "Required when outcome is \"failed\"."
},
"actual_cost_micros": {
"type": "integer",
"minimum": 0,
"description": "What it really cost, if different from the estimate."
}
},
"required": [
"effect_id",
"lease_token",
"outcome"
]
}
ratchet_extend_lease
Call this periodically during a long action you were authorised to perform, before the lease expires. It tells Ratchet you are alive and extends your hold.
Use it when work turns out to take longer than the lease you asked for β a slow vendor, a large export, a retrying upload. Without it, the lease expires while you are still working, the effect is recorded as having an UNKNOWN outcome, and your report is then refused.
If it fails with lease_expired or lease_lost, STOP. Your hold is gone and the outcome is already recorded as unknown. Do not keep going and do not retry the action β call ratchet_begin_effect to find out where things actually stand.
Parameters3
effect_id
string
required
lease_token
string
required
extend_seconds
integer
optional
How much longer you need, from now. Clamped to the policy maximum.
Raw schema
{
"type": "object",
"properties": {
"effect_id": {
"type": "string"
},
"lease_token": {
"type": "string"
},
"extend_seconds": {
"type": "integer",
"minimum": 5,
"maximum": 3600,
"description": "How much longer you need, from now. Clamped to the policy maximum."
}
},
"required": [
"effect_id",
"lease_token"
]
}
ratchet_get_effect
Look up the recorded state of an action WITHOUT reserving a lease and without consuming your plan allowance. Use it to answer "did I already do this?" β for example when resuming after a crash, or when a user asks whether something went through. To actually perform work, use ratchet_begin_effect instead; this tool never grants permission.
Parameters2
effect_type
string
required
Namespaced kind of side effect, e.g. "email.send", "payment.charge", "github.pr.create". Policy is configured per type.
idempotency_key
string
required
Raw schema
{
"type": "object",
"properties": {
"effect_type": {
"type": "string",
"pattern": "^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$",
"description": "Namespaced kind of side effect, e.g. \"email.send\", \"payment.charge\", \"github.pr.create\". Policy is configured per type."
},
"idempotency_key": {
"type": "string"
}
},
"required": [
"effect_type",
"idempotency_key"
]
}
ratchet_resolve_effect
Use ONLY after you have checked the third-party system and now know what really happened to an effect that was left "indeterminate". Record "succeeded" if the action did occur, "failed" if it did not, or "cancelled" to abandon it. Include how you verified it in `evidence`. Never guess: resolving incorrectly is exactly the duplicate or lost action this service exists to prevent.
Parameters4
effect_id
string
required
outcome
string
required
evidence
string
optional
How you verified the real outcome. Stored in the audit trail.
Recall the work already gated under a run id, before doing anything that might repeat it. Call this FIRST when resuming a task β after a restart, a handoff, or when your context has been compacted and you are no longer certain what you did. It returns what succeeded with the recorded results, what is still in flight, what failed, and β separately, because it is the only category that can hurt you β what has an unknown outcome. Anything under "done" has already happened: use its result rather than performing it again. Costs about a seventeenth of the context of listing the same effects.
Parameters1
run_id
string
required
The run id you passed to ratchet_begin_effect for this task.
Raw schema
{
"type": "object",
"properties": {
"run_id": {
"type": "string",
"maxLength": 128,
"description": "The run id you passed to ratchet_begin_effect for this task."
}
},
"required": [
"run_id"
]
}
ratchet_list_effects
Review recent effects for this workspace, optionally filtered by state or run. Use it to find unresolved work β filter by state "indeterminate" to see every action whose outcome is unknown and still needs verification.
Parameters4
state
string
optional
effect_type
string
optional
Namespaced kind of side effect, e.g. "email.send", "payment.charge", "github.pr.create". Policy is configured per type.
Shows how this workspace has configured a given effect type: whether it is allowed, how long a lease lasts, the attempt ceiling, spend limits, the approval threshold above which an action waits for a human, and β most importantly β what happens when an attempt ends indeterminate. Check this before designing a retry strategy.
Parameters1
effect_type
string
required
Namespaced kind of side effect, e.g. "email.send", "payment.charge", "github.pr.create". Policy is configured per type.
Raw schema
{
"type": "object",
"properties": {
"effect_type": {
"type": "string",
"pattern": "^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$",
"description": "Namespaced kind of side effect, e.g. \"email.send\", \"payment.charge\", \"github.pr.create\". Policy is configured per type."
}
},
"required": [
"effect_type"
]
}
ratchet_unwind_group
Call this when a multi-step workflow fails partway and the steps that already succeeded must be undone β a booking made but not paid for, a resource created but not configured.
Returns the exact compensations to perform, in the order to perform them, which is the REVERSE of the order they succeeded in. Undoing forwards can strand a step that depended on an earlier one.
Ratchet does NOT perform the compensations. For each step: call ratchet_begin_effect with the step's suggested_idempotency_key and compensates_effect_id, do the real undo, then call ratchet_report_effect. Gating the undo is what stops a retry from refunding twice.
Read `unresolved` first. If any effect in the group has an unknown outcome, STOP and resolve it before undoing anything around it. Read `irreversible` too: those steps succeeded and declared no way to undo themselves, so a human has to decide what to do about them. Say so plainly rather than implying the rollback was complete.
Parameters2
group_key
string
required
The unit of work to roll back, e.g. "booking:trip_8812".
reason
string
optional
Why it is being rolled back. Stored for the operator.
Raw schema
{
"type": "object",
"properties": {
"group_key": {
"type": "string",
"description": "The unit of work to roll back, e.g. \"booking:trip_8812\"."
},
"reason": {
"type": "string",
"description": "Why it is being rolled back. Stored for the operator."
}
},
"required": [
"group_key"
]
}
ratchet_get_group
Shows every step in a multi-step unit of work: what succeeded, what can still be undone, what has already been undone, what is irreversible, and what has an unknown outcome. Use it to answer "where did this workflow actually get to?" after a crash, without changing anything.
Returns the current plan, remaining included effects for the month, prepaid credit balance, and today's declared external spend against each budget ceiling. Use it to warn a user before a long run exhausts an allowance or a budget.
Parameters
No parameters.
Raw schema
{
"type": "object",
"properties": {}
}
ratchet_list_receipts
Returns a signed receipt for every decision made about one effect, refusals included. Each signature is over the exact bytes in `body` and verifies offline against the Ed25519 key published at /.well-known/ratchet-receipt-key β you do not have to trust this server to check them. Use this when a human asks you to PROVE an action was or was not authorised, rather than asserting it.
Given the idempotency keys for actions a vendor says actually happened, returns which ones went through Ratchet and which it has never seen. The unseen ones are code paths that acted WITHOUT asking, so a retry there can act twice β a bug the operator almost certainly does not know about. Send references only; never send credentials.
Parameters2
effect_type
string
required
keys
array
required
Idempotency keys your system should have used for those actions.
Raw schema
{
"type": "object",
"properties": {
"effect_type": {
"type": "string"
},
"keys": {
"type": "array",
"items": {
"type": "string"
},
"maxItems": 1000,
"description": "Idempotency keys your system should have used for those actions."
}
},
"required": [
"effect_type",
"keys"
]
}
ratchet_get_prevented_loss
Counts duplicate actions refused in the last 30 days and what they would have cost. Only counts refusals where a cost was declared on the effect, so it under-reports rather than flatters. IMPORTANT: pass estimated_cost_micros on ratchet_begin_effect or this reads zero β the number is only as good as what callers declare. This is money not spent at your vendors, never money paid to Ratchet.
An effect gate for AI agents. Your agent asks before it does anything it cannot take back β
charge a card, ship a deploy, publish a package, send the email β and gets a durable decision, so
the same real-world action is attempted at most once across crashes and retries. Agents can also
read back what a run already did, and spend against a limit they cannot raise.
Agents retry. LLM control flow is non-deterministic, network calls fail ambiguously, and processes
die mid-action. The result is duplicate emails, double charges, and repeated writes β and nothing
in the stack knows which. Vendor idempotency keys help for the few vendors that offer them, and
never across separate agent processes or model providers.
Ratchet does not execute your actions. It holds a durable decision record in front of them.
If your process dies between "go" and "done", most systems quietly let the next caller retry.
Ratchet won't. The lease expires and the effect becomes indeterminate β a known unknown,
surfaced instead of buried. What happens next is the policy you declared for that effect type:
on_indeterminate
Behaviour
Use for
block (default)
No automatic retry. A human or verifying agent resolves it.
Anything irreversible
retry
A fresh attempt is granted, up to max_attempts.
Vendors that are genuinely idempotent
probe
Caller must verify at the vendor and record evidence first.
Charges, transfers, payouts
Exactly-once delivery is not achievable in a distributed system and this project does not claim it.
What Ratchet guarantees is at-most-once initiation, a recorded outcome that later callers
replay, and an explicit state for the case nobody else admits exists.
Quick start
Requirements: Node 20.11+, Docker (for local Postgres).
bash
npm install
cp .env.example .env# defaults work for local development
npm run dev:db # Postgres on :5433 via Docker
npm run migrate
npm run dev # control plane on :8787
npm run dev:worker # lease reaper + webhook delivery (separate terminal)
npm run seed populates a workspace with realistic state β a completed effect, a duplicate, an
indeterminate one, and one awaiting approval β so the console has something to show.
Or with Docker Compose
bash
AUTH_SECRET=$(openssl rand -base64 32) docker compose up --build
The core loop
bash
# 1. Ask, before you act.
curl -X POST http://localhost:8787/v1/effects/begin \
-H "Authorization: Bearer $RATCHET_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"effect_type": "email.send",
"idempotency_key": "welcome:user_123",
"payload": { "to": "sam@example.com" },
"estimated_cost_micros": 800
}'# β { "decision": "execute", "effect_id": "eff_...", "lease_token": "lt_..." }# 2. Do the real thing, yourself. Ratchet never touches it.# 3. Say what happened.
curl -X POST http://localhost:8787/v1/effects/eff_.../report \
-H "Authorization: Bearer $RATCHET_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "lease_token": "lt_...", "outcome": "succeeded",
"result": { "message_id": "msg_9f2" } }'# Any later caller with the same key now gets:# β { "decision": "duplicate", "result": { "message_id": "msg_9f2" } }
The one rule: report failed only when you know the action did not reach the outside world.
If you are unsure β a timeout, a dropped connection β report nothing. The lease lapses and Ratchet
records an honest indeterminate. A false failed is worse than silence, because it licenses a
duplicate.
Idempotency keys
Derive the key from the work, deterministically.
Good
Broken
welcome-email:user_123
uuid4()
invoice:2026-08:acct_88123
"send-" + Date.now()
pr:acme/api:feature-auth
f"job-{attempt_number}"
A key that changes on every attempt makes every retry look like new work.
When Ratchet is unreachable
Ratchet sits in your critical path, so decide this before integrating: on an outage your agent
either acts without the gate (fail-open) or refuses to act (fail-closed). Use fail-closed
for anything you would have to apologise for; fail-open where the vendor deduplicates anyway.
Full contract, client patterns, and the honest availability posture:
docs/FAILURE_MODES.md.
The control plane is stateless and scales horizontally β it may run on serverless infrastructure.
The worker may not. It expires leases on a timer whether or not a request is in flight; a
serverless function cannot do that. Run it as a long-running container. Multiple replicas are safe
(every claim uses FOR UPDATE SKIP LOCKED).
At-most-once is enforced by a database unique constraint on
(workspace_id, effect_type, idempotency_key) β not by application logic.
The control plane is stateless and can scale freely. The worker cannot β it expires leases on a
timer whether or not a request arrives, so it must be a long-running process. That single
constraint rules out purely serverless hosts (Vercel, Netlify functions) despite their being
easier, and is why fly.toml runs both process groups from one image.
bash
brew install flyctl && fly auth login # once, needs your browser
npm run deploy:fly
The script is idempotent: it creates the app, provisions managed Postgres, generates AUTH_SECRET
once (never rotating it, since that would invalidate every API key), deploys both processes, and
verifies readiness. It refuses to proceed unless preflight passes:
bash
npm run deploy:preflight
Preflight runs the full suite and production build, then checks that AUTH_SECRET is strong and
not the dev default, PUBLIC_URL is set (otherwise the manifest would advertise localhost),
RATE_LIMIT_OVERRIDE is unset, private-network webhooks are off, CORS carries no wildcard, and β
if Stripe is selected β that both the key and the webhook secret are present. It prints no secret
values.
Any container platform works; only fly.toml is Fly-specific. Set DATABASE_URL, AUTH_SECRET,
PUBLIC_URL, NODE_ENV=production, then run node dist/api/server.js (scale freely) and
node dist/worker/main.js (at least one, always on).
To rehearse the exact production containers locally:
bash
AUTH_SECRET=$(openssl rand -base64 32) docker compose up --build
Commands
Command
What it does
npm run dev
Control plane with reload
npm run dev:worker
Worker with reload
npm run dev:db / dev:db:down
Local Postgres in Docker
npm run migrate
Apply migrations (advisory-locked; safe to run concurrently)
npm run seed
Populate a workspace with realistic state
npm test
Typecheck + unit + integration + e2e against a disposable database
npm run test:unit / test:integration / test:e2e
One layer
npm run typecheck / lint
TypeScript in strict mode
npm run build
Compile to dist/
npm start / start:worker
Run the compiled build
npm run mcp:stdio
MCP server over stdio
npm run openapi
Write the OpenAPI document to disk
npm run deploy:preflight
Verify the build and configuration are safe to deploy
npm run deploy:fly
Deploy control plane, worker, and database to Fly.io
npm run metrics
Operating metrics against the pricing-review thresholds
npm run stripe:check
Report payment configuration and verify it against Stripe
npm run stripe:listen
Forward Stripe events to a local instance and print a webhook secret
npm run audit
Production dependency audit
Environment
Every variable, with defaults and safety notes, is in .env.example. The two that
are required:
Variable
Notes
DATABASE_URL
Postgres connection string
AUTH_SECRET
32+ random characters. Derives the API-key pepper and console session ids. Rotating it invalidates every API key and session.
In NODE_ENV=production the process refuses to start if AUTH_SECRET is the development
default or shorter than 32 characters, if CORS_ORIGINS contains *, or if
WEBHOOK_ALLOW_PRIVATE_NETWORK is on.
For agents
Surface
Path
OpenAPI 3.1
/openapi.json β generated from the schemas the routes validate against
Capability manifest
/.well-known/agent-manifest.json β including what Ratchet doesn't do
Ready-to-use configs and code: examples/ β Python, TypeScript, curl, Claude Desktop,
Cursor, and generic MCP over HTTP.
Security posture
Only a SHA-256 fingerprint of your payload is stored β never the payload itself.
API keys are stored as HMAC-SHA256 peppered with a server secret; a database leak alone
yields no usable key. Comparison is constant-time and runs even for unknown prefixes.
Every query is workspace-scoped; a cross-tenant lookup returns 404, never a hint.
SSRF defence in two layers: static URL validation, then DNS re-resolution with the socket
pinned to the checked address on every delivery attempt. Redirects are never followed.
Agent-supplied text is data, never instructions. Decisions come from stored policy and
database state β nothing in a payload can widen a scope, raise a budget, or change a policy.
Unknown request fields are rejected, not silently dropped.
No third-party audit has been performed. There is no SOC 2 report and no penetration test.
Details and threat model: docs/handoff/SECURITY_REVIEW.md.
Pricing
One meter: a gated effect β the first begin for a given (effect_type, idempotency_key).
Duplicate suppression, in-flight checks, retries, reports, reads, policy changes, and webhooks are
all free. You are never charged for the retry behaviour the product exists to absorb.
Plan
Price
Included effects/mo
Overage
Free
$0
1,000
$1.50 / 1,000, prepaid credit only
Pro
$29/mo
25,000
$1.50 / 1,000
Custom
contact
above 250,000
negotiated
Free stops at its allowance unless you load prepaid credit; there is no automatic overage and no
invoice. Past roughly 19,300 effects a month, Pro is cheaper than paying credit on Free, so the
upgrade is arithmetic rather than a wall.
Two plans, not three: three tiers assert knowledge of three customer segments, and there is no
usage history yet to support one. See
docs/handoff/PRICING_AND_DISTRIBUTION_REVIEW.md
for the reasoning, including why the previous ladder priced a 20x usage range at one number.
Overage draws from prepaid credit, so a runaway agent stops at your balance rather than generating
an invoice. Cost model and assumptions:
docs/handoff/PRICING_AND_UNIT_ECONOMICS.md.
Payments
Stripe is fully wired: startCheckout creates real Checkout Sessions, and the signed
checkout.session.completed webhook credits the ledger. Card details are entered on Stripe's own
page and never reach Ratchet, and credit is applied only on the signed webhook β never on the
browser returning to a success URL.
Both STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are required before checkout opens. A key
alone selects Stripe but keeps checkout closed: taking a payment that cannot be confirmed would
leave a customer charged and uncredited. Run npm run stripe:check to see exactly what is
configured (it prints no secret values).
For local development, get a webhook secret without a public URL:
bash
npm run stripe:listen # prints whsec_β¦ ; put it in .env and restart
Most of Stripe's onboarding checklist does not apply. Ratchet builds line items inline and never
reads your Stripe catalog, so there is no product to create; it does not use Stripe Invoicing;
and it creates Checkout Sessions through the API, not the no-code builder. What does matter before
live payments: verify your account (Stripe's KYC), create a webhook endpoint pointing at
your deployed /v1/billing/webhook/stripe and use that endpoint's signing secret in production,
and decide whether you need tax collection (STRIPE_AUTOMATIC_TAX, off by default).
With no Stripe credentials at all, the built-in test adapter runs instead: no card is charged and
no external request is made. Verified so far in Stripe test mode only β see
docs/handoff/KNOWN_LIMITATIONS.md before going live,
particularly regarding refunds.