> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://docs.paymentkit.com/guides/integration/webhooks/llms.txt. > For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.paymentkit.com/_mcp/server. # 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 #### Dashboard 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 #### API ```bash curl -X POST https://app.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 } } } ``` > **Note** > > 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. `livemode` is `true` for events from a live account and `false` for events from a sandbox account, so your receiver can reject an environment mismatch. # 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. #### Node.js ```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 ```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"} ``` > **Note** > > Always use the raw request body for signature verification, not a parsed/serialized version. JSON serialization can change formatting and break verification. # Managing signing secrets Each webhook endpoint has a signing secret (format: `whsec_...`) used to verify incoming payloads. You can reveal the current secret, rotate it with a grace period, or use it immediately after creation. ## Reveal secret If you lose your signing secret, retrieve it without creating a new one: ```bash curl https://app.paymentkit.com/api/{account_id}/webhook-endpoints/{external_id}/reveal-secret \ -H "Authorization: Bearer st_prod_..." ``` The response includes the current `signing_secret`: ```json { "id": "whe_prod_a1b2c3d4e5f6g7h8", "url": "https://your-server.com/webhooks", "signing_secret": "whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", "is_active": true, "events": ["invoice.paid"] } ``` ## Roll secret Generate a new signing secret while keeping the old one valid during a configurable grace period. Use this for routine security rotation or when a secret may have been compromised. ```bash curl -X POST https://app.paymentkit.com/api/{account_id}/webhook-endpoints/{external_id}/roll-secret \ -H "Authorization: Bearer st_prod_..." \ -H "Content-Type: application/json" \ -d '{ "ttl_seconds": 3600 }' ``` The response contains the new `signing_secret`. Update your server to use the new secret before the grace period expires. | Parameter | Type | Default | Range | Description | | ------------- | ------- | ------- | ------------ | ---------------------------------------------------------------- | | `ttl_seconds` | integer | `3600` | `0`–`604800` | Seconds the old secret remains valid (0 = immediate, max 7 days) | > **Note** > > Roll your secret before the grace period expires and update your server to use the new `signing_secret` value returned in the response. # Endpoint lifecycle ## Blacklisting After consecutive delivery failures, PaymentKit automatically blacklists the endpoint and stops delivery attempts. A blacklisted endpoint has `is_blacklisted: true` and stops receiving events until reactivated. ## Reactivate a blacklisted endpoint After fixing the issues on your receiving server, reactivate the endpoint to resume delivery: ```bash curl -X POST https://app.paymentkit.com/api/{account_id}/webhook-endpoints/{external_id}/reactivate \ -H "Authorization: Bearer st_prod_..." ``` This clears the blacklist status (`is_blacklisted: false`) and resets the consecutive failure counter to zero. The endpoint immediately resumes receiving events. > **Warning** > > Fix the underlying server issue before reactivating. If the endpoint continues to fail after reactivation, it will be blacklisted again. # Event types ## Payment events | Event | Description | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `payment.succeeded` | Payment completed successfully | | `payment.failed` | Payment attempt failed | | `payment.refunded` | Payment was refunded | | `payment.refund_reversed` | A refund recorded against this payment was reversed because the processor confirmed it never moved money. The payment's refunded total is reduced by the reversed amount | ## 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.pause_scheduled` | Subscription pause has been scheduled | | `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.updated` | Invoice details changed | | `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.refund_reversed` | A refund that had been credited against this invoice was reversed because the processor confirmed it never moved money. The invoice's paid amount is restored | | `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` | A refund field changed. Not emitted for the transition to `failed`, and not reliably emitted for the transition to `succeeded` — reconcile settlements on `refund.succeeded` | | `refund.succeeded` | Refund settled successfully | | `refund.failed` | Refund failed | ## Upgrade path events | Event | Description | | ---------------------- | -------------------- | | `upgrade_path.created` | Upgrade path created | | `upgrade_path.updated` | Upgrade path updated | | `upgrade_path.deleted` | Upgrade path deleted | ## API request events | Event | Description | | ------------------- | -------------------------------- | | `api_request.error` | API request resulted in an error | # Retry behavior If your endpoint returns a server error (5xx status), times out, or is unreachable, PaymentKit automatically retries delivery: > **Note** > > 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. > **Tip** > > Respond to webhooks quickly (within 30 seconds) with a 2xx status. Process event data asynchronously to avoid timeouts. # Best practices #### Respond quickly Return a 2xx response immediately, then process the event asynchronously. #### Handle duplicates Use the event ID to deduplicate. Webhooks may be retried on failure, potentially delivering the same event multiple times. #### Verify signatures Always verify webhook signatures in production to prevent spoofing. #### Use HTTPS 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.