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.
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:
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
<?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();
}
}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_idstops 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
402means the site's team has no active plan. Revenue is never blocked by the monthly event limit. - A
422names the field that failed. Fix the payload; retrying the same request won't help.