> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cuprice.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Cancel-flow offers

> Offer a discount, a cheaper plan or a pause when a customer cancels in the billing widget, and apply the one they pick in your own payment service

When a customer clicks **Cancel** in the [billing widget](https://docs.cuprice.io/embed/billing-widget.md), Cuprice can show them an offer first: a discount, a move down one plan, or a pause. You choose the offers in the project's **Integration → Cancel flow** settings.

Cuprice never touches your payment service. When the customer accepts an offer, Cuprice sends your server a signed webhook. Your server applies the offer in Stripe, Paddle or whatever you use, and tells Cuprice it did.

Offers appear only when:

* the widget has a **v2 subscriber token** for this customer (below), and
* the project has a **webhook URL** in **Integration → Webhooks**.

Without either, Cancel goes straight to your `data-cuprice-manage-url`, as before.

> [!NOTE]
>
> Each customer gets offers at most once per cooldown (6 months by default). Customers on yearly billing are only offered the cheaper plan. Nothing is offered while an accepted discount or pause is still running.

## 1. Pass a v2 subscriber token

The v2 token tells Cuprice who the customer is, which plan they are on and how they pay, signed with your project's SDK secret so it can't be edited in the browser. Mint it on your server and put it in the widget container.

```html theme={null}
<div
  data-cuprice-billing="YOUR_SHARE_ID"
  data-cuprice-subscriber-token="v2.eyJzdWIi..."
  data-cuprice-manage-url="https://yourapp.com/billing"
></div>
<script src="https://app.cuprice.io/embed.js" async></script>
```

| Field | Required | Meaning |
| - | - | - |
| `sub` | yes | Your id for the customer (64 characters at most) |
| `plan` | yes | The plan code, the same one Cuprice sends you at checkout (the plan's ID in Cuprice, or a slug of its name) |
| `interval` | yes | `"month"` or `"year"` |
| `amount` | no | What the customer actually pays per interval, in the project currency. With it, the discount offer shows real prices; without it, only the percentage |
| `exp` | yes | Unix seconds, at most 24 hours ahead |

Pass a v2 token only for a subscription that is active and not already set to cancel. You know that; Cuprice doesn't.

```javascript Node.js theme={null}
import { createHmac } from "node:crypto";

// sdkSecret: GET /api/v1/projects/:slug/sdk (starts with csk_). Keep it on your server.
export function cupriceSubscriberToken(sdkSecret, { sub, plan, interval, amount }) {
  const claims = { sub, plan, interval, ...(amount != null && { amount }), exp: Math.floor(Date.now() / 1000) + 3600 };
  const payload = Buffer.from(JSON.stringify(claims)).toString("base64url");
  const signature = createHmac("sha256", sdkSecret).update(`v2.${payload}`).digest("base64url");
  return `v2.${payload}.${signature}`;
}
```

To try it without writing code, `POST /api/v1/projects/:slug/sdk` with `{ "subscriberId", "plan", "interval", "amount" }` returns a `subscriberTokenV2` valid for 24 hours.

## 2. Add your webhook URL

In the project sidebar, open **Integration → Webhooks** and save an `https://` URL. Cuprice creates a signing secret (`whsec_…`) on the first save. Copy it to your server. **Send test event** sends a `retention.test` event and shows the result.

Addresses on private or local networks are refused.

## 3. Handle the webhook

Cuprice sends a `POST` with a JSON body:

```json theme={null}
{
  "id": "evt_9f2c41d07a3b5e8c61f0a2d4",
  "type": "retention.offer_accepted",
  "created": 1794500000,
  "data": {
    "offer_id": "ro_4812",
    "subscriber_id": "cus_partner_77",
    "current_plan": { "code": "scale", "interval": "month" },
    "kind": "discount",
    "discount": { "percent_off": 30, "months": 3 },
    "downgrade": null,
    "pause": null
  }
}
```

Exactly one of `discount`, `downgrade` and `pause` is set, matching `kind`:

| `kind` | Object |
| - | - |
| `discount` | `{ "percent_off": 30, "months": 3 }` |
| `downgrade` | `{ "code": "growth", "name": "Growth", "interval": "month", "price": 39, "currency": "USD" }` |
| `pause` | `{ "months": 2, "resumes_at": "2027-01-07T00:00:00Z" }` |

A test event has the same envelope, with `"type": "retention.test"` and `"data": { "test": true }`.

Headers:

| Header | Value |
| - | - |
| `Cuprice-Event-Id` | The event's `id` |
| `Cuprice-Event-Type` | `retention.offer_accepted` or `retention.test` |
| `Cuprice-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256 of t + "." + raw body>` |

### Verify the signature

Check the signature against the raw request body, before parsing it, and reject anything more than 5 minutes old.

```javascript Node.js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyCupriceSignature(webhookSecret, header, rawBody, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = createHmac("sha256", webhookSecret).update(`${t}.${rawBody}`).digest();
  const provided = Buffer.from(parts.v1 ?? "", "hex");
  return provided.length === expected.length && timingSafeEqual(provided, expected);
}
```

### Reply

Cuprice waits up to 5 seconds for your reply, and the customer sees the result right away:

| Your reply | The customer sees | Retries |
| - | - | - |
| 2xx with `{"status":"applied"}` | The offer is on | None |
| 2xx with `{"status":"rejected","reason":"…"}` | "We couldn't apply this offer" | None |
| Any other 2xx (for example 202 with an empty body) | "Request received" | None. Confirm later through the API |
| Non-2xx, a redirect, a timeout or a network error | "Request received" | Yes |

If applying the offer takes longer than 5 seconds, answer `202` right away and confirm through the API when you're done.

## Retries and idempotency

Every attempt for an event carries the same `id`. Store the ids you have applied and ignore repeats.

A notice that wasn't received is retried about 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours later: 6 attempts in all. If every attempt fails, the project owner gets one email, and **Integration → Webhooks** lists the delivery with a **Resend** button that sends the same event again.

If the project's last 3 accepted offers all failed to deliver, the widget stops showing offers until a delivery or a test event succeeds.

## Confirm later, or catch up

Confirm an offer you answered with "received", or correct an earlier answer. The latest answer counts. Use an API key with write access.

```bash theme={null}
curl -X POST https://app.cuprice.io/api/v1/projects/YOUR_SLUG/retention/offers/ro_4812 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status":"applied"}'
```

`status` is `applied` or `rejected`, with an optional `note` of up to 500 characters. Sending the same answer again changes nothing (`"changed": false`).

List the offers still waiting for your answer, with the same fields as the webhook's `data`:

```bash theme={null}
curl "https://app.cuprice.io/api/v1/projects/YOUR_SLUG/retention/offers?partner_status=pending" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

`partner_status` can also be `applied` or `rejected`.

> [!NOTE]
>
> The JavaScript SDK's older cancel flow (`/api/rescue/trigger`, `/api/rescue/outcome` and `/api/rescue/pause`) still works, but it only records what the customer picked. Use the billing widget with a v2 token for offers that apply.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.