---
title: Events
description: The shared webhook envelope and the payload of all five Insy event types — membership, payment and digital product purchase events.
---

Insy sends five event types, and every active webhook of an eligible user receives all of them. Each delivery uses the same envelope; only the contents of `data` differ per type. Branch on `event_type` and ignore anything you do not handle.

| Event type | Fires when |
| --- | --- |
| `membership.created` | A membership is created for a user in a community |
| `membership.updated` | An existing membership's active state flips |
| `payment.success` | A payment completes |
| `payment.failed` | A payment does not complete |
| `digital.product.purchase` | A digital product is purchased |

## The envelope

Every event has the same four top-level fields.

```json title="Envelope"
{
  "event_type": "membership.created",
  "event_id": "insy_event_1756742400000_k3f9d2a1x8b",
  "data": {
    "membershipId": "4f9a1c2e-7b3d-4e58-9c01-6d2f8a5b3e17",
    "metadata": { "userProfile": {} }
  },
  "timestamp": "1756742400000"
}
```

| Field | Type | Description |
| --- | --- | --- |
| `event_type` | string | One of the five event types. Also sent as the `X-Insy-Event-Type` header. |
| `event_id` | string | The event identifier, in the same `insy_event_<epochMillis>_<random>` form as the `X-Insy-Event-Id` header. Use it as your deduplication key. |
| `data` | object | The event payload. Always contains `metadata`, plus the type-specific fields documented below. |
| `timestamp` | string | Epoch **milliseconds**, as a JSON **string**. |

> **timestamp is a string of epoch milliseconds**
>
> `"timestamp": "1756742400000"` is not ISO-8601 and not a number. A schema that types it as an integer or parses it as a date string will fail. Convert it explicitly: `new Date(Number(event.timestamp))` in JavaScript, `datetime.fromtimestamp(int(event["timestamp"]) / 1000, tz=timezone.utc)` in Python. Also note that this is the event's own timestamp, not the delivery time — a retried delivery carries the original value.

### data.metadata.userProfile

Every payload carries `data.metadata.userProfile`, the Insy profile of the user the event is about. The examples below abbreviate it as `{}`. Read the properties you need defensively and ignore the rest; the object is additive.

The `data` fields listed in the tables below are in addition to `metadata`.

> **Treat status-like strings as open sets**
>
> `role`, `paymentStatus`, `paymentMethod`, `paymentType` and `lastPaymentMethod` come from server-side enums that can gain members without notice. The values documented below are the ones in use today. Match the values you care about and let unknown ones fall through to a default branch rather than throwing.
>
> They are lowercase snake_case on the wire — `stripe`, `stripe_one_time`, `one_time`, `partially_refunded`. Compare against the lowercase form.

## membership.created

Fires when a membership record is created for a user in a community. Delivered to the webhooks of that community's `owner`, `admin` and `moderator` members.

Creation is not activation: a membership backed by a not-yet-active Stripe subscription is created with `isActive: false`, so `membership.created` can arrive for a member without access. Grant access only when `isActive` is `true`, not on the event itself.

| Field | Type | Description |
| --- | --- | --- |
| `membershipId` | string | Identifier of the membership. Stable across later updates, so it is the right key to store. |
| `isActive` | boolean | Whether the membership is active now. |
| `wasActive` | boolean | Whether it was active before this change. |
| `role` | string | The role the user holds in the community. The values are `owner`, `admin`, `moderator` and `member`. |
| `validFrom` | string | Start of the membership window, ISO-8601. |
| `validUntil` | string | End of the membership window, ISO-8601. Do not assume it is always populated — a lifetime membership has no end date. |
| `lastPaymentMethod` | string | The payment method most recently used for this membership. |

```json title="membership.created"
{
  "event_type": "membership.created",
  "event_id": "insy_event_1756742400000_k3f9d2a1x8b",
  "data": {
    "membershipId": "4f9a1c2e-7b3d-4e58-9c01-6d2f8a5b3e17",
    "isActive": true,
    "wasActive": false,
    "role": "member",
    "validFrom": "2025-09-01T16:00:00.000Z",
    "validUntil": "2025-10-01T16:00:00.000Z",
    "lastPaymentMethod": "stripe",
    "metadata": { "userProfile": {} }
  },
  "timestamp": "1756742400000"
}
```

