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

# Webhooks

> Receive real-time event notifications from Kelviq to your server.

Kelviq can push event notifications to your server whenever something meaningful happens — a subscription is created, an invoice is paid, or a refund is issued. This guide explains how to register an endpoint, verify incoming requests, and handle events.

***

## How It Works

When an event occurs, Kelviq creates a `WebhookEvent` and fans it out to all enabled endpoints you have registered for that event type. Each delivery is a single `POST` request with a JSON body and signed headers.

Kelviq retries failed deliveries up to **3 times** with a 60-second delay between attempts. A delivery is considered successful when your endpoint returns a `2xx` status code.

***

## Registering an Endpoint

Go to **Settings → Webhooks** in the [Kelviq dashboard](https://app.kelviq.com/settings/webhooks/new) to create a webhook endpoint. You will specify:

* **URL** — The public HTTPS URL Kelviq will POST events to.
* **Events** — The subset of event types you want to subscribe to.

After creation, Kelviq generates a signing secret for the endpoint in the format `kq_whsec_<random>`. Store this secret securely — you will need it to verify signatures.

<Warning>
  Your signing secret is shown only once. If you lose it, you must regenerate it from the dashboard.
</Warning>

***

## Inspect and resend deliveries

Open **Settings → Webhooks** and select an endpoint to view its delivery logs. Each delivery shows the event type, status, response code, and response body.

To retry a delivery:

1. Fix the endpoint or deployment issue that caused the delivery to fail.
2. Open the failed delivery in the webhook logs.
3. Click **Resend**.
4. Review the new delivery attempt and its response.

Kelviq also retries failed deliveries automatically. A manual resend is useful when the endpoint is working again and you want to retry immediately.

<Warning>
  Automatic retries and manual resends can deliver the same event more than once. Process webhooks idempotently using the event `id` or the `webhook-id` header.
</Warning>

***

## Event Types

| Event                       | Description                                                                                                                                                     |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `checkout.completed`        | A customer successfully completed a checkout session and payment was collected.                                                                                 |
| `customer.created`          | A new customer record was created in your organization.                                                                                                         |
| `customer.updated`          | A customer's profile data (name, email, metadata) was updated.                                                                                                  |
| `order.created`             | A new order was placed.                                                                                                                                         |
| `order.updated`             | An existing order was updated.                                                                                                                                  |
| `order.refunded`            | A full or partial refund was issued against an order.                                                                                                           |
| `subscription.created`      | A new subscription was activated for a customer.                                                                                                                |
| `subscription.updated`      | A subscription's details were modified — including when a cancellation is scheduled for the end of the current period (status stays active; `end_date` is set). |
| `subscription.cancelled`    | A subscription actually reached cancelled status. For a period-end cancellation, this fires later, when the period ends — not at the moment it was scheduled.   |
| `subscription.plan_changed` | A subscription was moved to a different plan.                                                                                                                   |
| `feature.usage_alert`       | A customer's feature usage crossed a configured threshold.                                                                                                      |
| `invoice.created`           | A new invoice was generated.                                                                                                                                    |
| `invoice.paid`              | An invoice was paid and funds were collected.                                                                                                                   |
| `invoice.payment_failed`    | A subscription invoice payment failed or requires customer action.                                                                                              |
| `refund.created`            | A refund was initiated.                                                                                                                                         |
| `refund.updated`            | A refund's status changed (e.g., from pending to completed).                                                                                                    |
| `payment_method.created`    | A new payment method was added for a customer.                                                                                                                  |
| `payment_method.updated`    | A payment method's status or default state changed.                                                                                                             |
| `payment_method.deleted`    | A payment method was removed from a customer.                                                                                                                   |

***

## Checkout, subscription, and payment lifecycle

### Checkout and subscription creation

`checkout.completed` confirms that checkout finished successfully, but it does not include `subscription_id`. Kelviq creates the subscription after checkout and sends its ID in `subscription.created`.

Use your customer identifier or checkout `metadata` to correlate the two events. See [Add metadata to checkout](/checkout/checkout-metadata) for request and webhook examples. Do not treat the checkout redirect as proof that a subscription has been created.

### Successful invoice payments

Kelviq sends `invoice.paid` whenever an invoice is paid, including invoices that are created directly with a `PAID` status. Use this event for payment-driven work such as recording revenue, starting an asynchronous provisioning job, or notifying an internal system.

For request-time access checks, query the customer's current entitlements instead of relying on a local webhook-derived subscription state.

### Failed invoice payments

Kelviq sends `invoice.payment_failed` when a subscription invoice payment fails or requires customer action. Use `data.object.subscription_id` and the embedded `customer` to identify the affected account. The invoice `status` is `PAYMENT_FAILED` for a failed attempt and can remain `OPEN` when the payment requires customer action.

This event can be followed by `invoice.paid` if the customer completes authentication or a later retry succeeds. Handle both events idempotently and treat the latest invoice and subscription state as authoritative.

### Subscription end dates

Subscription webhook objects include `end_date` inside `data.object`. Kelviq sets the field when a subscription has been cancelled or is scheduled to cancel on a future date. Otherwise, the value is `null`.

Use `end_date` for the subscription's scheduled or final end. `billing_period_end_time` describes the current billing period instead and should not be treated as the subscription's cancellation date.

### Failed renewal payments

Kelviq does not currently send a separate `payment.failed` event. For a failed card renewal:

1. The subscription moves to `past_due`.
2. Kelviq emails the customer a payment link.
3. Kelviq sends `subscription.updated` with the new subscription state.
4. Kelviq retries collection up to eight times over 14 days.
5. If all recovery attempts fail, Kelviq cancels the subscription and sends `subscription.cancelled`.

Do not remove access when you receive the first `subscription.updated` with `past_due` unless that is your own policy. Use the final subscription status or a live entitlement check to decide when access should end.

<Note>
  The retry window applies only to card payments. Kelviq does not retry a failed non-card renewal; the subscription is cancelled after the failure.
</Note>

### Billing period field names

Subscription payloads use `billing_period_start_time` and `billing_period_end_time`. Match these names exactly when parsing dates. They describe the current billing period; use `end_date` for the subscription's scheduled or final end.

### Plan changes

By default, Kelviq applies a plan change immediately, charges any prorated difference, and sends `subscription.plan_changed`. After the generated invoice is paid, Kelviq sends the related order and invoice events.

If you update a subscription with `paymentBehavior: "activate_on_payment"`, the existing subscription remains `active` while payment is pending. A payment method that requires customer action leaves the updated subscription in `incomplete`. After payment succeeds, the updated subscription becomes `active` and the previous subscription becomes `superseded`. If an immediate charge fails, or the customer does not complete payment before expiry, the updated subscription becomes `incomplete_expired` and the existing subscription remains active.

Do not provision the new plan from the update API response alone. Read the latest subscription status or query the customer's current entitlements before changing access.

### Partial and full refunds

`order.refunded` identifies whether the order is partially or fully refunded through its `status`:

* `PARTIAL_REFUND` for a partial refund
* `REFUNDED` for a full refund

The `order.refunded` payload does not include the refunded amount. Read `amount` or `amount_units` from `refund.created` and `refund.updated` when you need the exact value.

***

## Request Headers

Every webhook request from Kelviq includes the following headers:

| Header              | Description                                                     |
| ------------------- | --------------------------------------------------------------- |
| `webhook-id`        | A unique UUID identifying this specific webhook event delivery. |
| `webhook-timestamp` | Unix timestamp (seconds) of when the event was dispatched.      |
| `webhook-signature` | HMAC-SHA256 signature. Format: `v1,{signature}`                 |
| `Content-Type`      | Always `application/json`.                                      |

***

## Payload Structure

The request body is a JSON object with the following top-level fields:

```json theme={null}
{
  "id": "3a7f1c2d-8e4b-4a9f-b123-0e5d6c7a8b9e",
  "type": "subscription.created",
  "created_at": "2024-06-01T12:00:00Z",
  "data": {
    "object": { ... },
    "previous_attributes": { ... }
  }
}
```

| Field                      | Type                | Description                                                                                           |
| -------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------- |
| `id`                       | `string` (UUID)     | The unique ID of this event. Matches the `webhook-id` header.                                         |
| `type`                     | `string`            | The event type (e.g., `subscription.created`).                                                        |
| `created_at`               | `string` (ISO 8601) | UTC timestamp of when the event was created.                                                          |
| `data.object`              | `object`            | The primary resource affected by the event.                                                           |
| `data.previous_attributes` | `object`            | *(Update events only)* The resource's changed attributes before the event. Omitted if not applicable. |

<Accordion title="Example: checkout.completed payload">
  ```json theme={null}
  {
    "id": "1619afec-caab-40c7-827c-6af4f3acc9a3",
    "type": "checkout.completed",
    "created_at": "2026-04-01T17:28:39Z",
    "data": {
      "object": {
        "id": "cs_jBy6ViggULoyDMW9ikaw4ydur1NUa6dCORT6vqKPbPPW8",
        "object": "checkout_session",
        "status": "success",
        "checkout_type": "ONE_TIME",
        "customer_id": "936cf361-3a4b-454c-a464-41ccc7600c2d",
        "customer": {
          "id": "936cf361-3a4b-454c-a464-41ccc7600c2d",
          "name": "Geo Jacob",
          "email": "geojacob@example.com",
          "customer_id": "geo-jacob",
          "billing_address": {
            "line1": null,
            "line2": null,
            "city": null,
            "state": null,
            "postal_code": "680121",
            "country": "IN"
          }
        },
        "organization": "9b759915-5387-4e61-90e9-2162318e8d17",
        "amount": "10.00",
        "amount_units": 1000,
        "currency": "USD",
        "discount_code": "",
        "features": null,
        "plan": {
          "name": "Exc Tax Plan",
          "version": 1,
          "identifier": "exc-tax-plan"
        },
        "product": {
          "id": "df398021-477b-4e67-972d-c763a2f2ba7b",
          "name": "ParityDeals"
        },
        "billing_address": {
          "line1": null,
          "line2": null,
          "city": null,
          "state": null,
          "postal_code": "680121",
          "country": "IN"
        },
        "metadata": {
          "order_ref": "ABC-123",
          "source": "pricing_page"
        },
        "data": {
          "name": "Geo",
          "email": "geojacob@example.com",
          "referer": null,
          "cancel_url": null,
          "success_url": null,
          "tax_behavior": "EXCLUSIVE",
          "discounts_enabled": true
        },
        "created_on": "2026-04-01T17:27:59Z",
        "modified_on": "2026-04-01T17:28:39Z"
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: customer.created payload">
  ```json theme={null}
  {
    "id": "0662641a-b415-46eb-a78a-c136ce12fa60",
    "type": "customer.created",
    "created_at": "2026-04-01T07:05:56Z",
    "data": {
      "object": {
        "id": "7dd79eff-180f-4b07-a213-ebafd29e9725",
        "object": "customer",
        "customer_id": "geo-jacob",
        "name": "Geo Jacob",
        "email": "geo@example.com",
        "details": {},
        "metadata": {},
        "billing_address": {},
        "subscriptions": 0,
        "organization": "9b759915-5387-4e61-90e9-2162318e8d17",
        "created_on": "2026-04-01T07:05:56Z",
        "modified_on": "2026-04-01T07:05:56Z"
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: customer.updated payload">
  ```json theme={null}
  {
    "id": "221a4d03-1cf4-47ae-9ed8-9f8f79ae989d",
    "type": "customer.updated",
    "created_at": "2026-04-01T07:32:42Z",
    "data": {
      "object": {
        "id": "7dd79eff-180f-4b07-a213-ebafd29e9725",
        "object": "customer",
        "customer_id": "geo-jacob",
        "name": "Geo Jacob v2",
        "email": "geo@example.com",
        "details": {},
        "metadata": {},
        "billing_address": {},
        "subscriptions": 0,
        "organization": "9b759915-5387-4e61-90e9-2162318e8d17",
        "created_on": "2026-04-01T07:05:56Z",
        "modified_on": "2026-04-01T07:32:42Z"
      },
      "previous_attributes": {
        "name": "Geo Jacob"
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: order.created payload">
  ```json theme={null}
  {
    "id": "evt_01HXYZ1234567890ABCDEF",
    "type": "order.created",
    "created_at": "2026-04-01T17:36:41Z",
    "data": {
      "object": {
        "id": "ORD-20260401173639-H8IO7",
        "object": "order",
        "status": "COMPLETE",
        "billing_type": "ONE_TIME",
        "customer_id": "936cf361-3a4b-454c-a464-41ccc7600c2d",
        "customer": {
          "id": "936cf361-3a4b-454c-a464-41ccc7600c2d",
          "name": "Geo Jacob",
          "email": "geojacob@example.com",
          "customer_id": "geo-jacob",
          "billing_address": {}
        },
        "organization": "9b759915-5387-4e61-90e9-2162318e8d17",
        "is_renewal": false,
        "paid_at": "2026-04-01T17:36:41Z",
        "amount_total": "10.00",
        "amount_total_units": 1000,
        "currency": "USD",
        "subscription_id": "2c78e8f0-892e-406c-9896-18c69d3026c4",
        "product": {
          "id": "df398021-477b-4e67-972d-c763a2f2ba7b",
          "name": "ParityDeals"
        },
        "plan": {
          "name": "Exc Tax Plan",
          "version": 1,
          "identifier": "exc-tax-plan"
        },
        "features": null,
        "created_on": "2026-04-01T17:36:41Z",
        "modified_on": "2026-04-01T17:36:41Z"
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: order.updated payload">
  ```json theme={null}
  {
    "id": "evt_01HXYZ2345678901BCDEFG",
    "type": "order.updated",
    "created_at": "2026-04-01T17:36:41Z",
    "data": {
      "object": {
        "id": "ORD-20260401173639-H8IO7",
        "object": "order",
        "status": "COMPLETE",
        "billing_type": "ONE_TIME",
        "customer_id": "936cf361-3a4b-454c-a464-41ccc7600c2d",
        "customer": {
          "id": "936cf361-3a4b-454c-a464-41ccc7600c2d",
          "name": "Geo Jacob",
          "email": "geojacob@example.com",
          "customer_id": "geo-jacob",
          "billing_address": {
            "line1": null,
            "line2": null,
            "city": null,
            "state": null,
            "postal_code": "680121",
            "country": "IN"
          }
        },
        "organization": "9b759915-5387-4e61-90e9-2162318e8d17",
        "is_renewal": false,
        "paid_at": "2026-04-01T17:36:41Z",
        "amount_total": "10.00",
        "amount_total_units": 1000,
        "currency": "USD",
        "subscription_id": "2c78e8f0-892e-406c-9896-18c69d3026c4",
        "product": {
          "id": "df398021-477b-4e67-972d-c763a2f2ba7b",
          "name": "ParityDeals"
        },
        "plan": {
          "name": "Exc Tax Plan",
          "version": 1,
          "identifier": "exc-tax-plan"
        },
        "features": null,
        "created_on": "2026-04-01T17:36:41Z",
        "modified_on": "2026-04-01T17:36:41Z"
      },
      "previous_attributes": {
        "status": "PENDING"
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: order.refunded payload">
  ```json theme={null}
  {
    "id": "c1fb8514-fec6-44fa-9196-7dabe2f153ac",
    "type": "order.refunded",
    "created_at": "2026-04-02T03:48:32Z",
    "data": {
      "object": {
        "id": "ORD-20260401173639-H8IO7",
        "object": "order",
        "status": "REFUNDED",
        "billing_type": "ONE_TIME",
        "customer_id": "936cf361-3a4b-454c-a464-41ccc7600c2d",
        "customer": {
          "id": "936cf361-3a4b-454c-a464-41ccc7600c2d",
          "name": "Geo Jacob",
          "email": "geojacob@example.com",
          "customer_id": "geo-jacob",
          "billing_address": {
            "line1": null,
            "line2": null,
            "city": null,
            "state": null,
            "postal_code": "680121",
            "country": "IN"
          }
        },
        "organization": "9b759915-5387-4e61-90e9-2162318e8d17",
        "is_renewal": false,
        "paid_at": "2026-04-01T17:36:41Z",
        "amount_total": "10.00",
        "amount_total_units": 1000,
        "currency": "USD",
        "subscription_id": "2c78e8f0-892e-406c-9896-18c69d3026c4",
        "product": {
          "id": "df398021-477b-4e67-972d-c763a2f2ba7b",
          "name": "ParityDeals"
        },
        "plan": {
          "name": "Exc Tax Plan",
          "version": 1,
          "identifier": "exc-tax-plan"
        },
        "features": null,
        "created_on": "2026-04-01T17:36:41Z",
        "modified_on": "2026-04-02T03:48:32Z"
      },
      "previous_attributes": {
        "status": "COMPLETE"
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: subscription.created payload">
  ```json theme={null}
  {
    "id": "3a7f1c2d-8e4b-4a9f-b123-0e5d6c7a8b9e",
    "type": "subscription.created",
    "created_at": "2026-03-11T05:25:34Z",
    "data": {
      "object": {
        "id": "a22f1b2a-b922-4a28-9b0e-75e826988b4b",
        "object": "subscription",
        "status": "active",
        "plan": {
          "name": "default pd",
          "version": 1,
          "identifier": "default-pd"
        },
        "product": {
          "id": "b4ea7e75-5dbe-4b3b-96e0-9a31dfe5a600",
          "name": "Migrate PD"
        },
        "amount": "21.24",
        "amount_units": 2100,
        "currency": "USD",
        "recurrence": "1 month",
        "start_date": "2026-03-11",
        "end_date": null,
        "billing_period_start_time": "2026-03-11T05:25:33Z",
        "billing_period_end_time": "2026-04-10T06:28:58Z",
        "trial_start": "2026-03-11T05:25:33Z",
        "trial_end": "2026-03-25T05:25:33Z",
        "customer": {
          "id": "1bb69d0f-39f8-4db8-aace-e003d6e5f3ed",
          "name": "Geo Jacob",
          "email": "geo@example.com",
          "customer_id": "1",
          "billing_address": {
            "city": "Bangalore",
            "line1": null,
            "line2": null,
            "state": "Karnataka",
            "country": "IN",
            "postal_code": "560001"
          }
        },
        "features": null,
        "organization": "2a3664c0-cbd2-4f1c-a98f-1b4f65e3256c",
        "created_on": "2026-03-11T05:25:34Z",
        "modified_on": "2026-03-11T05:25:34Z"
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: subscription.updated payload">
  ```json theme={null}
  {
    "id": "be4b0fa8-2e82-4664-b4f8-c3ab1622086c",
    "type": "subscription.updated",
    "created_at": "2026-04-02T05:21:33Z",
    "data": {
      "object": {
        "id": "ff258fd6-9a31-4eda-b3c8-000a24530890",
        "object": "subscription",
        "status": "active",
        "amount": "40.00",
        "amount_units": 4000,
        "currency": "USD",
        "recurrence": "1 month",
        "start_date": "2026-04-02",
        "end_date": null,
        "organization": "9b759915-5387-4e61-90e9-2162318e8d17",
        "features": null,
        "plan": {
          "name": "Inc tax sub",
          "version": 1,
          "identifier": "inc-tax-sub"
        },
        "product": {
          "id": "df398021-477b-4e67-972d-c763a2f2ba7b",
          "name": "ParityDeals"
        },
        "customer": {
          "id": "936cf361-3a4b-454c-a464-41ccc7600c2d",
          "name": "Geo Jacob",
          "email": "geojacob@example.com",
          "customer_id": "bc43b1a034a643309ee39d2f1a4d2131",
          "billing_address": {
            "line1": null,
            "line2": null,
            "city": null,
            "state": null,
            "postal_code": "680121",
            "country": "IN"
          }
        },
        "billing_period_start_time": "2026-04-02T05:04:43Z",
        "billing_period_end_time": "2026-05-02T05:04:43Z",
        "trial_start": "2026-04-02T05:04:43Z",
        "trial_end": "2026-04-16T05:04:43Z",
        "created_on": "2026-04-02T05:21:28Z",
        "modified_on": "2026-04-02T05:21:33Z"
      },
      "previous_attributes": {
        "status": null,
        "billing_period_start_time": null
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: subscription.cancelled payload">
  ```json theme={null}
  {
    "id": "cacae554-d67e-4c6c-9d51-9d68f5c9b574",
    "type": "subscription.cancelled",
    "created_at": "2026-04-02T05:02:47Z",
    "data": {
      "object": {
        "id": "0faa77b7-2d42-4b6f-b682-d012987adad2",
        "object": "subscription",
        "status": "canceled",
        "amount": "40.00",
        "amount_units": 4000,
        "currency": "USD",
        "recurrence": "1 month",
        "start_date": "2026-04-02",
        "end_date": "2026-05-02",
        "organization": "9b759915-5387-4e61-90e9-2162318e8d17",
        "features": null,
        "plan": {
          "name": "Inc tax sub",
          "version": 1,
          "identifier": "inc-tax-sub"
        },
        "product": {
          "id": "df398021-477b-4e67-972d-c763a2f2ba7b",
          "name": "ParityDeals"
        },
        "customer": {
          "id": "936cf361-3a4b-454c-a464-41ccc7600c2d",
          "name": "Geo Jacob",
          "email": "geojacob@example.com",
          "customer_id": "bc43b1a034a643309ee39d2f1a4d2131",
          "billing_address": {
            "line1": null,
            "line2": null,
            "city": null,
            "state": null,
            "postal_code": "680121",
            "country": "IN"
          }
        },
        "billing_period_start_time": "2026-04-02T04:25:13Z",
        "billing_period_end_time": "2026-05-02T04:25:13Z",
        "trial_start": "2026-04-02T04:25:13Z",
        "trial_end": "2026-04-16T04:25:13Z",
        "created_on": "2026-04-02T04:25:20Z",
        "modified_on": "2026-04-02T05:02:47Z"
      },
      "previous_attributes": {
        "status": "active"
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: subscription.plan_changed payload">
  ```json theme={null}
  {
    "id": "b9c2e1f0-7d3a-4b8e-a456-1f2g3h4i5j6k",
    "type": "subscription.plan_changed",
    "created_at": "2026-03-04T11:53:12Z",
    "data": {
      "object": {
        "id": "d1284180-beba-465a-b756-9a300324245e",
        "object": "subscription",
        "status": "active",
        "plan": {
          "name": "Pro",
          "version": 4,
          "identifier": "pro"
        },
        "product": {
          "id": "988dba89-0a96-4cb5-870a-34810011c1f1",
          "name": "demo"
        },
        "amount": "29.00",
        "amount_units": 2900,
        "currency": "USD",
        "recurrence": "1 month",
        "start_date": "2026-02-26",
        "end_date": null,
        "billing_period_start_time": "2026-02-26T05:25:11Z",
        "billing_period_end_time": "2026-03-26T05:25:11Z",
        "trial_start": "2026-02-26T05:25:11Z",
        "trial_end": "2026-03-12T05:25:11Z",
        "customer": {
          "id": "798e663f-2fe9-43d7-879f-2549263e20d1",
          "name": "Geo",
          "email": "geo@example.com",
          "customer_id": "geo123",
          "billing_address": {}
        },
        "features": null,
        "organization": "5d0bec80-a7c1-41ae-bd24-ea9228500586",
        "created_on": "2026-03-04T11:53:12Z",
        "modified_on": "2026-03-04T11:53:14Z"
      },
      "previous_attributes": {
        "id": "065ee4e2-b643-4f78-bac0-9bd4afcba963",
        "object": "subscription",
        "status": "active",
        "plan": {
          "name": "Enterprise",
          "version": 1,
          "identifier": "enterprise"
        },
        "product": {
          "id": "988dba89-0a96-4cb5-870a-34810011c1f1",
          "name": "demo"
        },
        "amount": "99.00",
        "amount_units": 9900,
        "currency": "USD",
        "recurrence": "1 month",
        "start_date": "2026-02-26",
        "end_date": null,
        "billing_period_start_time": "2026-02-26T05:25:11Z",
        "billing_period_end_time": "2026-03-26T05:25:11Z",
        "trial_start": "2026-02-26T05:25:11Z",
        "trial_end": "2026-03-12T05:25:11Z",
        "customer": {
          "id": "798e663f-2fe9-43d7-879f-2549263e20d1",
          "name": "Geo",
          "email": "geo@example.com",
          "customer_id": "geo123",
          "billing_address": {}
        },
        "features": null,
        "organization": "5d0bec80-a7c1-41ae-bd24-ea9228500586",
        "created_on": "2026-03-04T11:29:19Z",
        "modified_on": "2026-03-04T11:29:23Z"
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: feature.usage_alert payload">
  ```json theme={null}
  {
    "id": "575a1aa4-3414-4e08-b4ad-6bfd3e96322d",
    "type": "feature.usage_alert",
    "created_at": "2026-05-18T18:48:59Z",
    "data": {
      "object": {
        "id": "a1c88c9d-19cf-48cc-b194-6cc6ebb356ee",
        "object": "feature_usage_alert",
        "feature": {
          "id": "245d745f-28ca-4856-81fe-1e1f5c6c7ebd",
          "name": "API calls",
          "identifier": "api-calls",
          "feature_type": "METER"
        },
        "customer": {
          "id": "d2b6c9ac-bcd5-4e59-8c7e-b6472485e112",
          "name": "Geo",
          "email": "geo@example.com",
          "customer_id": "fcec7a06c7484584b8a4d92711bfe528",
          "billing_address": {
            "city": null,
            "line1": null,
            "line2": null,
            "state": null,
            "country": "IN",
            "postal_code": "680121"
          }
        },
        "usage_limit": 5,
        "current_usage": 5,
        "threshold_type": "PERCENTAGE",
        "threshold_value": 90,
        "period_start_time": "2026-05-18T00:00:00Z",
        "period_end_time": null,
        "subscription_id": "137e4652-5540-4a3b-9bce-fa393b028539",
        "organization": "2a3664c0-cbd2-4f1c-a98f-1b4f65e3256c",
        "created_on": "2026-05-18T17:57:16Z",
        "modified_on": "2026-05-18T17:57:17Z"
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: invoice.created payload">
  ```json theme={null}
  {
    "id": "2f31925f-10fc-41eb-8cb9-3cdf2b4e9e29",
    "type": "invoice.created",
    "created_at": "2026-04-02T05:56:33Z",
    "data": {
      "object": {
        "id": "INV-20260402055633-FCM4B",
        "object": "invoice",
        "status": "OPEN",
        "paid_at": null,
        "amount_total": "40",
        "amount_total_units": 4000,
        "currency": "USD",
        "due_date": null,
        "order_id": null,
        "customer_id": "c479275c-6987-4e0b-9aca-46e6e83226aa",
        "customer": {
          "id": "c479275c-6987-4e0b-9aca-46e6e83226aa",
          "name": "Geo Jacob",
          "email": "geojacob@example.com",
          "customer_id": "geo-jacob",
          "billing_address": {
            "line1": null,
            "line2": null,
            "city": null,
            "state": null,
            "postal_code": "680121",
            "country": "IN"
          }
        },
        "subscription_id": "a2951973-aca5-408d-bd9c-1d86827ed463",
        "organization": "9b759915-5387-4e61-90e9-2162318e8d17",
        "product": {
          "id": "df398021-477b-4e67-972d-c763a2f2ba7b",
          "name": "ParityDeals"
        },
        "created_on": "2026-04-02T05:56:33Z",
        "modified_on": "2026-04-02T05:56:33Z"
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: invoice.paid payload">
  ```json theme={null}
  {
    "id": "9d2c7e54-3f1b-4a8c-9e6d-2b5f8e1a4c7b",
    "type": "invoice.paid",
    "created_at": "2026-04-02T06:00:00Z",
    "data": {
      "object": {
        "id": "INV-20260402055633-FCM4B",
        "object": "invoice",
        "status": "PAID",
        "paid_at": "2026-04-02T06:00:00Z",
        "amount_total": "40",
        "amount_total_units": 4000,
        "currency": "USD",
        "due_date": null,
        "order_id": null,
        "customer_id": "c479275c-6987-4e0b-9aca-46e6e83226aa",
        "customer": {
          "id": "c479275c-6987-4e0b-9aca-46e6e83226aa",
          "name": "Geo Jacob",
          "email": "geojacob@example.com",
          "customer_id": "geo-jacob",
          "billing_address": {
            "line1": null,
            "line2": null,
            "city": null,
            "state": null,
            "postal_code": "680121",
            "country": "IN"
          }
        },
        "subscription_id": "a2951973-aca5-408d-bd9c-1d86827ed463",
        "organization": "9b759915-5387-4e61-90e9-2162318e8d17",
        "product": {
          "id": "df398021-477b-4e67-972d-c763a2f2ba7b",
          "name": "ParityDeals"
        },
        "created_on": "2026-04-02T05:56:33Z",
        "modified_on": "2026-04-02T06:00:00Z"
      },
      "previous_attributes": {
        "status": "OPEN",
        "paid_at": null
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: invoice.payment_failed payload">
  ```json theme={null}
  {
    "id": "1b8b128e-31c8-48b2-9d0b-f477ce543d58",
    "type": "invoice.payment_failed",
    "created_at": "2026-08-01T10:14:22Z",
    "data": {
      "object": {
        "id": "INV-20260801101420-Q7M2P",
        "object": "invoice",
        "organization": "9b759915-5387-4e61-90e9-2162318e8d17",
        "status": "PAYMENT_FAILED",
        "customer_id": "c479275c-6987-4e0b-9aca-46e6e83226aa",
        "customer": {
          "id": "c479275c-6987-4e0b-9aca-46e6e83226aa",
          "name": "Geo Jacob",
          "email": "geojacob@example.com",
          "customer_id": "geo-jacob",
          "billing_address": {
            "line1": null,
            "line2": null,
            "city": null,
            "state": null,
            "postal_code": "680121",
            "country": "IN"
          }
        },
        "product": {
          "id": "df398021-477b-4e67-972d-c763a2f2ba7b",
          "name": "ParityDeals"
        },
        "subscription_id": "a2951973-aca5-408d-bd9c-1d86827ed463",
        "order_id": null,
        "amount_total": "40",
        "amount_total_units": 4000,
        "currency": "USD",
        "paid_at": null,
        "due_date": null,
        "created_on": "2026-08-01T10:14:20Z",
        "modified_on": "2026-08-01T10:14:22Z"
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: refund.created payload">
  ```json theme={null}
  {
    "id": "6e76547b-9b6b-4504-af36-c8f646fa91fe",
    "type": "refund.created",
    "created_at": "2026-04-02T05:41:21Z",
    "data": {
      "object": {
        "id": "ca435ba3-d951-472d-a836-370787df3026",
        "object": "refund",
        "status": "PROCESSING",
        "amount": "20.0",
        "amount_units": 2000,
        "currency": "USD",
        "reason": "REQUESTED_BY_CUSTOMER",
        "order_id": "ORD-20260402042513-8M80C",
        "customer": {
          "id": "c479275c-6987-4e0b-9aca-46e6e83226aa",
          "name": "Geo Jacob",
          "email": "geojacob@example.com",
          "customer_id": "geo-jacob",
          "billing_address": {
            "line1": null,
            "line2": null,
            "city": null,
            "state": null,
            "postal_code": "680121",
            "country": "IN"
          }
        },
        "subscription_id": "a2951973-aca5-408d-bd9c-1d86827ed463",
        "organization": "9b759915-5387-4e61-90e9-2162318e8d17",
        "created_on": "2026-04-02T05:41:21Z",
        "modified_on": "2026-04-02T05:41:21Z"
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: refund.updated payload">
  ```json theme={null}
  {
    "id": "evt_01HXYZ2345678901BCDEFG",
    "type": "refund.updated",
    "created_at": "2026-04-02T05:41:30Z",
    "data": {
      "object": {
        "id": "ca435ba3-d951-472d-a836-370787df3026",
        "object": "refund",
        "status": "SUCCEEDED",
        "amount": "20.0",
        "amount_units": 2000,
        "currency": "USD",
        "reason": "REQUESTED_BY_CUSTOMER",
        "order_id": "ORD-20260402042513-8M80C",
        "customer": {
          "id": "c479275c-6987-4e0b-9aca-46e6e83226aa",
          "name": "Geo Jacob",
          "email": "geojacob@example.com",
          "customer_id": "geo-jacob",
          "billing_address": {
            "line1": null,
            "line2": null,
            "city": null,
            "state": null,
            "postal_code": "680121",
            "country": "IN"
          }
        },
        "subscription_id": "a2951973-aca5-408d-bd9c-1d86827ed463",
        "organization": "9b759915-5387-4e61-90e9-2162318e8d17",
        "created_on": "2026-04-02T05:41:21Z",
        "modified_on": "2026-04-02T05:41:30Z"
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: payment_method.created payload">
  ```json theme={null}
  {
    "id": "7f9c9a3e-2b41-4b7a-9d2e-5c8f1a6b3d4e",
    "type": "payment_method.created",
    "created_at": "2026-04-02T06:12:04Z",
    "data": {
      "object": {
        "id": 482,
        "object": "payment_method",
        "organization": "9b759915-5387-4e61-90e9-2162318e8d17",
        "customer_id": "geo-jacob",
        "customer": {
          "id": "936cf361-3a4b-454c-a464-41ccc7600c2d",
          "name": "Geo Jacob",
          "email": "geojacob@example.com",
          "customer_id": "geo-jacob",
          "billing_address": {
            "line1": null,
            "line2": null,
            "city": null,
            "state": null,
            "postal_code": "680121",
            "country": "IN"
          }
        },
        "method_type": "card",
        "method_data": {
          "brand": "visa",
          "last4": "4242",
          "exp_month": 12,
          "exp_year": 2029,
          "funding": "credit",
          "country": "US"
        },
        "is_default": true,
        "status": "succeeded"
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: payment_method.updated payload">
  ```json theme={null}
  {
    "id": "b1d4e5f6-3c72-4a9b-8e1d-6f2a9c4b7e8d",
    "type": "payment_method.updated",
    "created_at": "2026-04-02T06:15:47Z",
    "data": {
      "object": {
        "id": 482,
        "object": "payment_method",
        "organization": "9b759915-5387-4e61-90e9-2162318e8d17",
        "customer_id": "geo-jacob",
        "customer": {
          "id": "936cf361-3a4b-454c-a464-41ccc7600c2d",
          "name": "Geo Jacob",
          "email": "geojacob@example.com",
          "customer_id": "geo-jacob",
          "billing_address": {
            "line1": null,
            "line2": null,
            "city": null,
            "state": null,
            "postal_code": "680121",
            "country": "IN"
          }
        },
        "method_type": "card",
        "method_data": {
          "brand": "visa",
          "last4": "4242",
          "exp_month": 12,
          "exp_year": 2029,
          "funding": "credit",
          "country": "US"
        },
        "is_default": true,
        "status": "succeeded"
      }
    }
  }
  ```
</Accordion>

<Accordion title="Example: payment_method.deleted payload">
  ```json theme={null}
  {
    "id": "e3a2b1c0-9d8e-47f6-b5a4-1c2d3e4f5a6b",
    "type": "payment_method.deleted",
    "created_at": "2026-04-02T06:20:12Z",
    "data": {
      "object": {
        "id": 482,
        "object": "payment_method",
        "organization": "9b759915-5387-4e61-90e9-2162318e8d17",
        "customer_id": "geo-jacob",
        "customer": {
          "id": "936cf361-3a4b-454c-a464-41ccc7600c2d",
          "name": "Geo Jacob",
          "email": "geojacob@example.com",
          "customer_id": "geo-jacob",
          "billing_address": {
            "line1": null,
            "line2": null,
            "city": null,
            "state": null,
            "postal_code": "680121",
            "country": "IN"
          }
        },
        "method_type": "card",
        "method_data": {
          "brand": "visa",
          "last4": "4242",
          "exp_month": 12,
          "exp_year": 2029,
          "funding": "credit",
          "country": "US"
        },
        "is_default": false,
        "status": "succeeded"
      }
    }
  }
  ```
</Accordion>

***

## Verifying Signatures

<Warning>
  Always verify the signature before processing a webhook. Skipping verification exposes your endpoint to spoofed requests from third parties.
</Warning>

Kelviq signs every request using **HMAC-SHA256**. To verify a request:

1. Read the `webhook-id` and `webhook-timestamp` headers.
2. Construct the signed string by concatenating: `{webhook-id}.{webhook-timestamp}.{raw-request-body}` (joined with `.`)
3. Compute HMAC-SHA256 over the signed string using your endpoint's signing secret as the key.
4. Compare the hex digest to the signature in the `webhook-signature` header (strip the `v1,` prefix before comparing).
5. Reject the request if the signatures do not match.

<Note>
  The raw request body must be used exactly as received — before any JSON parsing. The compact JSON serialization (no spaces) is what Kelviq sends and signs.
</Note>

Optionally, also check that `webhook-timestamp` is within a few minutes of your server's current time to defend against replay attacks.

***

## Code Examples

<Note>
  Use `express.raw()` (not `express.json()`) in Node.js so that `req.body` contains the unmodified request bytes. Parsing the body before verification will break the signature check.
</Note>

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  function verifyWebhook(req, signingSecret) {
    const webhookId        = req.headers['webhook-id'];
    const webhookTimestamp = req.headers['webhook-timestamp'];
    const webhookSignature = req.headers['webhook-signature'];

    if (!webhookId || !webhookTimestamp || !webhookSignature) {
      throw new Error('Missing required webhook headers');
    }

    // Defend against replay attacks (5-minute tolerance)
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - parseInt(webhookTimestamp, 10)) > 300) {
      throw new Error('Webhook timestamp is too old');
    }

    // Reconstruct the signed payload
    const rawBody       = req.body; // must be the raw Buffer/string, not parsed JSON
    const signedPayload = `${webhookId}.${webhookTimestamp}.${rawBody}`;

    // Compute HMAC-SHA256
    const expectedSignature = crypto
      .createHmac('sha256', signingSecret)
      .update(signedPayload, 'utf8')
      .digest('hex');

    // Strip the "v1," prefix from the header value
    const [, receivedSignature] = webhookSignature.split(',');

    if (!crypto.timingSafeEqual(
      Buffer.from(expectedSignature, 'hex'),
      Buffer.from(receivedSignature, 'hex')
    )) {
      throw new Error('Webhook signature mismatch');
    }

    return JSON.parse(rawBody);
  }

  // Express handler example
  app.post('/webhooks/kelviq', express.raw({ type: 'application/json' }), (req, res) => {
    let event;
    try {
      event = verifyWebhook(req, process.env.KELVIQ_WEBHOOK_SECRET);
    } catch (err) {
      console.error('Webhook verification failed:', err.message);
      return res.status(400).send('Bad Request');
    }

    switch (event.type) {
      case 'subscription.created':
        console.log('New subscription:', event.data.object.id);
        break;
      case 'invoice.paid':
        console.log('Invoice paid:', event.data.object.id);
        break;
      default:
        console.log('Unhandled event type:', event.type);
    }

    res.status(200).json({ received: true });
  });
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import json
  import time
  from flask import Flask, request, abort

  app = Flask(__name__)
  SIGNING_SECRET = "kq_whsec_your_secret_here"  # load from environment


  def verify_webhook(payload_bytes: bytes, webhook_id: str, webhook_timestamp: str, webhook_signature: str) -> dict:
      # Defend against replay attacks (5-minute tolerance)
      now = int(time.time())
      if abs(now - int(webhook_timestamp)) > 300:
          raise ValueError("Webhook timestamp is too old")

      # Reconstruct the signed payload (body must be the raw bytes decoded as utf-8)
      signed_payload = f"{webhook_id}.{webhook_timestamp}.{payload_bytes.decode('utf-8')}"

      # Compute HMAC-SHA256
      expected_signature = hmac.new(
          SIGNING_SECRET.encode("utf-8"),
          signed_payload.encode("utf-8"),
          hashlib.sha256,
      ).hexdigest()

      # Strip the "v1," prefix from the header value
      _, received_signature = webhook_signature.split(",", 1)

      if not hmac.compare_digest(expected_signature, received_signature):
          raise ValueError("Webhook signature mismatch")

      return json.loads(payload_bytes)


  @app.route("/webhooks/kelviq", methods=["POST"])
  def handle_webhook():
      webhook_id        = request.headers.get("webhook-id")
      webhook_timestamp = request.headers.get("webhook-timestamp")
      webhook_signature = request.headers.get("webhook-signature")

      try:
          event = verify_webhook(request.get_data(), webhook_id, webhook_timestamp, webhook_signature)
      except ValueError as e:
          print(f"Webhook verification failed: {e}")
          abort(400)

      event_type = event.get("type")
      if event_type == "subscription.created":
          print("New subscription:", event["data"]["object"]["id"])
      elif event_type == "invoice.paid":
          print("Invoice paid:", event["data"]["object"]["id"])
      else:
          print("Unhandled event type:", event_type)

      return {"received": True}, 200
  ```

  ```go Go theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "encoding/json"
      "errors"
      "fmt"
      "io"
      "math"
      "net/http"
      "strconv"
      "strings"
      "time"
  )

  const signingSecret = "kq_whsec_your_secret_here" // load from environment

  type WebhookEvent struct {
      ID        string          `json:"id"`
      Type      string          `json:"type"`
      CreatedAt string          `json:"created_at"`
      Data      json.RawMessage `json:"data"`
  }

  func verifyWebhook(body []byte, webhookID, webhookTimestamp, webhookSignature string) (*WebhookEvent, error) {
      // Defend against replay attacks (5-minute tolerance)
      ts, err := strconv.ParseInt(webhookTimestamp, 10, 64)
      if err != nil {
          return nil, errors.New("invalid webhook-timestamp header")
      }
      if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
          return nil, errors.New("webhook timestamp is too old")
      }

      // Reconstruct the signed payload
      signedPayload := fmt.Sprintf("%s.%s.%s", webhookID, webhookTimestamp, string(body))

      // Compute HMAC-SHA256
      mac := hmac.New(sha256.New, []byte(signingSecret))
      mac.Write([]byte(signedPayload))
      expectedSig := hex.EncodeToString(mac.Sum(nil))

      // Strip the "v1," prefix from the header value
      parts := strings.SplitN(webhookSignature, ",", 2)
      if len(parts) != 2 {
          return nil, errors.New("malformed webhook-signature header")
      }
      receivedSig := parts[1]

      expectedBytes, _ := hex.DecodeString(expectedSig)
      receivedBytes, _ := hex.DecodeString(receivedSig)
      if !hmac.Equal(expectedBytes, receivedBytes) {
          return nil, errors.New("webhook signature mismatch")
      }

      var event WebhookEvent
      if err := json.Unmarshal(body, &event); err != nil {
          return nil, fmt.Errorf("failed to parse webhook body: %w", err)
      }
      return &event, nil
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
      body, err := io.ReadAll(r.Body)
      if err != nil {
          http.Error(w, "cannot read body", http.StatusBadRequest)
          return
      }
      defer r.Body.Close()

      event, err := verifyWebhook(
          body,
          r.Header.Get("webhook-id"),
          r.Header.Get("webhook-timestamp"),
          r.Header.Get("webhook-signature"),
      )
      if err != nil {
          http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest)
          return
      }

      switch event.Type {
      case "subscription.created":
          fmt.Println("New subscription:", event.ID)
      case "invoice.paid":
          fmt.Println("Invoice paid:", event.ID)
      default:
          fmt.Println("Unhandled event type:", event.Type)
      }

      w.Header().Set("Content-Type", "application/json")
      w.WriteHeader(http.StatusOK)
      w.Write([]byte(`{"received":true}`))
  }

  func main() {
      http.HandleFunc("/webhooks/kelviq", webhookHandler)
      http.ListenAndServe(":8080", nil)
  }
  ```
</CodeGroup>

***

## Best Practices

* **Return `2xx` fast.** Acknowledge the webhook immediately and process it asynchronously. Long-running handlers increase the risk of timeouts and duplicate retries.
* **Make handlers idempotent.** The same event may be delivered more than once. Use the top-level event `id` or the `webhook-id` header as the deduplication key.
* **Validate the timestamp.** Reject requests where `webhook-timestamp` is more than 5 minutes from your server's clock to prevent replay attacks.
* **Use `hmac.compare_digest` / `hmac.Equal` / `crypto.timingSafeEqual`.** Constant-time comparison prevents timing side-channel attacks.
* **Store the raw body before parsing.** Signature verification operates on the raw bytes, not the deserialized object.

***

## Need Help?

* Email us at **[hi@kelviq.com](mailto:hi@kelviq.com)**
* [Book a demo](https://tidycal.com/neravath/15-minute-meeting)
