# Connect Paddle Billing

Create licenses automatically from Paddle Billing transactions, subscriptions and refunds.

Paddle Billing handles checkout, taxes and invoices but has no license keys. Once connected, Keyrail creates, renews and revokes licenses from Paddle’s webhooks, and emails the key to your customer.

## 1. Create the integration

In the dashboard, open **Integrations** in the sidebar and click **Connect provider**. Choose **Paddle Billing**, the product, and the **Environment** (**Sandbox (test payments)** to start, **Live** for real sales), then **Connect**. The integration then shows its **Webhook URL**, the **Webhook secret** field and the **Price map** ([dashboard guide](/docs/dashboard/#integrations)). Or with the [vendor API](/docs/vendor-api/#integrations):

```bash
curl https://api.keyrail.tech/v1/integrations \
  -H "Authorization: Bearer $KEYRAIL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "provider": "paddle",
    "product": "prd_7k2m9q4x1c8d3f6h0j5n",
    "environment": "sandbox",
    "priceMap": {
      "pri_01hv8x2monthly000000000000": "pro",
      "pri_01hv8x2yearly0000000000000": "pro",
      "pri_01hv8x2lifetime00000000000": "lifetime"
    }
  }'
```

The response contains a `webhookUrl` of the form `https://api.keyrail.tech/v1/webhooks/paddle/int_…`. Keep this page open.

In the dashboard, map prices in the integration’s **Price map**: **Add price**, paste the Paddle price ID, pick the plan, then **Save price map**.

`priceMap` maps each Paddle **price ID** (`pri_…`) to the **code** of one of your plans. Several prices can point to the same plan (monthly and yearly, different currencies). `environment` is `sandbox` or `live`: use one integration per Paddle environment.

## 2. Add a notification destination in Paddle

In Paddle, go to **Developer tools → Notifications → New destination**:

- **Type**: webhook. **URL**: the `webhookUrl` from step 1.
- **Events**: select
  - `customer.created`, `customer.updated`
  - `transaction.completed`
  - `subscription.updated`, `subscription.past_due`, `subscription.canceled`, `subscription.paused`, `subscription.resumed`, `subscription.activated`
  - `adjustment.created`, `adjustment.updated`

Save, then copy the destination’s **secret key** and store it in Keyrail: paste it in the integration’s **Webhook secret** field and click **Save secret**, or with the API:

```bash
curl -X PATCH https://api.keyrail.tech/v1/integrations/int_4w8d2k6m0p3r7t1v5x9z \
  -H "Authorization: Bearer $KEYRAIL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "webhookSecret": "pdl_ntfset_…" }'
```

The secret is write-only: no API returns it.

## 3. Customer emails (and the optional API key)

Paddle’s transaction and subscription events carry a `customer_id`, not the customer’s email. Keyrail learns the email (and name) from the `customer.created` and `customer.updated` events, which is why they must be enabled.

Paddle does not guarantee delivery order. If a `transaction.completed` arrives before Keyrail knows its customer, it answers `500` and Paddle retries a little later, once `customer.created` has been processed. The license is created on that retry.

To avoid the wait, you can give Keyrail a Paddle API key with the read-only **customer.read** permission. Keyrail then fetches the unknown customer from the Paddle API right away. The key is write-only and stored encrypted; responses only show `hasApiKey: true`. In the dashboard, paste it in the integration’s **Paddle API key** field and click **Save key**; or with the API:

```bash
curl -X PATCH https://api.keyrail.tech/v1/integrations/int_4w8d2k6m0p3r7t1v5x9z   -H "Authorization: Bearer $KEYRAIL_SECRET_KEY"   -H "Content-Type: application/json"   -d '{ "apiKey": "pdl_sdbx_apikey_…" }'
```

## 4. Test with the Paddle sandbox

Use a sandbox checkout with a test card. Within seconds, the license appears in **Licenses**, the webhook appears in **Events** and in the license’s **History**, and the customer receives the key by email. Nothing happened? See [no license after a payment](/docs/troubleshooting/#no-license-after-a-payment). Before real sales, go through the [going-live checklist](/docs/going-live/).

## What each event does

| Paddle event | Effect on the license |
|---|---|
| `customer.created`, `customer.updated` | Create or update the customer (email, name) linked to the Paddle `customer_id`. No license change. |
| `transaction.completed` with a subscription | Find or create the license linked to the `subscription_id`; `paidUntil` = `billing_period.ends_at`; status `active`; seats = item quantity. |
| `transaction.completed` without subscription | One-time purchase: a `perpetual` license. |
| `subscription.activated`, `subscription.resumed` | Status `active`. |
| `subscription.past_due` | Status `past_due`. |
| `subscription.canceled` | Status `canceled` (usable until `validUntil`). |
| `subscription.paused` | Status `suspended`. |
| `subscription.updated` | Status follows the subscription’s status, with the same mapping. Dates are not changed. |
| `adjustment.created` / `adjustment.updated`, `action = refund`, `status = approved`, full amount | Status `refunded`. |
| `adjustment.created` / `adjustment.updated`, `action = chargeback` | Status `revoked`. |

Subscription events older than the license’s last processed event are ignored, so out-of-order delivery is safe. Partial refunds don’t change the license.

The customer is found through the Paddle `customer_id` (see [customer emails](#3-customer-emails-and-the-optional-api-key)), then matched by email within your account, or created. One customer with one subscription to a plan has one license: re-subscribing after expiry reuses the same license and key.

## Signature verification

Keyrail checks the `Paddle-Signature` header (`ts=…;h1=…`): `h1` must equal the HMAC-SHA256 of `ts:rawBody` with your secret, and `ts` must be within 5 minutes. Anything else gets `401`. Details in [webhooks](/docs/webhooks/).
