Documentation

Paddle

This guide uses Paddle Billing, not Paddle Classic. Paddle uses a custom server adapter. Stripe, Lemon Squeezy and Polar can instead be connected directly under Settings → Revenue. For Paddle, mint a key under Developer with payments:write, and save it as JELTO_KEY in your webhook server. A default analytics-only key cannot write payments. The key is shown once; manage or revoke it from the same screen.

Checkout attribution

After the tracker loads, read aggregate attribution when opening checkout:

const attribution = jelto('attribution');
Paddle.Checkout.open({
  items: [{ priceId: PRICE_ID, quantity: 1 }],
  customData: {
    jelto_cohort: attribution.cohort,
    ...(attribution.first ? { jelto_jt: 'first' } : {}),
  },
});

Pass that same metadata as custom_data when creating a transaction from your server. It contains a channel label, not a visitor ID. Missing attribution must stay missing. Web cohort and app install_id are mutually exclusive.

Verified webhook adapter

This Express example uses the official @paddle/paddle-node-sdk to validate the untouched body and its timestamp. Register this route before a global JSON body parser. See Paddle signature verification.

Install express and @paddle/paddle-node-sdk in your Node.js server. Configure PADDLE_API_KEY, PADDLE_WEBHOOK_SECRET, JELTO_KEY and JELTO_ORIGIN (your Jelto dashboard's HTTPS origin). Use a separate Jelto product for sandbox tests: the custom Payments API records submitted payments in its live dataset. Subscribe your Paddle notification destination to transaction.completed, adjustment.created and adjustment.updated.

import express from 'express';
import { Paddle } from '@paddle/paddle-node-sdk';
const app = express();
const paddle = new Paddle(process.env.PADDLE_API_KEY);

function decimal(minor, currency) {
  const digits = new Intl.NumberFormat('en', { style: 'currency', currency })
    .resolvedOptions().maximumFractionDigits;
  const value = BigInt(minor);
  const unit = 10n ** BigInt(digits);
  return digits ? `${value / unit}.${(value % unit).toString().padStart(digits, '0')}` : value.toString();
}

app.post('/webhooks/paddle', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
  let event;
  try {
    const raw = req.body.toString('utf8');
    await paddle.webhooks.unmarshal(raw, process.env.PADDLE_WEBHOOK_SECRET, req.get('Paddle-Signature') ?? '');
    event = JSON.parse(raw);
  } catch { return res.sendStatus(400); }
  const value = event.data;
  const purchase = event.event_type === 'transaction.completed';
  const refund = ['adjustment.created', 'adjustment.updated'].includes(event.event_type)
    && value.action === 'refund' && value.status === 'approved';
  if (!purchase && !refund) return res.sendStatus(200);
  try {
    const custom = value.custom_data ?? {};
    const payment = {
      transaction_id: value.id,
      occurred_at: event.occurred_at,
      currency: value.currency_code,
      amount: (refund ? '-' : '') + decimal(refund ? value.totals.total : value.details.totals.grand_total, value.currency_code),
      ...(refund ? { refund_of: value.transaction_id } : custom.jelto_cohort ? {
        cohort: custom.jelto_cohort, ...(custom.jelto_jt === 'first' ? { jt: 'first' } : {}),
      } : {}),
    };
    const result = await fetch(`${process.env.JELTO_ORIGIN}/api/v1/payments`, {
      method: 'POST', headers: { Authorization: `Bearer ${process.env.JELTO_KEY}`, 'Content-Type': 'application/json' },
      body: JSON.stringify(payment), signal: AbortSignal.timeout(5000),
    });
    if (!result.ok) return res.sendStatus(503);
    return res.sendStatus(200);
  } catch { return res.sendStatus(503); }
});
app.listen(3000);

Use a stable transaction ID for retries. New records return 201, repeated IDs return 200. Do not acknowledge a provider webhook before either Jelto accepts it or your own durable queue takes responsibility for retrying it. Investigate permanent validation failures in your server without logging payer data.

Decimal conversion uses the currency's minor-unit exponent and integer math: JPY has no decimal places. Sales use the transaction total after credits, including tax, rather than the pre-credit total or payout earnings. Refunds use the approved adjustment's ID, negative amount and original transaction ID in refund_of. A found original supplies refund attribution; a missing original stays unknown.

Verify and read revenue

Confirm a new payment returns 201 and its exact retry returns 200, then inspect the Revenue card over the event date. Revenue is already available in the dashboard. It uses historical ECB cross-rates to estimate USD on the monetary event's UTC date, preserving original amounts and currencies. Missing rates are labelled incomplete; estimates are not settlement or payout amounts. Historical provider imports without checkout attribution remain unknown. Current custom payments with missing or empty cohorts also show Unknown; some older payments may show Direct instead. Neither missing-context case establishes a direct website visit. API event times must be within the last 30 days or five minutes into the future.

Provider fields: completed transactions and approved adjustments.

Related: Website API, Browser attribution, Revenue attribution troubleshooting.