Win-back: once, after the fix
Win-back: one note to a former Customer once the reason they left is fixed, and only once.
A Customer who left gets one note after the reason they gave is fixed, and never before.
You, to your agent
Owner statements shipped. Write once to the Customers who left over it. Don’t send.
curl "https://api.sb/leaseline.app/events?where=name%20%3D%20%22Subscription.unsubscribed%22&limit=10" \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY"
curl -X POST "https://api.sb/leaseline.app/messages/message_88/schedule?mode=try" \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"at":"2026-10-22T14:00:00Z"}'
curl -X POST "https://api.sb/leaseline.app/messages/message_89/schedule?mode=try" \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"at":"2026-10-29T14:00:00Z"}'
# Not live yet (gaps #2)
curl -X POST "https://api.sb/leaseline.app/subscriptions/subscription_4/reactivate?mode=try" \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY"
# Not live yet (gaps #4)
curl -X POST https://api.sb/leaseline.app/metrics/query \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"metric":"reactivated-arr","shape":"trend","window":"28d"}'import { $ } from 'startup-builder'
const { events } = await $.Event.list({ where: 'name = "Subscription.unsubscribed"', limit: 10 }) // 2 of the 6 who left in 90 days gave missing-feature: owner statements
await $.Message.schedule({ message: 'message_88', at: '2026-10-22T14: 00: 00Z' }, { mode: 'try' }) // the one note to Aspen Row, after the Change merged on Oct 20
await $.Message.schedule({ message: 'message_89', at: '2026-10-29T14: 00: 00Z' }, { mode: 'try' }) // a second note a week later
// 422 · The second note to Aspen Row did not send: the win-back note of Oct 22 is still open.
// customer-ask-cap: Wait for the open ask to close; Decision.list names the Play that holds them. · then Decision.list
await $.Subscription.reactivate({ subscription: 'subscription_4' }, { mode: 'try' }) // Not live yet, gaps #2
return await $.Metric.query({ metric: 'reactivated-arr', shape: 'trend', window: '28d' }) // Not live yet, gaps #4
npx startup-builder do Event.list --json '{"where":"name = \"Subscription.unsubscribed\"","limit":10}'
npx startup-builder do Message.schedule --json '{"message":"message_88","at":"2026-10-22T14:00:00Z"}' --try
npx startup-builder do Message.schedule --json '{"message":"message_89","at":"2026-10-29T14:00:00Z"}' --try
npx startup-builder do Subscription.reactivate --json '{"subscription":"subscription_4"}' --try # Not live yet (gaps #2)
npx startup-builder do Metric.query --json '{"metric":"reactivated-arr","shape":"trend","window":"28d"}' # Not live yet (gaps #4){
"name": "do",
"arguments": {
"code": "const { events } = await $.Event.list({ where: 'name = \"Subscription.unsubscribed\"', limit: 10 }) // 2 of the 6 who left in 90 days gave missing-feature: owner statements\nawait $.Message.schedule({ message: 'message_88', at: '2026-10-22T14:00:00Z' }, { mode: 'try' }) // the one note to Aspen Row, after the Change merged on Oct 20\nawait $.Message.schedule({ message: 'message_89', at: '2026-10-29T14:00:00Z' }, { mode: 'try' }) // a second note a week later\n// 422 · The second note to Aspen Row did not send: the win-back note of Oct 22 is still open.\n// customer-ask-cap: Wait for the open ask to close; Decision.list names the Play that holds them. · then Decision.list\nawait $.Subscription.reactivate({ subscription: 'subscription_4' }, { mode: 'try' }) // Not live yet, gaps #2\nreturn await $.Metric.query({ metric: 'reactivated-arr', shape: 'trend', window: '28d' }) // Not live yet, gaps #4",
"mode": "try"
}
}Response200
application/json{
"metric": {
"$id": "https: //api.sb/ leaseline.app/ metrics/ reactivated-arr",
"$type": "Metric",
"name": "Reactivated ARR"
},
"rows": [
{
"window": "28d",
"value": 347,
"customers": 1
}
]
}
sample · 2 Not live yetPreview+$347 ARR won back in 28 days · 1 of 6 who left · before-after,
Metric.query({"metric": · sample · leaseline.app at 2026-10-19
What it does
| What it does | Price per unit | Moves | Who does it | Today |
|---|---|---|---|---|
The win-back step of the save-and-win-back Workflow schedules one POST /{namespace}/messages/{message}/schedule per former Customer once a Change fixes the reason they gave. | $0.04 per note (Worker cost) | Won back | Head of customers | 3 of 5 calls live |
Set it up
| Check (dry run on a 0-Customer Startup, gaps #8) | Fix | Then | Your time | Wait |
|---|---|---|---|---|
baseline (Not live yet, gaps #8) · warn: 1 of 6 who left came back in 90 days | n 6: it reads before-after, not proof. | POST /{namespace}/metrics/query | 0 min | none |
role-unstaffed (Not live yet, gaps #8) · warn: Head of customers has no person: each note comes to you | Staff Head of customers, or leave it: each note comes to you. | POST /{namespace}/roles/{role}/staff | 0 min | none |
The records
{
"$id": "https://api.sb/leaseline.app/subscriptions/subscription_4" ,
"$type": "Subscription",
"status": "canceled",
"renewsAt": null,
"termEndsAt": "2026-09-02",
"quantity": 1
}Calls
| Call | Here | Emits | Status |
|---|---|---|---|
GET /{namespace}/events | 2 of the 6 who left in 90 days gave missing-feature: owner statements | — | live |
POST /{namespace}/messages/{message}/schedule | the one note to Aspen Row, after the Change merged on Oct 20 | Message.scheduled | live |
POST /{namespace}/messages/{message}/schedule | a second note a week later | Message.scheduled | live |
Subscription.reactivate (Not live yet, gaps #2) | — | Not live yet, gaps #2 | |
POST /{namespace}/metrics/query | Metric.queried | Not live yet, gaps #4 |
Events
| Event | When it fires | What hears it |
|---|---|---|
Change.merged | the fix ships: owner statements, Oct 20 | the win-back step, for each former Customer whose reason it answers |
Subscription.unsubscribed | they left, with a reason code | the list the win-back step reads |
Refuses when
| Check | Fix | Then |
|---|---|---|
customer-ask-cap fail · Not live yet, gaps #3 | Wait for the open ask to close; Decision.list names the Play that holds them. | GET /{namespace}/decisions |
JSON
{
"type": "https://startupbuilder.dev/errors/refused",
"title": "Refused",
"status": 422,
"detail": "The second note to Aspen Row did not send: the win-back note of Oct 22 is still open.",
"checks": [
{
"check": "customer-ask-cap",
"status": "fail",
"doc": "/code/refusals#check-customer-ask-cap",
"fix": "Wait for the open ask to close; Decision.list names the Play that holds them.",
"then": "Decision.list"
}
]
}It says no, says why, and names the fix.
| Check | Refuses | Fix | Then |
|---|---|---|---|
customer-ask-cap (Not live yet, gaps #3) | a second open ask to one Customer in a window, from any Play | Wait for the open ask to close; GET /{namespace}/decisions names the Play that holds them. | GET /{namespace}/decisions |
Plays that use it
| Play | What it does | Use it when |
|---|---|---|
| Objection watch | Records why each Customer or Deal said no, holds them from other outreach, and sends one Message, with proof, once the reason stops being true. | 20 or more nos a month recorded with a reason: lost Deals, cancels, downgrades and stalled trials |
fix-triggered-win-back (Not live yet, gaps #4) | The Play that writes once to former Customers when the reason they left is fixed. | today: The hub Workflow save-and-win-back: its win-back step, one POST /{namespace}/messages/{message}/schedule. |
Hub guides it deepens
| On startupbuilder.dev | Id |
|---|---|
| Save and win back | workflow: |
| Retention | process: |
Example
Aspen Row · left Sep 2, missing-feature: owner statements · the Change merged Oct 20 · one note Oct 22 · a second note refused (customer-ask-cap) · +$347 ARR won back in 28 days, 1 of 6 · sample Compiled, not written: edit its page, then regenerate. Every step runs from a sentence or a line of code. In your words: the CRM words you know, and where each lives here.