# Webhooks > Webhooks notify your application in real-time when events occur in PaymentKit. Use webhooks to trigger business logic, sync data, and keep your systems up to date. # How webhooks work When events occur (like a payment succeeding or subscription being created), PaymentKit sends an HTTP POST request to your configured endpoint with details about the event. ```mermaid sequenceDiagram participant PaymentKit participant WebhookDelivery as Webhook Delivery participant YourServer as Your Server PaymentKit->>WebhookDelivery: Event occurs (e.g., payment) WebhookDelivery->>YourServer: POST request with payload YourServer->>YourServer: Process event & Update systems ``` # Setting up webhooks 1. Navigate to **Developers > Webhooks** 2. Click **Add Endpoint** 3. Enter your endpoint URL (must be HTTPS) 4. Select the events you want to receive 5. Copy your signing secret for verification ```bash curl -X POST https://api.paymentkit.com/api/{account_id}/webhook-endpoints \ -H "Authorization: Bearer st_prod_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-server.com/webhooks", "events": ["invoice.paid", "customer.subscription.created"] }' ``` # Webhook payload Webhooks are delivered as JSON with a Stripe-compatible format: ```json { "id": "evt_prod_a1b2c3d4e5f6g7h8", "object": "event", "type": "invoice.paid", "created": 1704067200, "livemode": true, "data": { "object": { "id": "in_prod_a1b2c3d4e5f6g7h8", "status": "paid", "currency": "usd", "total_amount_atom": 290000, "due_amount_atom": 0, "paid_amount_atom": 290000 }, "previous_attributes": { "status": "open", "due_amount_atom": 290000, "paid_amount_atom": 0 } } } ``` The `data.object` field contains the full entity state at the time of the event. The example above is simplified; actual payloads include all entity fields. Amount fields use atomic units (e.g., cents for USD) with the `_atom` suffix. The `previous_attributes` field contains the previous state for update events, showing fields that changed. # Webhook headers Each webhook request includes headers for verification and debugging: | Header | Description | | ----------------------- | ---------------------------------- | | `Content-Type` | `application/json` | | `X-Webhook-Signature` | HMAC signature for verification | | `X-Webhook-Event-Id` | Unique event identifier | | `X-Webhook-Event-Type` | Event type (e.g., `invoice.paid`) | | `X-Webhook-Delivery-Id` | Unique delivery attempt identifier | # Verifying signatures Always verify webhook signatures to ensure requests are from PaymentKit. The signature is an HMAC-SHA256 hash of the raw request body using your endpoint's signing secret. ```javascript const crypto = require('crypto'); function verifyWebhook(payload, signature, secret) { // Signature format is "sha256=" const receivedHash = signature.replace('sha256=', ''); const expected = crypto .createHmac('sha256', secret) .update(payload, 'utf8') .digest('hex'); // Use timing-safe comparison return crypto.timingSafeEquals( Buffer.from(expected), Buffer.from(receivedHash) ); } // In your webhook handler app.post('/webhooks', (req, res) => { const signature = req.headers['x-webhook-signature']; const rawBody = req.rawBody; // Ensure you capture raw body if (!verifyWebhook(rawBody, signature, WEBHOOK_SECRET)) { return res.status(401).send('Invalid signature'); } const event = JSON.parse(rawBody); // Process event... res.status(200).send('OK'); }); ``` ```python import hmac import hashlib def verify_webhook(payload: bytes, signature: str, secret: str) -> bool: # Signature format is "sha256=" received_hash = signature.replace('sha256=', '') expected = hmac.new( secret.encode('utf-8'), payload, hashlib.sha256 ).hexdigest() # Use constant-time comparison to prevent timing attacks return hmac.compare_digest(expected, received_hash) # In your webhook handler @app.post("/webhooks") async def handle_webhook(request: Request): payload = await request.body() signature = request.headers.get("x-webhook-signature") if not verify_webhook(payload, signature, WEBHOOK_SECRET): raise HTTPException(status_code=401, detail="Invalid signature") event = json.loads(payload) # Process event... return {"status": "ok"} ``` Always use the raw request body for signature verification, not a parsed/serialized version. JSON serialization can change formatting and break verification. # Event types ## Payment events | Event | Description | | ------------------- | ------------------------------ | | `payment.succeeded` | Payment completed successfully | | `payment.failed` | Payment attempt failed | | `payment.refunded` | Payment was refunded | ## Checkout session events | Event | Description | | ---------------------------- | --------------------------------------- | | `checkout.session.created` | Checkout session created | | `checkout.session.updated` | Checkout session updated | | `checkout.session.deleted` | Checkout session deleted | | `checkout.session.completed` | Checkout session completed successfully | | `checkout.session.expired` | Checkout session expired | ## Payment intent events | Event | Description | | ------------------------------------------ | ----------------------------------------- | | `payment_intent.created` | Payment intent created | | `payment_intent.processing` | Payment intent is processing | | `payment_intent.requires_action` | Payment intent requires additional action | | `payment_intent.amount_capturable_updated` | Capturable amount updated | | `payment_intent.succeeded` | Payment intent succeeded | | `payment_intent.canceled` | Payment intent was canceled | | `payment_intent.payment_failed` | Payment intent payment failed | | `payment_intent.partially_funded` | Payment intent was partially funded | ## Subscription events | Event | Description | | -------------------------------------- | ---------------------------- | | `customer.subscription.created` | New subscription created | | `customer.subscription.updated` | Subscription details changed | | `customer.subscription.paused` | Subscription was paused | | `customer.subscription.resumed` | Subscription was resumed | | `customer.subscription.cancelled` | Subscription was cancelled | | `customer.subscription.trial_will_end` | Trial period ending soon | ## Invoice events | Event | Description | | --------------------------------- | ------------------------------------------ | | `invoice.created` | New invoice created | | `invoice.finalized` | Invoice finalized and ready for payment | | `invoice.paid` | Invoice payment succeeded | | `invoice.payment_succeeded` | Invoice payment succeeded | | `invoice.payment_failed` | Invoice payment failed | | `invoice.payment_action_required` | Invoice payment requires additional action | | `invoice.overdue` | Invoice is overdue | | `invoice.voided` | Invoice was voided | | `invoice.marked_uncollectible` | Invoice marked as uncollectible | | `invoice.refunded` | Invoice was refunded | | `invoice.upcoming` | Upcoming invoice notification | ## Customer events | Event | Description | | ------------------ | ------------------------ | | `customer.created` | New customer created | | `customer.updated` | Customer details changed | | `customer.deleted` | Customer was deleted | ## Customer discount events | Event | Description | | --------------------------- | ------------------------- | | `customer.discount.created` | Customer discount created | | `customer.discount.updated` | Customer discount updated | | `customer.discount.deleted` | Customer discount deleted | ## Coupon events | Event | Description | | ---------------- | -------------- | | `coupon.created` | Coupon created | | `coupon.updated` | Coupon updated | | `coupon.deleted` | Coupon deleted | ## Price events | Event | Description | | --------------- | ------------- | | `price.created` | Price created | | `price.updated` | Price updated | | `price.deleted` | Price deleted | ## Product events | Event | Description | | ----------------- | --------------- | | `product.created` | Product created | | `product.updated` | Product updated | | `product.deleted` | Product deleted | ## Refund events | Event | Description | | ---------------- | -------------- | | `refund.created` | Refund created | | `refund.updated` | Refund updated | | `refund.failed` | Refund failed | # Retry behavior If your endpoint returns a server error (5xx status), times out, or is unreachable, PaymentKit automatically retries delivery: Client errors (4xx status codes) are treated as permanent failures and will not be retried. Ensure your endpoint returns a 2xx status for successful receipt. | Retry | Delay | | ----- | ---------- | | 1 | 1 minute | | 2 | 5 minutes | | 3 | 30 minutes | | 4 | 2 hours | | 5 | 24 hours | After 5 failed attempts, the delivery is marked as permanently failed. You can manually retry from the dashboard. Respond to webhooks quickly (within 30 seconds) with a 2xx status. Process event data asynchronously to avoid timeouts. # Best practices Return a 2xx response immediately, then process the event asynchronously. Use the event ID to deduplicate. Webhooks may be retried on failure, potentially delivering the same event multiple times. Always verify webhook signatures in production to prevent spoofing. Webhook endpoints must use HTTPS. PaymentKit rejects HTTP URLs. # Testing webhooks You can use tools like [ngrok](https://ngrok.com) to expose your local development server for webhook testing. This allows you to receive real webhook events during development. # Monitoring deliveries Track webhook delivery status in the dashboard under **Events**: * **delivered** - Successfully received (2xx response) * **pending** - Scheduled for initial delivery * **delivering** - Currently being delivered * **failed** - Delivery failed (will retry if attempts remain, or permanent failure if retries exhausted) View delivery details including attempt count, response status, error messages, and retry timing.