# Webhooks

Churn Solution sends real-time notifications when key events occur in your account. This guide covers how events are generated, the structure of each payload, and how to verify that every request came from Churn Solution.

## Overview

When a relevant event occurs — such as a customer completing a cancellation flow — Churn Solution sends an HTTP `POST` request to your registered endpoint. The body is JSON, and a signature in the request headers lets you confirm the sender.

| Property | Value |
|---|---|
| **HTTP method** | `POST` |
| **Content-Type** | `application/json` |
| **Signature header** | `Churnsolution-Signature` |
| **Field naming** | `snake_case` keys |
| **Timestamps** | UTC Unix seconds (not milliseconds) |

Each registered endpoint receives its own **signing secret**. Keep it private — it is what proves a payload is authentic.

## Endpoint requirements

1. **Serve over HTTPS**

   At a stable, public URL.

2. **Accept a JSON POST body**

   The request is sent as `application/json`.

3. **Return a 2xx status**

   Once the event is stored. Any `4xx` or `5xx` counts as a failed delivery.

4. **Respond quickly**

   Acknowledge receipt first and process heavy work asynchronously.

> **Warning:**
> Always verify the signature before trusting any data in the payload.

## Request format

Every webhook uses the same top-level envelope: an event `id`, your `app_id`, a `data` object whose `object` field names the payload type, `created_at` and `attempt_number`.

```json
{
  "id": 90217,
  "app_id": "your-app-identifier",
  "data": {
    "object": "session",
    "session": { /* see The Session object below */ }
  },
  "created_at": 1718971200,
  "attempt_number": 1
}
```

| Field | Type | Description |
|---|---|---|
| `id` | integer | Unique event ID. Use it for de-duplication. |
| `app_id` | string | Your app identifier on the billing gateway. |
| `data` | object | The event payload, with an `object` discriminator. |
| `data.object` | string | Object type, e.g. `"session"`. |
| `created_at` | integer | UTC Unix seconds. Used in the signature. |
| `attempt_number` | integer | Delivery attempt count, starting at 1. |

### Request headers

| Header | Value |
|---|---|
| `Churnsolution-Signature` | Hex-encoded HMAC-SHA256 signature |
| `Content-Type` | `application/json` |

## Event types

Churn Solution currently sends one event type.

### `session` — Cancellation-flow session

