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

# Overview

> Recurr's webhook event API. Delivery model, payload structure, authentication, idempotency, schema versioning, retry policy.

Recurr emits subscription, payment, motion, and ticket events as signed webhooks to your existing data stack. This reference documents the event catalog, payload shape, and operational semantics — HMAC signing, retries, idempotency, schema versioning.

This page covers the cross-cutting concerns. The [Events](/api-reference/events) page documents every individual event type. The [OpenAPI 3.1 spec](/api-reference/openapi.yaml) is the formal contract — import into Postman, generate clients, or validate at runtime.

<Warning>
  **🚧 Under development.** The API reference defines the contract; implementation is in progress.

  * **Schemas + operational semantics** (envelope, HMAC, idempotency, retry, versioning) are committed — engineering builds against these.
  * **Webhook event delivery** is the V1 milestone; foundation work in progress.
  * **REST endpoints** (Subscribers, Subscriptions, Events, Cohorts, Motions, Metrics, Payments, Tickets, Replay, Destinations) are scaffolded for planning; not available today. Each operation is marked Under development in its description.

  Customer-facing access lands in phases — webhook stream first, REST reads next (when first customer pulls), writes after that. See each endpoint's description for status.
</Warning>

## Delivery model

Events fire from Recurr's billing layer in near real-time. Each event is delivered as an HTTP POST to your registered endpoint(s):

