Skip to main content
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 page documents every individual event type. The OpenAPI 3.1 spec is the formal contract — import into Postman, generate clients, or validate at runtime.
🚧 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.

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):
HeaderExamplePurpose
Content-Typeapplication/jsonPayload format
X-Recurr-Event-Idevt_01HQX8K9M1P0R5N3Y2T7B4C6VGlobally unique event ID; use for idempotency
X-Recurr-Event-Typesubscription.activatedEvent type (see Events)
X-Recurr-Schema-Versionv1Schema version (see Versioning)
X-Recurr-Signaturet=1716386096,v1=5257a869...HMAC-SHA256 signature (see Authentication)
Your endpoint should respond with a 2xx status code within 30 seconds. Non-2xx responses trigger Recurr’s retry policy.

Payload structure

All events share a common envelope. The data field carries event-specific fields documented per type on the Events page.
{
  "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

FieldTypeDescription
idstringGlobally unique event ID. Use for idempotency dedup.
typestringEvent type. See Events for catalog.
schema_versionstringSchema version. Current: v1.
created_atstring (ISO 8601)Event creation timestamp at Recurr.
tenant.idstringCustomer’s Recurr tenant ID.
tenant.namestringCustomer’s app / brand name.
subscriber.idstringStable subscriber identifier across migrations + auth providers.
subscriber.emailstringRaw email. Included for first-party destinations (your CS tool, BI, internal pipeline).
subscriber.email_hashedstringSHA256 hash of lower-cased trimmed email. Use for ad platforms / MMPs where raw PII shouldn’t flow.
subscriber.created_atstring (ISO 8601)When this subscriber first activated.
subscription.*objectCurrent subscription state at event time. Omitted for non-subscription events (e.g., ticket.submitted with no active sub).
dataobjectEvent-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 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)

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)

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
Your endpoint must handle duplicate deliveries gracefully. 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
-- 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:
AttemptDelay after previous
Initial
Retry 11 minute
Retry 25 minutes
Retry 330 minutes
Retry 42 hours
Retry 512 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 when generally available.

Specific status code handling

ResponseTreatment
2xxSuccess; no retry
3xxTreated 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
5xxRetried

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, destination management), rate limits apply per Recurr API key:
Endpoint groupLimit
Read operations100 req/min
Write operations30 req/min
Replay invocations3 concurrent per tenant
Standard X-RateLimit-* headers communicate current usage.