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.outcomeWhat happened
discountThe subscriber accepted a discount and it was applied to their Stripe subscription.
pauseThe subscriber accepted a pause and it was applied. Billing stops until offer.resumesAt. Access does not, unless you gate it.
plan_changeThe subscriber accepted a different plan and it was applied. offer.effectiveAt says when it starts.
trial_extensionThe subscriber accepted more trial time and it was applied. offer.trialEndsAt is the new trial end.
cancelThe subscriber cancelled. Their subscription is set to end at the end of the billing period.
abortA 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_requiredAcornRetain 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_blockedThe 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

FieldTypeMeaning
eventstringAlways session.completed.
idstringUnique per event and the same on every retry. Use it to drop duplicates.
createdAtISO 8601 stringWhen the cancel flow ended.
appIdstringYour app's public id, the one in your snippet.
appNamestringYour app's name in the dashboard.
mode"live" or "test"Which Stripe mode the session ran in.
sessionobjectThe cancel flow this event is about.
session.idstringThe session id. The CSV export calls it session_id.
session.outcomestringHow the flow ended. See the outcomes above.
session.savedbooleanTrue for the four offer outcomes. Decided when the event is built and never updated.
session.flowVersionnumberThe published version of your cancel flow the subscriber saw.
subscriberobjectWho went through the flow.
subscriber.customerIdstringThe Stripe customer id your server passed to the snippet.
subscriber.subscriptionIdstring or nullThe Stripe subscription id, when one was passed.
valueobjectWhat the subscription is worth.
value.amountCentsnumber or nullThe 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.currencystring or nullThree-letter code in lower case, such as usd.
value.intervalstring or nullday, week, month or year.
value.intervalCountnumber or nullHow many intervals one billing period covers. A quarterly plan is month with 3. Null means 1.
value.periodEndsAtISO 8601 string or nullWhen the current billing period ends.
offerobject or nullThe offer involved, or null when there was none, as on a cancel.
offer.idstringThe offer's id in your flow.
offer.typestringdiscount, pause, plan_change or trial_extension.
offer.grantedbooleanTrue when it was applied in Stripe.
offer.stripeRefsobject or nullIds of the Stripe objects we changed, such as subscription, coupon, price or schedule.
offer.failureReasonstring or nullWhy applying the offer failed.
offer.resumesAtISO 8601 string or nullPause only: when billing starts again.
offer.trialEndsAtISO 8601 string or nullTrial extension only: the new trial end.
offer.effectiveAtISO 8601 string or nullPlan change only: when the new plan starts.
offer.chargedNowCentsnumber or nullPlan change only: what Stripe invoiced straight away, if anything.
offer.chargedNowCurrencystring or nullPlan change only: the currency of that invoice.
surveyobjectWhat the subscriber answered and wrote.
survey.answersarrayOne entry per answer. The survey has one question today, so at most one.
survey.answers[].questionKeystringAlways reason today.
survey.answers[].answerValuestringThe answer's stable id, such as opt_9e83f4a6. It stays the same when you reword the answer.
survey.answers[].answerLabelstring or nullThe answer as worded when the subscriber chose it.
survey.answers[].freeTextstring or nullWhat they typed in that answer's text box.
survey.freeformFeedbackstring or nullWhat they typed in the final free-text step.
manualobject or nullAlways sent. Null unless the outcome is manual_action_required; then it says what your team has to do in Stripe.
scheduleobject, sometimes absentSent 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 createdAt to 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: Basic header.
  • A custom header. Add one header name and value to the endpoint, such as X-Webhook-Secret with a long random value. If you name it Authorization, 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.