Docs / Webhooks
Webhooks
When a subscriber finishes a cancel flow, AcornRetain can send what happened to your own server: the outcome, the offer, and every answer with anything they typed. Use it to put cancel reasons in your CRM, a spreadsheet, or your own database.
Set one up
In the dashboard, open Settings, then Notifications, and add a Webhook endpoint. Choose which outcomes it receives and whether it gets Live events, Test events, or both. The URL must use HTTPS and be reachable from the public internet. Each endpoint has a Test button that sends a sample cancel event in test mode, with session.id set to sess_test.
When it fires
There is one event, session.completed. It is sent once for each finished cancel flow whose outcome the endpoint is set to receive. A subscriber who answers the survey and then closes the window without choosing sends nothing, so that session never reaches the dashboard or a webhook.
| session.outcome | What happened |
|---|---|
| discount | The subscriber accepted a discount and it was applied to their Stripe subscription. |
| pause | The subscriber accepted a pause and it was applied. Billing stops until offer.resumesAt. Access does not, unless you gate it. |
| plan_change | The subscriber accepted a different plan and it was applied. offer.effectiveAt says when it starts. |
| trial_extension | The subscriber accepted more trial time and it was applied. offer.trialEndsAt is the new trial end. |
| cancel | The subscriber cancelled. Their subscription is set to end at the end of the billing period. |
| abort | A change we tried in Stripe failed. With an offer, offer.granted is false and offer.failureReason says why. For a failed cancellation, offer is null and the reason is on the session in your dashboard. |
| manual_action_required | AcornRetain could not make the change itself, because another Stripe app controls the subscription or a subscription schedule is attached. Someone on your team has to make it in Stripe. manual says exactly what. |
| cancel_blocked | The cancel was refused because a subscription schedule is attached. schedule describes it, and the subscriber was told what to do next. |
session.saved is decided when the event is built and never changes. A manual_action_required event is always saved: false, even after your team makes the change in Stripe; the dashboard counts the save once it lands. Treat each event as a record of that moment.
These outcome names are the webhook's own. The dashboard and the CSV export record a refused cancel as blocked, and a handoff under the offer's own outcome with a manual_state.
Fields
| Field | Type | Meaning |
|---|---|---|
| event | string | Always session.completed. |
| id | string | Unique per event and the same on every retry. Use it to drop duplicates. |
| createdAt | ISO 8601 string | When the cancel flow ended. |
| appId | string | Your app's public id, the one in your snippet. |
| appName | string | Your app's name in the dashboard. |
| mode | "live" or "test" | Which Stripe mode the session ran in. |
| session | object | The cancel flow this event is about. |
| session.id | string | The session id. The CSV export calls it session_id. |
| session.outcome | string | How the flow ended. See the outcomes above. |
| session.saved | boolean | True for the four offer outcomes. Decided when the event is built and never updated. |
| session.flowVersion | number | The published version of your cancel flow the subscriber saw. |
| subscriber | object | Who went through the flow. |
| subscriber.customerId | string | The Stripe customer id your server passed to the snippet. |
| subscriber.subscriptionId | string or null | The Stripe subscription id, when one was passed. |
| value | object | What the subscription is worth. |
| value.amountCents | number or null | The price for one billing period, in the currency's smallest unit, as Stripe stores it: 4900 is $49.00, and 4900 is ¥4,900. |
| value.currency | string or null | Three-letter code in lower case, such as usd. |
| value.interval | string or null | day, week, month or year. |
| value.intervalCount | number or null | How many intervals one billing period covers. A quarterly plan is month with 3. Null means 1. |
| value.periodEndsAt | ISO 8601 string or null | When the current billing period ends. |
| offer | object or null | The offer involved, or null when there was none, as on a cancel. |
| offer.id | string | The offer's id in your flow. |
| offer.type | string | discount, pause, plan_change or trial_extension. |
| offer.granted | boolean | True when it was applied in Stripe. |
| offer.stripeRefs | object or null | Ids of the Stripe objects we changed, such as subscription, coupon, price or schedule. |
| offer.failureReason | string or null | Why applying the offer failed. |
| offer.resumesAt | ISO 8601 string or null | Pause only: when billing starts again. |
| offer.trialEndsAt | ISO 8601 string or null | Trial extension only: the new trial end. |
| offer.effectiveAt | ISO 8601 string or null | Plan change only: when the new plan starts. |
| offer.chargedNowCents | number or null | Plan change only: what Stripe invoiced straight away, if anything. |
| offer.chargedNowCurrency | string or null | Plan change only: the currency of that invoice. |
| survey | object | What the subscriber answered and wrote. |
| survey.answers | array | One entry per answer. The survey has one question today, so at most one. |
| survey.answers[].questionKey | string | Always reason today. |
| survey.answers[].answerValue | string | The answer's stable id, such as opt_9e83f4a6. It stays the same when you reword the answer. |
| survey.answers[].answerLabel | string or null | The answer as worded when the subscriber chose it. |
| survey.answers[].freeText | string or null | What they typed in that answer's text box. |
| survey.freeformFeedback | string or null | What they typed in the final free-text step. |
| manual | object or null | Always sent. Null unless the outcome is manual_action_required; then it says what your team has to do in Stripe. |
| schedule | object, sometimes absent | Sent only on cancel_blocked, and on a manual_action_required that a subscription schedule caused. Describes the schedule. |
Examples
A save
A subscriber accepted two more weeks of trial. The offer carries the new trial end.
{
"event": "session.completed",
"id": "evt_7Hq2Lx9VbN4cTz1R",
"createdAt": "2026-10-06T09:30:00.000Z",
"appId": "app_Qm7rT2vXk9Lp",
"appName": "Acme Cloud",
"mode": "live",
"session": {
"id": "5b0f3c1e-8a2d-4f7e-9c41-2d6a8e9b0c13",
"outcome": "trial_extension",
"saved": true,
"flowVersion": 4
},
"subscriber": {
"customerId": "cus_Qx81nVb2LwT9",
"subscriptionId": "sub_1Q8ZbHFk2LwT9xYz"
},
"value": {
"amountCents": 4900,
"currency": "usd",
"interval": "month",
"intervalCount": 1,
"periodEndsAt": "2026-10-21T00:00:00.000Z"
},
"offer": {
"id": "off_trial_14",
"type": "trial_extension",
"granted": true,
"stripeRefs": {
"subscription": "sub_1Q8ZbHFk2LwT9xYz"
},
"failureReason": null,
"resumesAt": null,
"trialEndsAt": "2026-11-04T00:00:00.000Z",
"effectiveAt": null,
"chargedNowCents": null,
"chargedNowCurrency": null
},
"survey": {
"answers": [
{
"questionKey": "reason",
"answerValue": "opt_9e83f4a6",
"answerLabel": "Not ready to commit yet",
"freeText": "Still testing it with my team."
}
],
"freeformFeedback": "Two more weeks would help."
},
"manual": null
}A blocked cancel
A subscription schedule was attached, so the cancel was refused and the subscriber was told what to do next. Only this outcome, and a handoff a schedule caused, carry schedule.
{
"event": "session.completed",
"id": "evt_Rz3Ka8mWq1Yt6NcB",
"createdAt": "2026-10-06T11:05:00.000Z",
"appId": "app_Qm7rT2vXk9Lp",
"appName": "Acme Cloud",
"mode": "live",
"session": {
"id": "c2e7a9d4-1b3f-4e8a-a6c5-7d9f0b2e4a61",
"outcome": "cancel_blocked",
"saved": false,
"flowVersion": 4
},
"subscriber": {
"customerId": "cus_Lm40sKd8PqR2",
"subscriptionId": "sub_1Q7YaGEj1KvS8wXy"
},
"value": {
"amountCents": 39000,
"currency": "usd",
"interval": "year",
"intervalCount": 1,
"periodEndsAt": "2027-03-01T00:00:00.000Z"
},
"offer": null,
"survey": {
"answers": [
{
"questionKey": "reason",
"answerValue": "opt_2c7d1e05",
"answerLabel": "Too expensive",
"freeText": null
}
],
"freeformFeedback": null
},
"manual": null,
"schedule": {
"kind": "fixed_term",
"changeAt": null,
"endsAt": 1803859200,
"planName": "Annual",
"amountCents": null,
"currency": null,
"interval": null,
"intervalCount": null,
"quantity": null
}
}Delivery
- An HTTP POST with a JSON body and
Content-Type: application/json. - We wait up to 10 seconds for an answer. Any 2xx status is a success; anything else, or no answer, is a failure.
- A failed delivery is tried again after a growing delay, up to 3 attempts in all. After the third failure it shows as Failed under Recent deliveries on the Notifications page.
- Every attempt carries the same
id. Store it and ignore an id you have seen. - Events can arrive out of order. Use
createdAtto put them in order.
Check that a request came from us
Events are not signed. Give your endpoint a secret that only you and AcornRetain know, and reject any request without it. There are two ways to send one:
- Basic auth. Put a username and password in the endpoint URL, in the standard place between https:// and the host. We take them out of the URL and send them as an
Authorization: Basicheader. - A custom header. Add one header name and value to the endpoint, such as
X-Webhook-Secretwith a long random value. If you name itAuthorization, it replaces the Basic header.
We store the URL and the header value encrypted, and HTTPS keeps them private on the way.
Back to getting started. Questions? Contact us.