## membership.updated

Fires when a membership's active state flips — an activation or a lapse. The payload has the same shape as `membership.created`.

A role change, or a renewal that only moves `validUntil` while the membership stays active, does **not** produce a webhook. Poll or re-read the membership if you need those.

| Field | Type | Description |
| --- | --- | --- |
| `membershipId` | string | Identifier of the membership being updated. |
| `isActive` | boolean | The state after the change. |
| `wasActive` | boolean | The state before the change. |
| `role` | string | The role the user holds in the community. |
| `validFrom` | string | Start of the membership window, ISO-8601. |
| `validUntil` | string | End of the membership window, ISO-8601. |
| `lastPaymentMethod` | string | The payment method most recently used for this membership. |

The pair `wasActive` and `isActive` tells you which direction the membership moved:

| `wasActive` | `isActive` | Meaning |
| --- | --- | --- |
| `false` | `true` | **Activation** — grant access. |
| `true` | `false` | **Lapse** — revoke access. |

`wasActive` is always the negation of `isActive` — every membership event is a transition. There is no "nothing changed" delivery.

Deliveries are unordered and can repeat, so do not toggle a flag on receipt — compare against the state you hold and treat `isActive` and `validUntil` from the payload as authoritative.

```json title="membership.updated — a lapse"
{
  "event_type": "membership.updated",
  "event_id": "insy_event_1756831245880_9qz4m7t2v1c",
  "data": {
    "membershipId": "4f9a1c2e-7b3d-4e58-9c01-6d2f8a5b3e17",
    "isActive": false,
    "wasActive": true,
    "role": "member",
    "validFrom": "2025-09-01T16:00:00.000Z",
    "validUntil": "2025-09-02T16:00:00.000Z",
    "lastPaymentMethod": "stripe",
    "metadata": { "userProfile": {} }
  },
  "timestamp": "1756831245880"
}
```

## payment.success

Fires when a payment completes. Delivered to the webhooks of the community's `owner`, `admin` and `moderator` members.

| Field | Type | Description |
| --- | --- | --- |
| `paymentId` | string | Identifier of the payment. Use it, together with the event id, to keep your bookkeeping idempotent. |
| `membershipId` | string | The membership this payment belongs to. |
| `paymentStatus` | string | Status of the payment. For `payment.success` this is always `completed` — the event type is derived from it. |
| `paymentMethod` | string | How the payment was made. |
| `paymentType` | string | What kind of payment it is. The values are `subscription`, `one_time` and `refund`. |
| `amountCents` | integer | Amount in **minor units** of `currency`. `4900` with `"currency": "pln"` is 49.00 PLN. |
| `currency` | string | Currency code for `amountCents`. Case is not normalised — Stripe-settled payments send the lowercase ISO code (`pln`, `usd`), crypto settlements may send uppercase. Compare case-insensitively. |
| `periodStart` | string, optional | Start of the paid period, ISO-8601. |
| `periodEnd` | string, optional | End of the paid period, ISO-8601. |
| `durationMonths` | integer, optional | Length of the paid period in months. |

> **The period fields are omitted, not null**
>
> `periodStart`, `periodEnd` and `durationMonths` are dropped from the JSON entirely when the emitting code does not set them, so the keys are absent rather than `null`. `durationMonths` in particular is only sent on one-time and crypto purchases — a `subscription` payment never carries it. Guard for the missing key: `new Date(data.periodEnd)` on an absent field yields `Invalid Date`.

> **amountCents is minor units**
>
> Never render `amountCents` directly. Divide by 100 for currencies with two decimal places, and do the arithmetic in integers — floating point on money is how rounding bugs get shipped.

```json title="payment.success"
{
  "event_type": "payment.success",
  "event_id": "insy_event_1756742463127_7hd4p9s2n6r",
  "data": {
    "paymentId": "b81d5f60-3c2a-4d19-95e7-0af6c3241b8d",
    "membershipId": "4f9a1c2e-7b3d-4e58-9c01-6d2f8a5b3e17",
    "paymentStatus": "completed",
    "paymentMethod": "stripe",
    "paymentType": "subscription",
    "amountCents": 4900,
    "currency": "pln",
    "periodStart": "2025-09-01T16:00:00.000Z",
    "periodEnd": "2025-10-01T16:00:00.000Z",
    "metadata": { "userProfile": {} }
  },
  "timestamp": "1756742463127"
}
```