| Header                    | Example                         | Purpose                                                       |
| ------------------------- | ------------------------------- | ------------------------------------------------------------- |
| `Content-Type`            | `application/json`              | Payload format                                                |
| `X-Recurr-Event-Id`       | `evt_01HQX8K9M1P0R5N3Y2T7B4C6V` | Globally unique event ID; use for [idempotency](#idempotency) |
| `X-Recurr-Event-Type`     | `subscription.activated`        | Event type (see [Events](/api-reference/events))              |
| `X-Recurr-Schema-Version` | `v1`                            | Schema version (see [Versioning](#versioning))                |
| `X-Recurr-Signature`      | `t=1716386096,v1=5257a869...`   | HMAC-SHA256 signature (see [Authentication](#authentication)) |

Your endpoint should respond with a 2xx status code within 30 seconds. Non-2xx responses trigger Recurr's [retry policy](#retry-policy).

## Payload structure

All events share a common envelope. The `data` field carries event-specific fields documented per type on the [Events](/api-reference/events) page.

```json theme={null}
{
  "id": "evt_01HQX8K9M1P0R5N3Y2T7B4C6V",
  "type": "subscription.activated",
  "schema_version": "v1",
  "created_at": "2026-05-22T12:34:56.123Z",
  "tenant": {
    "id": "tnt_app123",
    "name": "ExampleApp"
  },
  "subscriber": {
    "id": "subscriber_01HQ...",
    "email": "user@example.com",
    "email_hashed": "sha256:abc123def456...",
    "created_at": "2025-03-10T00:00:00Z"
  },
  "subscription": {
    "id": "sub_01HQ...",
    "status": "active",
    "plan": "premium_monthly",
    "current_period_start": "2026-05-22T00:00:00Z",
    "current_period_end": "2026-06-22T00:00:00Z"
  },
  "data": {
    "source": "migration",
    "cohort_id": "cohort_q3_pilot",
    "first_payment_amount": 999,
    "first_payment_currency": "USD"
  }
}
```

### Envelope field reference

| Field                     | Type              | Description                                                                                                                  |
| ------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `id`                      | string            | Globally unique event ID. Use for idempotency dedup.                                                                         |
| `type`                    | string            | Event type. See [Events](/api-reference/events) for catalog.                                                                 |
| `schema_version`          | string            | Schema version. Current: `v1`.                                                                                               |
| `created_at`              | string (ISO 8601) | Event creation timestamp at Recurr.                                                                                          |
| `tenant.id`               | string            | Customer's Recurr tenant ID.                                                                                                 |
| `tenant.name`             | string            | Customer's app / brand name.                                                                                                 |
| `subscriber.id`           | string            | Stable subscriber identifier across migrations + auth providers.                                                             |
| `subscriber.email`        | string            | Raw email. Included for first-party destinations (your CS tool, BI, internal pipeline).                                      |
| `subscriber.email_hashed` | string            | SHA256 hash of lower-cased trimmed email. Use for ad platforms / MMPs where raw PII shouldn't flow.                          |
| `subscriber.created_at`   | string (ISO 8601) | When this subscriber first activated.                                                                                        |
| `subscription.*`          | object            | Current subscription state at event time. Omitted for non-subscription events (e.g., `ticket.submitted` with no active sub). |
| `data`                    | object            | Event-specific payload. Documented per event type.                                                                           |

### PII handling

Two email fields are always present in the envelope:

* `subscriber.email` — raw email
* `subscriber.email_hashed` — SHA256 hash

Per-destination configuration controls which fields actually forward to a given endpoint. By default:

* BI / CS / warehouse destinations receive both
* MMP / ad-platform destinations receive `email_hashed` only

See [Destinations](/api-reference/destinations) for PII-mode configuration.

## Authentication

Every webhook is signed with HMAC-SHA256 using your endpoint's signing secret.

### Signature header

```
X-Recurr-Signature: t=1716386096,v1=5257a869e7ecf4bd95918e7d6b4f5d0e23...
```

* `t` — Unix timestamp (seconds) when Recurr signed the payload
* `v1` — HMAC-SHA256 of `${t}.${rawBody}` using your destination's signing secret

### Verification (Node.js)

```js theme={null}
import crypto from "crypto";

function verifyRecurrSignature(rawBody, signatureHeader, signingSecret) {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("=")),
  );
  if (!parts.t || !parts.v1) return false;

  // Reject if timestamp is more than 5 minutes old (replay protection)
  const ageSeconds = Math.floor(Date.now() / 1000) - Number(parts.t);
  if (ageSeconds > 300 || ageSeconds < -60) return false;

  const expected = crypto
    .createHmac("sha256", signingSecret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(parts.v1, "hex"),
  );
}
```

### Verification (Python)

```python theme={null}
import hmac
import hashlib
import time

def verify_recurr_signature(raw_body, signature_header, signing_secret):
    parts = dict(p.split("=") for p in signature_header.split(","))
    if "t" not in parts or "v1" not in parts:
        return False

    age_seconds = int(time.time()) - int(parts["t"])
    if age_seconds > 300 or age_seconds < -60:
        return False

    expected = hmac.new(
        signing_secret.encode(),
        f"{parts['t']}.{raw_body}".encode(),
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, parts["v1"])
```

### Rejection policy

Reject the request (return 401 or 403) if any of the following:

* Signature header missing or malformed
* HMAC verification fails
* Timestamp more than 5 minutes old (replay-attack protection)
* Timestamp more than 60 seconds in the future (clock skew protection)

## Idempotency

Every event has a globally unique `id` (`evt_*`). Recurr may deliver the same event multiple times due to:

* Retries on 5xx responses
* Network failures during initial delivery
* Manual replay via the [Replay API](/api-reference/replay)

Your endpoint **must** handle duplicate deliveries gracefully.

### Recommended pattern

Store received `evt_*` IDs in a dedup table (e.g., a `webhook_events_received` table keyed on `event_id`) for 30+ days. On each delivery:

1. Check if `event_id` exists in dedup table
2. If yes — return 200 immediately, skip re-processing
3. If no — process the event, then insert `event_id` into dedup table

```sql theme={null}
-- Example dedup table
CREATE TABLE webhook_events_received (
  event_id VARCHAR(64) PRIMARY KEY,
  received_at TIMESTAMP NOT NULL DEFAULT NOW(),
  processed_at TIMESTAMP,
  event_type VARCHAR(64) NOT NULL
);

-- TTL via partitioning or scheduled cleanup
DELETE FROM webhook_events_received WHERE received_at < NOW() - INTERVAL '30 days';
```

## Retry policy

If your endpoint returns a non-2xx status code or fails to respond within 30 seconds, Recurr retries with exponential backoff:

| Attempt  | Delay after previous             |
| -------- | -------------------------------- |
| Initial  | —                                |
| Retry 1  | 1 minute                         |
| Retry 2  | 5 minutes                        |
| Retry 3  | 30 minutes                       |
| Retry 4  | 2 hours                          |
| Retry 5  | 12 hours                         |
| Retry 6+ | 24 hours, for up to 7 days total |

After 7 days of continuous failures, the event moves to a dead-letter queue. Manual replay available via the [Replay API](/api-reference/replay) when generally available.

### Specific status code handling

| Response                | Treatment                                                     |
| ----------------------- | ------------------------------------------------------------- |
| 2xx                     | Success; no retry                                             |
| 3xx                     | Treated as failure; retried                                   |
| 4xx (except 408)        | Treated as permanent failure; no retry (assumes config error) |
| 408 (Request Timeout)   | Retried                                                       |
| 429 (Too Many Requests) | Retried; honors `Retry-After` header if present               |
| 5xx                     | Retried                                                       |

## Versioning

Webhook payloads are versioned via `schema_version` in the envelope + the `X-Recurr-Schema-Version` header.

### Versioning policy

* **Additive changes** (new optional fields) are non-breaking; deployed without version bump
* **Breaking changes** (renamed/removed fields, changed types, changed semantics) ship as a new schema version (`v2`, `v3`, etc.)
* New schema versions are announced 90 days before becoming default for new destinations
* Existing destinations stay pinned to their registered version until they explicitly migrate

### Pinning a version

Specify your preferred schema version when registering a destination. Recurr emits events in that version's shape until you migrate to a newer one.

If no version is pinned, the destination receives the current default (`v1` today).

### Deprecation cadence

Older schema versions remain supported for 24 months after a newer default ships. Sunset notices are sent to the destination contact email at 12, 6, 3, and 1 month thresholds before removal.

## Rate limits

Webhook delivery is not rate-limited from Recurr's side — events fire at the cadence of underlying subscription events.

For API endpoints (e.g., [Replay API](/api-reference/replay), destination management), rate limits apply per Recurr API key:

| Endpoint group     | Limit                   |
| ------------------ | ----------------------- |
| Read operations    | 100 req/min             |
| Write operations   | 30 req/min              |
| Replay invocations | 3 concurrent per tenant |

Standard `X-RateLimit-*` headers communicate current usage.
