supapulsedocs
Revenue packageExamples

Examples

End-to-end revenue tracking with Stripe Checkout and Express, the SDK, Laravel, and payments that never touch a browser.

Express and Stripe Checkout

With the built-in Stripe webhook, your server only creates the checkout. Supapulse receives the payment from Stripe directly.

server.ts
import express from 'express';
import Stripe from 'stripe';

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// The browser calls supapulse.identify(user.id) after login, then posts
// { priceId, fingerprint, sessionId } here when checkout starts.
app.post('/checkout', express.json(), async (req, res) => {
  const { priceId, fingerprint, sessionId } = req.body;
  const metadata = {
    supapulse_customer_id: req.user.id,
    supapulse_visitor_fingerprint: fingerprint ?? '',
    supapulse_session_id: sessionId ?? '',
  };

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: 'https://acme.com/thanks',
    cancel_url: 'https://acme.com/pricing',
    metadata,
    subscription_data: { metadata },
  });
  res.json({ url: session.url });
});

app.listen(3000);

Add the Supapulse endpoint to Stripe once, in the dashboard. See the Stripe guide.

Express with the SDK

For a processor Supapulse has no webhook for, send revenue from your own handler with @supapulse/revenue:

webhooks.ts
import { Revenue } from '@supapulse/revenue';

const revenue = new Revenue({ apiToken: process.env.SUPAPULSE_SECRET_TOKEN! });

app.post('/webhooks/acme-pay', express.json(), async (req, res) => {
  const payment = verify(req); // your processor's signature check

  await revenue.track({
    customer_id: payment.metadata.user_id,
    amount: payment.amount / 100,
    currency: payment.currency.toUpperCase(),
    transaction_id: payment.id,
    event_type: 'purchase',
    payment_processor: 'acme-pay',
  });

  res.json({ received: true });
});

If track() throws, the handler returns a 5xx and the processor retries. The repeated transaction_id isn't stored twice.

Laravel

app/Services/Supapulse.php
<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;

class Supapulse
{
    /**
     * Send a revenue event. Throws on a non-2xx response so the webhook is retried.
     */
    public function trackRevenue(string $customerId, float $amount, string $transactionId, string $currency = 'USD', string $eventType = 'purchase', string $processor = 'paddle'): void
    {
        Http::withHeaders(['X-API-Token' => config('services.supapulse.secret_token')])
            ->post('https://supapulse.app/api/ingest', [
                'type' => 'revenue',
                'data' => [
                    'customer_id' => $customerId,
                    'amount' => $amount,
                    'currency' => $currency,
                    'transaction_id' => $transactionId,
                    'event_type' => $eventType,
                    'payment_processor' => $processor,
                ],
            ])
            ->throw();
    }
}
In a payment webhook handler
app(\App\Services\Supapulse::class)->trackRevenue(
    customerId: (string) $order->user_id,
    amount: $payment->amount / 100,
    transactionId: $payment->id,
    currency: strtoupper($payment->currency),
);

Add 'supapulse' => ['secret_token' => env('SUPAPULSE_SECRET_TOKEN')] to config/services.php.

Paddle Billing

See the Paddle guide for the checkout and the webhook handler.

Payments without a browser

Invoices paid by bank transfer, or deals closed by your sales team, have no browser visit. Send them with a customer ID so totals stay right:

curl -X POST https://supapulse.app/api/ingest \
  -H "Content-Type: application/json" \
  -H "X-API-Token: $SUPAPULSE_SECRET_TOKEN" \
  -d '{
    "type": "revenue",
    "data": {
      "customer_id": "acme-corp",
      "amount": 4800,
      "currency": "USD",
      "transaction_id": "invoice-2026-0142",
      "payment_processor": "manual"
    }
  }'

Handling errors

  • Throw when Supapulse answers with an error, and let the webhook fail. Processors retry, and the unique transaction_id stops a retry from counting twice.
  • Don't swallow a 401: it means the secret token changed, and every payment until you fix it is lost.
  • A 402 means the site's team has no active plan. Revenue is never blocked by the monthly event limit.
  • A 422 names the field that failed. Fix the payload; retrying the same request won't help.