113 lines
2.6 KiB
Markdown
113 lines
2.6 KiB
Markdown
# Paddle Webhooks
|
|
|
|
Event-driven notifications for payment lifecycle.
|
|
|
|
## Setup
|
|
|
|
1. Dashboard → Developer Tools → Notifications
|
|
2. Create new destination with endpoint URL
|
|
3. Select events to receive
|
|
4. Copy signing secret
|
|
|
|
## Signature Verification
|
|
|
|
Header: `Paddle-Signature`
|
|
Format: `ts=1234567890;h1=sha256_signature`
|
|
|
|
### Node.js SDK
|
|
|
|
```typescript
|
|
import Paddle from '@paddle/paddle-node-sdk';
|
|
|
|
const paddle = new Paddle(process.env.PADDLE_API_KEY);
|
|
|
|
app.post('/webhooks/paddle', async (req, res) => {
|
|
const signature = req.headers['paddle-signature'];
|
|
const rawBody = req.body; // raw request body string
|
|
|
|
try {
|
|
const event = paddle.webhooks.unmarshal(
|
|
rawBody,
|
|
process.env.PADDLE_WEBHOOK_SECRET,
|
|
signature
|
|
);
|
|
|
|
await handleEvent(event);
|
|
res.status(200).send('OK');
|
|
} catch (err) {
|
|
res.status(400).send('Invalid signature');
|
|
}
|
|
});
|
|
```
|
|
|
|
### Manual Verification
|
|
|
|
```typescript
|
|
import crypto from 'crypto';
|
|
|
|
function verifyPaddleWebhook(
|
|
rawBody: string,
|
|
signature: string,
|
|
secret: string
|
|
): boolean {
|
|
const [tsPart, h1Part] = signature.split(';');
|
|
const ts = tsPart.replace('ts=', '');
|
|
const h1 = h1Part.replace('h1=', '');
|
|
|
|
const signedPayload = `${ts}:${rawBody}`;
|
|
const expectedSig = crypto
|
|
.createHmac('sha256', secret)
|
|
.update(signedPayload)
|
|
.digest('hex');
|
|
|
|
return crypto.timingSafeEqual(
|
|
Buffer.from(h1),
|
|
Buffer.from(expectedSig)
|
|
);
|
|
}
|
|
```
|
|
|
|
## Key Events
|
|
|
|
| Event | Description |
|
|
|-------|-------------|
|
|
| `transaction.completed` | Payment successful |
|
|
| `transaction.payment_failed` | Payment failed |
|
|
| `subscription.created` | New subscription |
|
|
| `subscription.updated` | Subscription changed |
|
|
| `subscription.canceled` | Subscription canceled |
|
|
| `subscription.past_due` | Payment overdue |
|
|
| `subscription.paused` | Subscription paused |
|
|
| `subscription.resumed` | Subscription resumed |
|
|
| `customer.created` | New customer |
|
|
| `customer.updated` | Customer updated |
|
|
|
|
## Event Payload
|
|
|
|
```json
|
|
{
|
|
"event_id": "evt_xxx",
|
|
"event_type": "subscription.created",
|
|
"occurred_at": "2024-01-15T10:00:00Z",
|
|
"notification_id": "ntf_xxx",
|
|
"data": {
|
|
"id": "sub_xxx",
|
|
"status": "active",
|
|
"customer_id": "ctm_xxx",
|
|
"items": [{ "price": { "id": "pri_xxx" }, "quantity": 1 }],
|
|
"billing_cycle": { "interval": "month", "frequency": 1 },
|
|
"current_billing_period": {
|
|
"starts_at": "2024-01-15",
|
|
"ends_at": "2024-02-15"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
- Store `event_id` for idempotency
|
|
- Return 200 immediately, process async
|
|
- Implement retry handling (Paddle retries failed deliveries)
|
|
- Use webhook secret per environment
|