Skip to content

Paddle Integration

Receive and route Paddle Billing (v2) webhooks for transactions, subscriptions, customers, and other billing events. Paddle is a Merchant of Record — it handles tax calculation, compliance, and invoicing on your behalf.

Setup

1. Create a Source in Hookbase

bash
curl -X POST https://api.hookbase.app/api/sources \
  -H "Authorization: Bearer whr_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Paddle Production",
    "slug": "paddle",
    "verificationConfig": {
      "type": "hmac",
      "secret": "pdl_ntf_...",
      "algorithm": "sha256",
      "header": "Paddle-Signature",
      "encoding": "hex"
    }
  }'

Save your webhook URL:

https://api.hookbase.app/ingest/{orgSlug}/paddle

2. Configure Paddle Webhook

  1. Go to Paddle DashboardDeveloper ToolsNotifications
  2. Click New destination
  3. Enter your Hookbase webhook URL
  4. Select the events you want to receive
  5. Click Save
  6. Copy the Secret key (starts with pdl_ntf_)
  7. Update your Hookbase source with this secret

TIP

Paddle provides separate API keys for sandbox and live environments. Make sure you use the notification secret from the correct environment.

3. Create Routes

bash
# Create destination for your billing handler
curl -X POST https://api.hookbase.app/api/destinations \
  -H "Authorization: Bearer whr_your_api_key" \
  -d '{"name": "Billing Service", "url": "https://api.myapp.com/webhooks/paddle"}'

# Create route
curl -X POST https://api.hookbase.app/api/routes \
  -H "Authorization: Bearer whr_your_api_key" \
  -d '{"name": "Paddle to Billing Service", "sourceId": "src_...", "destinationIds": ["dst_..."]}'

Signature Verification

Paddle signs webhooks with HMAC-SHA256 using your notification secret key. The signature is sent in the Paddle-Signature header with the following format:

Paddle-Signature: ts=1671552777;h1=a]b2c3d4e5f6...

The header contains two components:

  • ts — Unix timestamp of when the notification was sent
  • h1 — HMAC-SHA256 signature computed over ts:request_body

Hookbase automatically verifies this when configured:

json
{
  "verificationConfig": {
    "type": "hmac",
    "secret": "pdl_ntf_...",
    "algorithm": "sha256",
    "header": "Paddle-Signature",
    "encoding": "hex"
  }
}

Timestamp Tolerance

Paddle signatures include a timestamp. Hookbase rejects webhooks older than 5 minutes by default to prevent replay attacks.

How Verification Works

  1. Hookbase extracts the ts and h1 values from the Paddle-Signature header
  2. Constructs the signed payload as ts:request_body
  3. Computes HMAC-SHA256 using your notification secret
  4. Compares the computed signature against h1

Common Events

transaction.completed

Triggered when a transaction is successfully completed (payment captured).

json
{
  "event_id": "evt_01hv8x3f2yqm5g0t4kwzr6jm8n",
  "event_type": "transaction.completed",
  "occurred_at": "2024-03-15T14:30:00.000Z",
  "notification_id": "ntf_01hv8x3f2yqm5g0t4kwzr6abc",
  "data": {
    "id": "txn_01hv8x3f2yqm5g0t4kwzr6def",
    "status": "completed",
    "customer_id": "ctm_01hv8x3f2yqm5g0t4kwzr6ghi",
    "currency_code": "USD",
    "items": [
      {
        "price": {
          "id": "pri_01hv8x3f2yqm5g0t4kwzr6jkl",
          "product_id": "pro_01hv8x3f2yqm5g0t4kwzr6mno",
          "unit_price": {
            "amount": "2999",
            "currency_code": "USD"
          },
          "tax_mode": "account_setting"
        },
        "quantity": 1
      }
    ],
    "details": {
      "totals": {
        "subtotal": "2999",
        "tax": "570",
        "total": "3569",
        "grand_total": "3569",
        "currency_code": "USD"
      },
      "tax_rates_used": [
        {
          "tax_rate": "0.19",
          "totals": {
            "subtotal": "2999",
            "tax": "570",
            "total": "3569"
          }
        }
      ]
    },
    "payments": [
      {
        "payment_method_id": "paymtd_01hv8x3f2yqm5g0t4kwzpqr",
        "amount": "3569",
        "status": "captured",
        "captured_at": "2024-03-15T14:30:00.000Z"
      }
    ],
    "created_at": "2024-03-15T14:28:00.000Z",
    "updated_at": "2024-03-15T14:30:00.000Z"
  }
}

subscription.created

Triggered when a new subscription is created.