## payment.failed

Fires when a payment does not complete. In practice this means one thing: a subscription renewal invoice Stripe could not charge, typically a declined card. Same field names as `payment.success`, but fewer of them — see the note below.

| Field | Type | Description |
| --- | --- | --- |
| `paymentId` | string | Identifier of the failed payment. |
| `membershipId` | string | The membership the payment was for. |
| `paymentStatus` | string | Status of the payment. For `payment.failed` this is always `failed`. |
| `paymentMethod` | string | How the payment was attempted. Today this is always `stripe`, since only a Stripe renewal charge produces the event. |
| `paymentType` | string | What kind of payment it was. Today this is always `subscription`, for the same reason. |
| `amountCents` | integer | Amount in minor units of `currency`. |
| `currency` | string | Currency code for `amountCents`. Case is not normalised — see the note under `payment.success`. |

> **No period fields on payment.failed**
>
> Unlike `payment.success`, a `payment.failed` body never carries `periodStart`, `periodEnd` or `durationMonths`. The only code path that emits this event does not set them, and absent values are omitted from the JSON rather than sent as `null`. Do not read those keys here.

A failed payment is not, by itself, a loss of access. The membership state is carried by `membership.updated`: expect a lapse only when a later event reports `wasActive: true` with `isActive: false`.

```json title="payment.failed"
{
  "event_type": "payment.failed",
  "event_id": "insy_event_1756831188441_c5w8j1r4y0k",
  "data": {
    "paymentId": "e02b7a94-6d15-42c8-b3f1-58ac0d97e264",
    "membershipId": "4f9a1c2e-7b3d-4e58-9c01-6d2f8a5b3e17",
    "paymentStatus": "failed",
    "paymentMethod": "stripe",
    "paymentType": "subscription",
    "amountCents": 4900,
    "currency": "pln",
    "metadata": { "userProfile": {} }
  },
  "timestamp": "1756831188441"
}
```

## digital.product.purchase

Fires when a digital product is purchased. Unlike the other four, this event is delivered to the webhooks of the **product's creator**, not to community moderators.

| Field | Type | Description |
| --- | --- | --- |
| `membershipId` | string | The membership created for the purchase. |
| `productVariantId` | string | The specific variant of the product that was bought. |
| `paymentId` | string | The payment behind the purchase. |
| `amountCents` | integer | Amount in minor units of `currency`. |
| `currency` | string | Currency code for `amountCents`. This event is always Stripe-settled, so the code is lowercase (`pln`, `usd`). |
| `validFrom` | string | Start of the access window, ISO-8601. |
| `validUntil` | string | End of the access window, ISO-8601. |

```json title="digital.product.purchase"
{
  "event_type": "digital.product.purchase",
  "event_id": "insy_event_1756744812004_m2v6b3t9q7d",
  "data": {
    "membershipId": "a17c8e42-9f05-4b6d-8e31-27c9a4d05f6b",
    "productVariantId": "5d3f0b71-8a24-4c96-a0e5-1b7f62d84c39",
    "paymentId": "c94e2d18-7b60-4f53-8a2c-3e15d097ab42",
    "amountCents": 12900,
    "currency": "pln",
    "validFrom": "2025-09-01T16:40:12.004Z",
    "validUntil": "2026-09-01T16:40:12.004Z",
    "metadata": { "userProfile": {} }
  },
  "timestamp": "1756744812004"
}
```

## Routing the five types

A single handler receives everything, so the dispatch is a switch with a default branch that does nothing but still acknowledges.

```js title="Node.js"
function handle(event) {
  switch (event.event_type) {
    case "membership.created":
      // A created membership is not always active — grant only when it is.
      return event.data.isActive ? grantAccess(event.data) : undefined;
    case "membership.updated":
      return event.data.isActive
        ? grantAccess(event.data)
        : revokeAccess(event.data);
    case "payment.success":
      return recordPayment(event.data);
    case "payment.failed":
      return flagDunning(event.data);
    case "digital.product.purchase":
      return fulfilProduct(event.data);
    default:
      // Unknown type: acknowledge and move on.
      return undefined;
  }
}
```

Signature verification comes first — see [Verifying signatures](/developers/webhooks/verify).
