Commet
  • Pricing
Log InTry out
Introduction

Subscription Events

subscription.createdsubscription.activatedsubscription.canceledsubscription.updatedsubscription.plan_changed

Payment Events

payment.receivedpayment.failed

Invoice Events

invoice.created
DocumentationKnowledge BaseBuild with AIAPI ReferenceWebhooks

Introduction

Receive real-time notifications when events happen in your Commet account.

Webhooks let your application receive real-time HTTP notifications when events happen in Commet — like a subscription being activated, a payment failing, or an invoice being created.

How it works

  1. You register an endpoint URL in the Commet dashboard
  2. You select which events you want to receive
  3. When an event occurs, Commet sends a POST request to your URL with the event data

Payload structure

Every webhook delivers a JSON payload with this envelope:

{
  "event": "subscription.activated",
  "timestamp": "2026-03-25T14:30:00.000Z",
  "organizationId": "org_abc123",
  "data": {
    // Event-specific fields
  }
}
FieldTypeDescription
eventstringThe event type (e.g. subscription.activated)
timestampstringISO 8601 datetime when the event was emitted
organizationIdstringYour organization ID
dataobjectEvent-specific payload — see Webhook Events

Handling webhooks

Receive events by exposing an HTTP endpoint. The Node.js SDK ships a dedicated Next.js handler that verifies signatures and routes events automatically; in other languages, verify the payload and dispatch on event.

app/api/webhooks/commet/route.ts
import { Webhooks } from "@commet/next"

export const POST = Webhooks({
  webhookSecret: process.env.COMMET_WEBHOOK_SECRET!,

  onSubscriptionActivated: async (payload) => {
    await db.update(users)
      .set({ isPaid: true })
      .where(eq(users.id, payload.data.customerId))
  },

  onSubscriptionCanceled: async (payload) => {
    await db.update(users)
      .set({ isPaid: false })
      .where(eq(users.id, payload.data.customerId))
  },

  onPayload: async (payload) => {
    console.log(`Received: ${payload.event}`)
  },
})
import os
from flask import Flask, request, Response
from commet import Commet

app = Flask(__name__)
commet = Commet(api_key=os.environ['COMMET_API_KEY'])

@app.post('/api/webhooks/commet')
def handle_webhook():
    payload = commet.webhooks.verify_and_parse(
        raw_body=request.get_data(as_text=True),
        signature=request.headers.get('x-commet-signature'),
        secret=os.environ['COMMET_WEBHOOK_SECRET'],
    )

    if payload is None:
        return Response('Invalid signature', status=401)

    if payload['event'] == 'subscription.activated':
        # Grant access
        pass
    elif payload['event'] == 'subscription.canceled':
        # Revoke access
        pass

    return Response('OK', status=200)
import (
    "io"
    "net/http"
    "os"

    "github.com/commet/commet-go"
)

client, _ := commet.New(os.Getenv("COMMET_API_KEY"))

http.HandleFunc("/api/webhooks/commet", func(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)

    payload, err := client.Webhooks.VerifyAndParse(
        string(body),
        r.Header.Get("X-Commet-Signature"),
        os.Getenv("COMMET_WEBHOOK_SECRET"),
    )
    if err != nil {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    switch payload["event"] {
    case "subscription.activated":
        // Grant access
    case "subscription.canceled":
        // Revoke access
    }

    w.WriteHeader(http.StatusOK)
})
import co.commet.Commet;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;

@RestController
public class CommetWebhookController {

    private final Commet commet = Commet.builder()
        .apiKey(System.getenv("COMMET_API_KEY"))
        .build();

    @PostMapping("/api/webhooks/commet")
    public ResponseEntity<String> handle(
        @RequestBody String rawBody,
        @RequestHeader("X-Commet-Signature") String signature
    ) {
        Map<String, Object> payload = commet.webhooks().verifyAndParse(
            rawBody,
            signature,
            System.getenv("COMMET_WEBHOOK_SECRET")
        );

        if (payload == null) {
            return ResponseEntity.status(401).body("Invalid signature");
        }

        String event = (String) payload.get("event");
        switch (event) {
            case "subscription.activated" -> { /* Grant access */ }
            case "subscription.canceled" -> { /* Revoke access */ }
        }

        return ResponseEntity.ok("OK");
    }
}
use Commet\Commet;

$commet = new Commet(apiKey: getenv('COMMET_API_KEY'));

$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_COMMET_SIGNATURE'] ?? null;

$payload = $commet->webhooks->verifyAndParse(
    $rawBody,
    $signature,
    getenv('COMMET_WEBHOOK_SECRET'),
);

if ($payload === null) {
    http_response_code(401);
    exit('Invalid signature');
}

match ($payload['event']) {
    'subscription.activated' => null, // Grant access
    'subscription.canceled' => null,  // Revoke access
    default => null,
};

http_response_code(200);
echo 'OK';

Verifying signatures manually

If you're not using @commet/next, verify the HMAC-SHA256 signature yourself with the SDK:

import { Commet } from "@commet/node"

const commet = new Commet({ apiKey: process.env.COMMET_API_KEY! })

export async function POST(request: Request) {
  const rawBody = await request.text()
  const signature = request.headers.get("x-commet-signature")

  const payload = commet.webhooks.verifyAndParse({
    rawBody,
    signature,
    secret: process.env.COMMET_WEBHOOK_SECRET!,
  })

  if (!payload) {
    return new Response("Invalid signature", { status: 403 })
  }

  switch (payload.event) {
    case "subscription.activated":
      // Grant access
      break
    case "subscription.canceled":
      // Revoke access
      break
  }

  return new Response("OK", { status: 200 })
}
import os
from flask import Flask, request, Response
from commet import Commet