json
{
  "event_id": "evt_01hv9a2b3c4d5e6f7g8h9i0j1k",
  "event_type": "subscription.created",
  "occurred_at": "2024-03-15T14:30:00.000Z",
  "notification_id": "ntf_01hv9a2b3c4d5e6f7g8h9i0abc",
  "data": {
    "id": "sub_01hv9a2b3c4d5e6f7g8h9i0def",
    "status": "active",
    "customer_id": "ctm_01hv8x3f2yqm5g0t4kwzr6ghi",
    "currency_code": "USD",
    "billing_cycle": {
      "interval": "month",
      "frequency": 1
    },
    "current_billing_period": {
      "starts_at": "2024-03-15T14:30:00.000Z",
      "ends_at": "2024-04-15T14:30:00.000Z"
    },
    "items": [
      {
        "price": {
          "id": "pri_01hv8x3f2yqm5g0t4kwzr6jkl",
          "product_id": "pro_01hv8x3f2yqm5g0t4kwzr6mno",
          "unit_price": {
            "amount": "2999",
            "currency_code": "USD"
          }
        },
        "quantity": 1,
        "status": "active"
      }
    ],
    "scheduled_change": null,
    "started_at": "2024-03-15T14:30:00.000Z",
    "created_at": "2024-03-15T14:30:00.000Z",
    "updated_at": "2024-03-15T14:30:00.000Z"
  }
}

subscription.canceled

Triggered when a subscription is canceled. The subscription remains active until the end of the current billing period.

json
{
  "event_id": "evt_01hv9b3c4d5e6f7g8h9i0j1k2l",
  "event_type": "subscription.canceled",
  "occurred_at": "2024-03-20T09:15:00.000Z",
  "notification_id": "ntf_01hv9b3c4d5e6f7g8h9i0j1abc",
  "data": {
    "id": "sub_01hv9a2b3c4d5e6f7g8h9i0def",
    "status": "canceled",
    "customer_id": "ctm_01hv8x3f2yqm5g0t4kwzr6ghi",
    "currency_code": "USD",
    "billing_cycle": {
      "interval": "month",
      "frequency": 1
    },
    "current_billing_period": {
      "starts_at": "2024-03-15T14:30:00.000Z",
      "ends_at": "2024-04-15T14:30:00.000Z"
    },
    "canceled_at": "2024-03-20T09:15:00.000Z",
    "scheduled_change": {
      "action": "cancel",
      "effective_at": "2024-04-15T14:30:00.000Z"
    },
    "created_at": "2024-03-15T14:30:00.000Z",
    "updated_at": "2024-03-20T09:15:00.000Z"
  }
}

transaction.payment_failed

Triggered when a payment attempt fails.

json
{
  "event_id": "evt_01hv9c4d5e6f7g8h9i0j1k2l3m",
  "event_type": "transaction.payment_failed",
  "occurred_at": "2024-03-15T14:35:00.000Z",
  "notification_id": "ntf_01hv9c4d5e6f7g8h9i0j1k2abc",
  "data": {
    "id": "txn_01hv9c4d5e6f7g8h9i0j1k2def",
    "status": "past_due",
    "customer_id": "ctm_01hv8x3f2yqm5g0t4kwzr6ghi",
    "currency_code": "USD",
    "items": [
      {
        "price": {
          "id": "pri_01hv8x3f2yqm5g0t4kwzr6jkl",
          "product_id": "pro_01hv8x3f2yqm5g0t4kwzr6mno",
          "unit_price": {
            "amount": "2999",
            "currency_code": "USD"
          }
        },
        "quantity": 1
      }
    ],
    "payments": [
      {
        "payment_method_id": "paymtd_01hv8x3f2yqm5g0t4kwzpqr",
        "amount": "3569",
        "status": "error",
        "error_code": "declined"
      }
    ],
    "created_at": "2024-03-15T14:28:00.000Z",
    "updated_at": "2024-03-15T14:35:00.000Z"
  }
}

subscription.past_due

Triggered when a subscription enters past-due status due to a failed payment.

json
{
  "event_id": "evt_01hv9d5e6f7g8h9i0j1k2l3m4n",
  "event_type": "subscription.past_due",
  "occurred_at": "2024-03-15T14:35:00.000Z",
  "notification_id": "ntf_01hv9d5e6f7g8h9i0j1k2l3abc",
  "data": {
    "id": "sub_01hv9a2b3c4d5e6f7g8h9i0def",
    "status": "past_due",
    "customer_id": "ctm_01hv8x3f2yqm5g0t4kwzr6ghi",
    "currency_code": "USD",
    "billing_cycle": {
      "interval": "month",
      "frequency": 1
    },
    "created_at": "2024-03-15T14:30:00.000Z",
    "updated_at": "2024-03-15T14:35:00.000Z"
  }
}

customer.created

Triggered when a new customer is created in Paddle.