Sent when a customer completes a cancellation-flow session. `data.object` is `"session"`, and `data.session` holds the [Session object](#session) described below. This is the primary business event.

## The Session object

The `data.session` object describes a customer's path through a cancellation flow and how it ended: the final status, whether the customer was saved, and nested customer, subscription, plan, flow, coupon and step details.

```json
{
  "id": 12345,
  "status": "CANCELLED",
  "is_saved": false,
  "created_at": 1718971200,
  "customer": {
    "id": "cus_abc123",
    "name": "Jane Doe",
    "email": "jane@example.com",
    "created_at": 1700000000
  },
  "subscription": {
    "id": "sub_abc123",
    "status": "ACTIVE",
    "start_date": 1700000000,
    "current_period_start": 1717200000,
    "current_period_end": 1719792000,
    "trial_start": null,
    "trial_end": null
  },
  "plan": {
    "id": "price_abc123",
    "recurring": { "interval": "month", "interval_count": 1 },
    "currency": "usd",
    "unit_amount": 2000,
    "unit_amount_decimal": 20.0,
    "quantity": 1,
    "plan_summary": "$20.00 / month"
  },
  "cancellation_flow": {
    "id": 55,
    "name": "Default Cancel Flow",
    "created_at": 1700000000,
    "updated_at": 1716000000
  },
  "uncancel_coupon": null,
  "steps": [ /* see Session steps below */ ]
}
```

| Field | Type | Description |
|---|---|---|
| `id` | integer | Session identifier. |
| `status` | enum string | Final session status. See [`status` values](#status-values). |
| `is_saved` | boolean | `true` if the customer was retained, `false` if they churned. |
| `created_at` | integer | UTC Unix seconds. |
| `customer` | object | See [Customer](#customer). |
| `subscription` | object | See [Subscription](#subscription). |
| `plan` | object | See [Plan](#plan). |
| `cancellation_flow` | object | See [Cancellation flow](#flow). |
| `uncancel_coupon` | object | See [Coupon](#coupon). `null` when not applicable. |
| `steps` | array | See [Session steps](#steps). |

### `status` values

`ABANDONED` `SEND_TO_PAGE` `CONTACT` `TRIAL_EXTENSION` `PAUSE` `SWITCH_PLAN` `COUPON` `CANCELLED` `PENDING_CANCEL` `UNCANCELLED` `UNPAUSED` `REACTIVATED` `SWITCH_PLAN_WITH_COUPON`

## Customer

| Field | Type | Description |
|---|---|---|
| `id` | string | Gateway customer identifier. |
| `name` | string | Customer name. |
| `email` | string | Customer email. |
| `created_at` | integer | UTC Unix seconds. |

## Subscription

| Field | Type | Description |
|---|---|---|
| `id` | string | Gateway subscription identifier. |
| `status` | enum string | Subscription status. See [values below](#subscription-status-values). |
| `start_date` | integer | Subscription start. |
| `current_period_start` | integer | Current billing period start. |
| `current_period_end` | integer | Current billing period end. |
| `trial_start` | integer | `null` if no trial. |
| `trial_end` | integer | `null` if no trial. |

### Subscription `status` values

`ACTIVE` `TRIALING` `PAST_DUE` `PAUSED` `INCOMPLETE` `INCOMPLETE_EXPIRED` `CANCELED` `UNPAID` `EXPIRED` `PENDING` `HALTED` `ON_HOLD`

## Plan

| Field | Type | Description |
|---|---|---|
| `id` | string | Plan / price identifier. |
| `recurring` | object | Billing cadence, e.g. `{ "interval": "month", "interval_count": 1 }`. |
| `recurring.interval` | string | `day`, `week`, `month` or `year`. |
| `recurring.interval_count` | integer | Number of intervals between billings. |
| `currency` | string | ISO code, e.g. `usd`. |
| `unit_amount` | integer | Amount in the smallest currency unit. |
| `unit_amount_decimal` | number | Amount in major units. |
| `quantity` | integer | Plan quantity. |
| `plan_summary` | string | Human-readable summary. |

## Cancellation flow

| Field | Type | Description |
|---|---|---|
| `id` | integer | Flow identifier. |
| `name` | string | Flow name the customer went through, such as an A/B variant or audience. |
| `created_at` | integer | UTC Unix seconds. |
| `updated_at` | integer | UTC Unix seconds. |

## Session steps

`session.steps` is an ordered array. Each step has a `type`; fields that do not apply to a step's type are `null`.

```json
{
  "id": 9001,
  "type": "SURVEY",
  "order": 1,
  "contains_offer": true,
  "survey_answer": "Too expensive",
  "followup_question": "What price would work for you?",
  "followup_question_answer": "Around $10",
  "form_answer": null,
  "offer": {
    "accepted": true,
    "coupon": {
      "name": "Win-back 50%",
      "code": "STAY50",
      "currency": "usd",
      "amount_off": null,
      "amount_off_decimal": null,
      "percent_off": 50,
      "duration": "repeating",
      "duration_in_months": 3,
      "number_of_billing_cycles": 3,
      "currency_symbol": "$",
      "summary": "50% for 3 billing cycles"
    },
    "pause": null,
    "trial_extension": null
  }
}
```

| Field | Type | Description |
|---|---|---|
| `id` | integer | Step identifier. |
| `type` | enum | `FORM`, `SURVEY` or `OFFER`. |
| `order` | integer | 1-based position in the flow. |
| `contains_offer` | boolean | Whether an offer was shown in this step. |
| `survey_answer` | string | `SURVEY` only. The selected option. |
| `followup_question` | string | `SURVEY` only. The follow-up question, if any. |
| `followup_question_answer` | string | `SURVEY` only. Free-text answer. |
| `form_answer` | string | `FORM` only. Free-text answer. |
| `offer` | object | Present when `contains_offer` is `true`. See [Offer](#offer). |

## Offer

Exactly one of `coupon`, `pause` or `trial_extension` is populated.

| Field | Type | Description |
|---|---|---|
| `accepted` | boolean | Whether the customer accepted the offer. |
| `coupon` | object / null | See [Coupon](#coupon). |
| `pause` | object | e.g. `{ "number_of_months": 2 }` |
| `trial_extension` | object | e.g. `{ "number_of_days": 14 }` |

## Coupon

| Field | Type | Description |
|---|---|---|
| `name` | string | Coupon name. |
| `code` | string | Coupon code. |
| `currency` | string | ISO code, for fixed-amount coupons. |
| `amount_off` | integer | Smallest currency unit. `null` for percentage coupons. |
| `amount_off_decimal` | number | Major units. `null` for percentage coupons. |
| `percent_off` | integer | Percentage discount. `null` for fixed-amount coupons. |
| `duration` | string | `once`, `repeating` or `forever`. |
| `duration_in_months` | integer | Months, when `duration` is `repeating`. |
| `number_of_billing_cycles` | integer | Billing cycles, if defined. |
| `currency_symbol` | string | Display symbol, e.g. `$`. |
| `summary` | string | Human-readable discount summary. |

## Security

### Verifying signatures

Each request carries a hex-encoded HMAC-SHA256 signature in the `Churnsolution-Signature` header, keyed with your endpoint's signing secret. Verify it with a constant-time comparison and reject mismatches with a `401`.

### How the signature is computed

```txt
signed_payload = "<created_at>;<raw_request_body>"
signature      = lowercase_hex( HMAC_SHA256( signing_secret, signed_payload ) )
```

> **Warning:**
> Use the exact bytes you received as the raw body. Do not re-serialize the parsed JSON — key order or whitespace changes will break the signature.

### Verification steps

1. **Read the raw request body**

   Before any parsing.

2. **Extract `created_at`**

   From the body.

3. **Compute the HMAC-SHA256**

   Over `created_at`, a semicolon, and the raw body, then hex-encode the result.

4. **Compare**

   Against the `Churnsolution-Signature` header using a constant-time comparison.

5. **Reject mismatches**

   Respond with `401`.

**Node.js (Express)**

```js
const crypto = require('crypto');
const express = require('express');
const app = express();

const SIGNING_SECRET = process.env.CHURNSOLUTION_SIGNING_SECRET;

// Capture the raw body — required for signature verification.
app.post(
  '/webhooks/churnsolution',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body; // Buffer
    const signature = req.get('Churnsolution-Signature');

    const createdAt = JSON.parse(rawBody.toString()).created_at;
    const signedPayload = `${createdAt};${rawBody.toString()}`;

    const expected = crypto
      .createHmac('sha256', SIGNING_SECRET)
      .update(signedPayload)
      .digest('hex');

    const valid =
      signature &&
      expected.length === signature.length &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));

    if (!valid) return res.status(401).send('Invalid signature');

    const event = JSON.parse(rawBody.toString());
    res.status(200).send('ok');   // acknowledge immediately
    handleEvent(event);           // process asynchronously
  }
);
```
**Python (Flask)**

```python
import hmac, hashlib, json, os
from flask import Flask, request, abort

app = Flask(__name__)
SIGNING_SECRET = os.environ["CHURNSOLUTION_SIGNING_SECRET"]

@app.post("/webhooks/churnsolution")
def churnsolution_webhook():
    raw_body = request.get_data()  # raw bytes
    signature = request.headers.get("Churnsolution-Signature", "")

    created_at = json.loads(raw_body)["created_at"]
    signed_payload = f"{created_at};{raw_body.decode()}".encode()

    expected = hmac.new(
        SIGNING_SECRET.encode(), signed_payload, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        abort(401)

    event = json.loads(raw_body)
    return "ok", 200
```

## Delivery & retries

- Any `2xx` or `3xx` response counts as a successful delivery. `4xx` / `5xx` responses, connection failures and timeouts count as failures.
- `attempt_number` tells you which delivery attempt this is.
- The same event may be delivered more than once. De-duplicate using the top-level `id`.
- Events may arrive out of order. Use `created_at` to sort them if ordering matters.

## Best practices

- Verify the signature before acting on any payload.
- Respond quickly; process asynchronously.
- Ignore unknown fields — new ones may be added over time.
- Treat optional fields as possibly `null` or absent.
- Store the signing secret in a secrets manager or environment variable, never in client-side code.
- Use HTTPS.

## Quick reference

| Property | Value |
|---|---|
| **HTTP method** | `POST` |
| **Body format** | JSON, `snake_case` keys |
| **Signature header** | `Churnsolution-Signature` |
| **Algorithm** | HMAC-SHA256, lowercase hex |
| **Signed payload** | `<created_at>;<raw_body>` |
| **Signing key** | Per-endpoint signing secret |
| **Expected response** | `2xx` |
| **Timestamps** | UTC Unix seconds |
| **Idempotency key** | Top-level `id` |