app = Flask(__name__)
commet = Commet(api_key=os.environ['COMMET_API_KEY'])

@app.post('/webhooks/commet')
def commet_webhook():
    raw_body = request.get_data(as_text=True)
    signature = request.headers.get('x-commet-signature')

    payload = commet.webhooks.verify_and_parse(
        raw_body=raw_body,
        signature=signature,
        secret=os.environ['COMMET_WEBHOOK_SECRET'],
    )

    if payload is None:
        return Response('Invalid signature', status=403)

    if payload['event'] == 'subscription.activated':
        # Grant access
        pass
    elif payload['event'] == 'subscription.canceled':
        # Revoke access
        pass

    return Response('OK', status=200)
import (
    "io"
    "net/http"
    "os"

    "github.com/commet/commet-go"
)

client, _ := commet.New(os.Getenv("COMMET_API_KEY"))

http.HandleFunc("/webhooks/commet", func(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    signature := r.Header.Get("X-Commet-Signature")

    payload, err := client.Webhooks.VerifyAndParse(
        string(body),
        signature,
        os.Getenv("COMMET_WEBHOOK_SECRET"),
    )
    if err != nil {
        http.Error(w, "Invalid signature", http.StatusForbidden)
        return
    }

    switch payload["event"] {
    case "subscription.activated":
        // Grant access
    case "subscription.canceled":
        // Revoke access
    }

    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
})
import co.commet.Commet;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;

@RestController
public class WebhookController {

    private final Commet commet = Commet.builder()
        .apiKey(System.getenv("COMMET_API_KEY"))
        .build();

    @PostMapping("/webhooks/commet")
    public ResponseEntity<String> handle(
        @RequestBody String rawBody,
        @RequestHeader("X-Commet-Signature") String signature
    ) {
        Map<String, Object> payload = commet.webhooks().verifyAndParse(
            rawBody,
            signature,
            System.getenv("COMMET_WEBHOOK_SECRET")
        );

        if (payload == null) {
            return ResponseEntity.status(403).body("Invalid signature");
        }

        String event = (String) payload.get("event");
        switch (event) {
            case "subscription.activated" -> { /* Grant access */ }
            case "subscription.canceled" -> { /* Revoke access */ }
        }

        return ResponseEntity.ok("OK");
    }
}
use Commet\Commet;

$commet = new Commet(apiKey: getenv('COMMET_API_KEY'));

$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_COMMET_SIGNATURE'] ?? null;

$payload = $commet->webhooks->verifyAndParse(
    $rawBody,
    $signature,
    getenv('COMMET_WEBHOOK_SECRET'),
);

if ($payload === null) {
    http_response_code(403);
    echo 'Invalid signature';
    exit;
}

match ($payload['event']) {
    'subscription.activated' => null, // Grant access
    'subscription.canceled' => null,  // Revoke access
    default => null,
};

http_response_code(200);
echo 'OK';

Headers

Commet sends these headers with every webhook request:

HeaderDescription
X-Commet-SignatureHMAC-SHA256 hex signature of the raw body
X-Commet-EventThe event type (e.g. subscription.activated)
X-Commet-TimestampISO 8601 datetime when the event was emitted
Content-Typeapplication/json

Retry policy

If your endpoint returns a non-2xx status or times out (10 seconds), Commet retries with exponential backoff:

AttemptDelay
1st retry1 minute
2nd retry5 minutes
3rd retry15 minutes
4th retry30 minutes
5th retry1 hour
6th retry2 hours
7th retry4 hours
8th retry6 hours

After 8 failed attempts, the delivery is marked as failed and we email your organization's notification recipient with the endpoint URL, event type, and the last response we received (HTTP status or error code).

Auto-disable for broken endpoints

If three events in a row fail to deliver, Commet automatically disables the endpoint so it stops consuming retries. You'll get a second email confirming the endpoint was turned off.

Once you've fixed the issue on your receiver, re-enable the endpoint from the Commet dashboard under Settings → Webhooks → Endpoints. Events that arrived while the endpoint was disabled are not replayed automatically — contact support if you need to backfill any missed events.

You can monitor delivery status, inspect payloads, and retry individual deliveries from the dashboard at any time.

Subscription status lifecycle

Every subscription.* webhook includes a status field. These are the valid values and which ones grant access to your product:

StatusGrants access?Meaning
draftNoInternal setup state before any event is fired
pending_paymentNoSubscription created, waiting for the first charge to confirm
trialingYesTrial active; card captured, no charge yet
activeYesPaid and current
past_dueYes (grace period)Payment failed; automatic retries in progress
pausedNoAdmin-paused; no billing
canceledNoCustomer or admin canceled
expiredNoTrial ended without payment, or terminal state

Typical flow:

draft → pending_payment → trialing → active → canceled
                                  ↓
                                paused → past_due → expired

Rule of thumb: gate access on status === "active" || status === "trialing". Rely on subscription.activated to turn access on and subscription.canceled to turn it off.

Related

  • Webhook Events — all events and their payloads

How is this guide?

subscription.created

Fired when a new subscription is created

On this page

How it works
Payload structure
Handling webhooks
Verifying signatures manually
Headers
Retry policy
Auto-disable for broken endpoints
Subscription status lifecycle
Related