json
{
  "event_id": "evt_01hv9e6f7g8h9i0j1k2l3m4n5o",
  "event_type": "customer.created",
  "occurred_at": "2024-03-15T14:25:00.000Z",
  "notification_id": "ntf_01hv9e6f7g8h9i0j1k2l3m4abc",
  "data": {
    "id": "ctm_01hv8x3f2yqm5g0t4kwzr6ghi",
    "email": "[email protected]",
    "name": "Jane Smith",
    "locale": "en",
    "marketing_consent": false,
    "status": "active",
    "created_at": "2024-03-15T14:25:00.000Z",
    "updated_at": "2024-03-15T14:25:00.000Z"
  }
}

Paddle Event Types

CategoryEvents
Transactionstransaction.billed, transaction.canceled, transaction.completed, transaction.created, transaction.paid, transaction.past_due, transaction.payment_failed, transaction.ready, transaction.updated
Subscriptionssubscription.activated, subscription.canceled, subscription.created, subscription.imported, subscription.past_due, subscription.paused, subscription.resumed, subscription.trialing, subscription.updated
Customerscustomer.created, customer.imported, customer.updated
Addressesaddress.created, address.imported, address.updated
Adjustmentsadjustment.created, adjustment.updated
Discountsdiscount.created, discount.imported, discount.updated
Payoutspayout.created, payout.paid
Reportsreport.created, report.updated

Transform Examples

Slack Alert for New Subscription

javascript
function transform(payload) {
  const sub = payload.data;
  const item = sub.items?.[0];
  const price = item?.price?.unit_price;
  const amount = price ? (parseInt(price.amount) / 100).toFixed(2) : '0.00';
  const currency = price?.currency_code || sub.currency_code || 'USD';
  const interval = sub.billing_cycle
    ? `${sub.billing_cycle.frequency} ${sub.billing_cycle.interval}`
    : 'one-time';

  return {
    blocks: [
      {
        type: "header",
        text: {
          type: "plain_text",
          text: "New Paddle Subscription"
        }
      },
      {
        type: "section",
        fields: [
          {
            type: "mrkdwn",
            text: `*Amount:*\n${currency} ${amount}`
          },
          {
            type: "mrkdwn",
            text: `*Billing Cycle:*\n${interval}`
          },
          {
            type: "mrkdwn",
            text: `*Customer:*\n${sub.customer_id}`
          },
          {
            type: "mrkdwn",
            text: `*Status:*\n${sub.status}`
          }
        ]
      },
      {
        type: "context",
        elements: [
          {
            type: "mrkdwn",
            text: `Subscription ID: ${sub.id} | Event: ${payload.event_type}`
          }
        ]
      }
    ]
  };
}

Database Format

javascript
function transform(payload) {
  const data = payload.data;
  const item = data.items?.[0];
  const price = item?.price?.unit_price;

  return {
    paddle_event_id: payload.event_id,
    event_type: payload.event_type,
    object_id: data.id,
    customer_id: data.customer_id,
    status: data.status,
    currency_code: data.currency_code,
    amount: price ? parseInt(price.amount) : null,
    product_id: item?.price?.product_id || null,
    price_id: item?.price?.id || null,
    billing_interval: data.billing_cycle?.interval || null,
    billing_frequency: data.billing_cycle?.frequency || null,
    occurred_at: payload.occurred_at,
    created_at: data.created_at,
    updated_at: data.updated_at
  };
}

Alert for Failed Payments

javascript
function transform(payload) {
  const txn = payload.data;
  const payment = txn.payments?.[0];
  const item = txn.items?.[0];
  const price = item?.price?.unit_price;
  const amount = price ? (parseInt(price.amount) / 100).toFixed(2) : '0.00';

  return {
    channel: "#billing-alerts",
    username: "Paddle Bot",
    icon_emoji: ":warning:",
    attachments: [{
      color: "danger",
      title: "Payment Failed",
      fields: [
        { title: "Customer", value: txn.customer_id, short: true },
        { title: "Amount", value: `$${amount} ${txn.currency_code}`, short: true },
        { title: "Error", value: payment?.error_code || "Unknown", short: true },
        { title: "Transaction", value: txn.id, short: true }
      ],
      footer: `Event: ${payload.event_id}`,
      ts: Math.floor(new Date(payload.occurred_at).getTime() / 1000)
    }]
  };
}

Subscription Cancellation Alert

javascript
function transform(payload) {
  const sub = payload.data;
  const effectiveAt = sub.scheduled_change?.effective_at;

  return {
    blocks: [
      {
        type: "header",
        text: {
          type: "plain_text",
          text: "Subscription Canceled"
        }
      },
      {
        type: "section",
        fields: [
          {
            type: "mrkdwn",
            text: `*Customer:*\n${sub.customer_id}`
          },
          {
            type: "mrkdwn",
            text: `*Subscription:*\n${sub.id}`
          },
          {
            type: "mrkdwn",
            text: `*Canceled At:*\n${sub.canceled_at}`
          },
          {
            type: "mrkdwn",
            text: `*Effective:*\n${effectiveAt || 'Immediately'}`
          }
        ]
      }
    ]
  };
}

Filter Examples

Transaction Events Only

json
{
  "name": "Transaction Events",
  "logic": "and",
  "conditions": [
    {
      "field": "event_type",
      "operator": "starts_with",
      "value": "transaction"
    }
  ]
}

Subscription Events

json
{
  "name": "Subscription Events",
  "logic": "and",
  "conditions": [
    {
      "field": "event_type",
      "operator": "starts_with",
      "value": "subscription"
    }
  ]
}

Failed Payments and Past Due

json
{
  "name": "Payment Issues",
  "logic": "or",
  "conditions": [
    {
      "field": "event_type",
      "operator": "equals",
      "value": "transaction.payment_failed"
    },
    {
      "field": "event_type",
      "operator": "equals",
      "value": "subscription.past_due"
    }
  ]
}

High-Value Transactions ($100+)

json
{
  "name": "High Value ($100+)",
  "logic": "and",
  "conditions": [
    {
      "field": "event_type",
      "operator": "equals",
      "value": "transaction.completed"
    },
    {
      "field": "data.details.totals.grand_total",
      "operator": "greater_than",
      "value": "10000"
    }
  ]
}

Headers

Paddle sends these headers with webhook notifications:

HeaderDescription
Paddle-SignatureHMAC-SHA256 signature with timestamp (ts=...;h1=...)
Content-TypeAlways application/json
User-AgentPaddle user agent string

Testing

Using Paddle Sandbox

Paddle provides a full sandbox environment for testing:

  1. Create a sandbox account at sandbox-vendors.paddle.com
  2. Set up a separate Hookbase source for sandbox webhooks:
bash
curl -X POST https://api.hookbase.app/api/sources \
  -H "Authorization: Bearer whr_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Paddle Sandbox",
    "slug": "paddle-sandbox",
    "verificationConfig": {
      "type": "hmac",
      "secret": "pdl_ntf_sandbox_...",
      "algorithm": "sha256",
      "header": "Paddle-Signature",
      "encoding": "hex"
    }
  }'
  1. Configure webhook notifications in the sandbox dashboard
  2. Use Paddle's test cards to trigger events

Simulate Notifications

Use the Paddle API to replay or simulate notifications:

bash
curl -X POST https://sandbox-api.paddle.com/notifications \
  -H "Authorization: Bearer your_paddle_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "notification_setting_id": "ntfset_01hv...",
    "type": "transaction.completed",
    "filter": "txn_01hv..."
  }'

Best Practices

  1. Separate sandbox and live: Use different sources for sandbox and production webhooks

  2. Handle idempotency: Use event_id to prevent duplicate processing — Paddle may retry failed deliveries

  3. Track notification IDs: Store notification_id alongside event_id for debugging with Paddle support

  4. Use scheduled changes: When a subscription is canceled, check scheduled_change.effective_at to know when access should end

  5. Handle tax data: Paddle includes full tax breakdowns — store these for your financial records

  6. Monitor past-due subscriptions: Set up alerts for subscription.past_due to proactively reach out to customers

  7. Validate amounts: Paddle amounts are in the smallest currency unit (e.g., cents) — divide by 100 for display

Troubleshooting

Signature Verification Failed

  1. Verify you're using the notification secret (starts with pdl_ntf_)
  2. Don't use the Paddle API key instead of the notification secret
  3. Use the correct secret for the environment (sandbox vs live)
  4. Ensure the verification config uses "algorithm": "sha256" and "encoding": "hex"

Missing Events

  1. Check Developer Tools → Notifications in the Paddle dashboard for delivery status
  2. Verify the notification destination is active and not paused
  3. Confirm the events you need are selected in the notification settings
  4. Check filters aren't blocking expected events in Hookbase

Duplicate Events

  1. Paddle retries failed deliveries up to several times
  2. Implement idempotency using event_id
  3. Enable deduplication using the event ID:
bash
curl -X PATCH .../sources/src_paddle \
  -d '{"dedupEnabled": true, "dedupStrategy": "provider_id"}'

Sandbox vs Live Confusion

  1. Sandbox and live environments have different API keys and secrets
  2. Sandbox webhook URLs: Use a separate Hookbase source with a distinct slug (e.g., paddle-sandbox)
  3. Sandbox events come from sandbox-api.paddle.com, live events from api.paddle.com

Released under the MIT License.