webhook-integration by dodopayments/skills
npx skills add https://github.com/dodopayments/skills --skill webhook-integration参考文档:docs.dodopayments.com/developer-resources/webhooks
Webhook 在支付事件发生时提供实时通知。使用它们来自动化工作流、更新数据库、发送通知并保持系统同步。
DODO_PAYMENTS_WEBHOOK_SECRET=your_webhook_secret_here
| 事件 | 描述 |
|---|---|
payment.succeeded | 支付成功完成 |
payment.failed |
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
| 支付尝试失败 |
payment.processing | 支付正在处理中 |
payment.cancelled | 支付已取消 |
| 事件 | 描述 |
|---|---|
subscription.active | 订阅现已激活 |
subscription.updated | 订阅详情已更改 |
subscription.on_hold | 订阅暂停(续订失败) |
subscription.renewed | 订阅成功续订 |
subscription.plan_changed | 计划已升级/降级 |
subscription.cancelled | 订阅已取消 |
subscription.failed | 订阅创建失败 |
subscription.expired | 订阅期限已结束 |
| 事件 | 描述 |
|---|---|
refund.succeeded | 退款处理成功 |
dispute.opened | 收到新的争议 |
license_key.created | 许可证密钥已生成 |
| 事件 | 描述 |
|---|---|
credit.added | 向客户授予信用额度(通过订阅、一次性购买或 API) |
credit.deducted | 通过使用或手动扣款消耗信用额度 |
credit.expired | 未使用的信用额度在配置期限后过期 |
credit.rolled_over | 未使用的信用额度在周期结束时结转 |
credit.rollover_forfeited | 达到最大结转次数时没收的信用额度 |
credit.overage_charged | 在余额为零后应用的超额费用 |
credit.manual_adjustment | 通过仪表板或 API 手动调整信用额度/扣款 |
credit.balance_low | 信用额度余额低于配置的阈值 |
POST /your-webhook-url
Content-Type: application/json
webhook-id: evt_xxxxx
webhook-signature: v1,signature_here
webhook-timestamp: 1234567890
{
"business_id": "bus_xxxxx",
"type": "payment.succeeded",
"timestamp": "2024-01-01T12:00:00Z",
"data": {
"payload_type": "Payment",
"payment_id": "pay_xxxxx",
"total_amount": 2999,
"currency": "USD",
"customer": {
"customer_id": "cust_xxxxx",
"email": "customer@example.com",
"name": "John Doe"
}
// ... 其他事件特定字段
}
}
// app/api/webhooks/dodo/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
const WEBHOOK_SECRET = process.env.DODO_PAYMENTS_WEBHOOK_SECRET!;
function verifySignature(payload: string, signature: string, timestamp: string): boolean {
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('base64');
// Extract signature from "v1,signature" format
const providedSig = signature.split(',')[1];
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(providedSig || '')
);
}
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('webhook-signature') || '';
const timestamp = req.headers.get('webhook-timestamp') || '';
const webhookId = req.headers.get('webhook-id');
// Verify signature
if (!verifySignature(body, signature, timestamp)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
// Check timestamp to prevent replay attacks (5 minute tolerance)
const eventTime = parseInt(timestamp) * 1000;
if (Math.abs(Date.now() - eventTime) > 300000) {
return NextResponse.json({ error: 'Timestamp too old' }, { status: 401 });
}
const event = JSON.parse(body);
// Handle events
switch (event.type) {
case 'payment.succeeded':
await handlePaymentSucceeded(event.data);
break;
case 'payment.failed':
await handlePaymentFailed(event.data);
break;
case 'subscription.active':
await handleSubscriptionActive(event.data);
break;
case 'subscription.cancelled':
await handleSubscriptionCancelled(event.data);
break;
case 'refund.succeeded':
await handleRefundSucceeded(event.data);
break;
case 'dispute.opened':
await handleDisputeOpened(event.data);
break;
case 'license_key.created':
await handleLicenseKeyCreated(event.data);
break;
case 'credit.added':
await handleCreditAdded(event.data);
break;
case 'credit.deducted':
await handleCreditDeducted(event.data);
break;
case 'credit.balance_low':
await handleCreditBalanceLow(event.data);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
}
async function handlePaymentSucceeded(data: any) {
const { payment_id, customer, total_amount, product_id, subscription_id } = data;
// Update database
// Send confirmation email
// Grant access to product
console.log(`Payment ${payment_id} succeeded for ${customer.email}`);
}
async function handlePaymentFailed(data: any) {
const { payment_id, customer, error_message } = data;
// Log failure
// Notify customer
// Update UI state
console.log(`Payment ${payment_id} failed: ${error_message}`);
}
async function handleSubscriptionActive(data: any) {
const { subscription_id, customer, product_id, next_billing_date } = data;
// Grant subscription access
// Update user record
// Send welcome email
console.log(`Subscription ${subscription_id} activated for ${customer.email}`);
}
async function handleSubscriptionCancelled(data: any) {
const { subscription_id, customer, cancelled_at, cancel_at_next_billing_date } = data;
// Schedule access revocation
// Send cancellation confirmation
console.log(`Subscription ${subscription_id} cancelled`);
}
async function handleRefundSucceeded(data: any) {
const { refund_id, payment_id, amount } = data;
// Update order status
// Revoke access if needed
console.log(`Refund ${refund_id} processed for payment ${payment_id}`);
}
async function handleDisputeOpened(data: any) {
const { dispute_id, payment_id, amount, dispute_status } = data;
// Alert team
// Prepare evidence
console.log(`Dispute ${dispute_id} opened for payment ${payment_id}`);
}
async function handleLicenseKeyCreated(data: any) {
const { id, key, product_id, customer_id, expires_at } = data;
// Store license key
// Send to customer
console.log(`License key created: ${key.substring(0, 8)}...`);
}
async function handleCreditAdded(data: any) {
const { customer_id, credit_entitlement_id, amount, balance_after } = data;
// Update internal credit balance
// Log credit grant
console.log(`${amount} credits added for customer ${customer_id}, balance: ${balance_after}`);
}
async function handleCreditDeducted(data: any) {
const { customer_id, credit_entitlement_id, amount, balance_after } = data;
// Update internal credit balance
// Check if balance is getting low
console.log(`${amount} credits deducted for customer ${customer_id}, balance: ${balance_after}`);
}
async function handleCreditBalanceLow(data: any) {
const { customer_id, credit_entitlement_name, available_balance, threshold_percent } = data;
// Notify customer about low balance
// Suggest upgrading plan or purchasing more credits
console.log(`Low balance alert: ${available_balance} ${credit_entitlement_name} remaining for ${customer_id}`);
}
import express from 'express';
import crypto from 'crypto';
const app = express();
const WEBHOOK_SECRET = process.env.DODO_PAYMENTS_WEBHOOK_SECRET!;
// Use raw body for signature verification
app.post('/webhooks/dodo',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['webhook-signature'] as string;
const timestamp = req.headers['webhook-timestamp'] as string;
const payload = req.body.toString();
// Verify signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSig = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('base64');
const providedSig = signature?.split(',')[1];
if (!providedSig || !crypto.timingSafeEqual(
Buffer.from(expectedSig),
Buffer.from(providedSig)
)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(payload);
// Process event
try {
switch (event.type) {
case 'payment.succeeded':
await processPayment(event.data);
break;
case 'subscription.active':
await activateSubscription(event.data);
break;
// ... handle other events
}
res.json({ received: true });
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).json({ error: 'Processing failed' });
}
}
);
from fastapi import FastAPI, Request, HTTPException
import hmac
import hashlib
import base64
import time
app = FastAPI()
WEBHOOK_SECRET = os.environ["DODO_PAYMENTS_WEBHOOK_SECRET"]
def verify_signature(payload: bytes, signature: str, timestamp: str) -> bool:
signed_payload = f"{timestamp}.{payload.decode()}"
expected_sig = base64.b64encode(
hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload.encode(),
hashlib.sha256
).digest()
).decode()
provided_sig = signature.split(',')[1] if ',' in signature else ''
return hmac.compare_digest(expected_sig, provided_sig)
@app.post("/webhooks/dodo")
async def handle_webhook(request: Request):
body = await request.body()
signature = request.headers.get("webhook-signature", "")
timestamp = request.headers.get("webhook-timestamp", "")
if not verify_signature(body, signature, timestamp):
raise HTTPException(status_code=401, detail="Invalid signature")
# Check timestamp freshness
event_time = int(timestamp)
if abs(time.time() - event_time) > 300:
raise HTTPException(status_code=401, detail="Timestamp too old")
event = json.loads(body)
if event["type"] == "payment.succeeded":
await handle_payment_succeeded(event["data"])
elif event["type"] == "subscription.active":
await handle_subscription_active(event["data"])
# ... handle other events
return {"received": True}
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var webhookSecret = os.Getenv("DODO_PAYMENTS_WEBHOOK_SECRET")
func verifySignature(payload []byte, signature, timestamp string) bool {
signedPayload := timestamp + "." + string(payload)
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(signedPayload))
expectedSig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
parts := strings.Split(signature, ",")
if len(parts) < 2 {
return false
}
return hmac.Equal([]byte(expectedSig), []byte(parts[1]))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
signature := r.Header.Get("webhook-signature")
timestamp := r.Header.Get("webhook-timestamp")
if !verifySignature(body, signature, timestamp) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Check timestamp
ts, _ := strconv.ParseInt(timestamp, 10, 64)
if time.Since(time.Unix(ts, 0)) > 5*time.Minute {
http.Error(w, "Timestamp too old", http.StatusUnauthorized)
return
}
var event map[string]interface{}
json.Unmarshal(body, &event)
switch event["type"] {
case "payment.succeeded":
handlePaymentSucceeded(event["data"].(map[string]interface{}))
case "subscription.active":
handleSubscriptionActive(event["data"].(map[string]interface{}))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
为防止欺骗,切勿在不验证签名的情况下处理 webhook。
使用 webhook-id 头来防止重复处理:
const processedIds = new Set<string>();
if (processedIds.has(webhookId)) {
return NextResponse.json({ received: true }); // Already processed
}
processedIds.add(webhookId);
立即返回 200,如果需要则异步处理:
// Queue for async processing
await queue.add('process-webhook', event);
return NextResponse.json({ received: true });
Dodo Payments 会重试失败的 webhook。设计处理程序时要确保幂等性。
保留详细日志以便调试:
console.log(`[Webhook] ${event.type} - ${webhookId}`, {
timestamp: event.timestamp,
data: event.data
});
# Start ngrok tunnel
ngrok http 3000
# Use the ngrok URL as your webhook endpoint
# https://xxxx.ngrok.io/api/webhooks/dodo
您可以从 Dodo Payments 仪表板触发测试 webhook:
每周安装量
199
代码仓库
GitHub 星标数
7
首次出现
2026年1月21日
安全审计
安装于
opencode167
gemini-cli164
codex154
github-copilot145
claude-code134
cursor127
Reference:docs.dodopayments.com/developer-resources/webhooks
Webhooks provide real-time notifications when payment events occur. Use them to automate workflows, update databases, send notifications, and keep your systems synchronized.
DODO_PAYMENTS_WEBHOOK_SECRET=your_webhook_secret_here
| Event | Description |
|---|---|
payment.succeeded | Payment completed successfully |
payment.failed | Payment attempt failed |
payment.processing | Payment is being processed |
payment.cancelled | Payment was cancelled |
| Event | Description |
|---|---|
subscription.active | Subscription is now active |
subscription.updated | Subscription details changed |
subscription.on_hold | Subscription on hold (failed renewal) |
subscription.renewed | Subscription renewed successfully |
subscription.plan_changed | Plan upgraded/downgraded |
subscription.cancelled |
| Event | Description |
|---|---|
refund.succeeded | Refund processed successfully |
dispute.opened | New dispute received |
license_key.created | License key generated |
| Event | Description |
|---|---|
credit.added | Credits granted to a customer (subscription, one-time, or API) |
credit.deducted | Credits consumed through usage or manual debit |
credit.expired | Unused credits expired after configured period |
credit.rolled_over | Unused credits carried forward at cycle end |
credit.rollover_forfeited | Credits forfeited at max rollover count |
credit.overage_charged |
POST /your-webhook-url
Content-Type: application/json
webhook-id: evt_xxxxx
webhook-signature: v1,signature_here
webhook-timestamp: 1234567890
{
"business_id": "bus_xxxxx",
"type": "payment.succeeded",
"timestamp": "2024-01-01T12:00:00Z",
"data": {
"payload_type": "Payment",
"payment_id": "pay_xxxxx",
"total_amount": 2999,
"currency": "USD",
"customer": {
"customer_id": "cust_xxxxx",
"email": "customer@example.com",
"name": "John Doe"
}
// ... additional event-specific fields
}
}
// app/api/webhooks/dodo/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
const WEBHOOK_SECRET = process.env.DODO_PAYMENTS_WEBHOOK_SECRET!;
function verifySignature(payload: string, signature: string, timestamp: string): boolean {
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('base64');
// Extract signature from "v1,signature" format
const providedSig = signature.split(',')[1];
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(providedSig || '')
);
}
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('webhook-signature') || '';
const timestamp = req.headers.get('webhook-timestamp') || '';
const webhookId = req.headers.get('webhook-id');
// Verify signature
if (!verifySignature(body, signature, timestamp)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
// Check timestamp to prevent replay attacks (5 minute tolerance)
const eventTime = parseInt(timestamp) * 1000;
if (Math.abs(Date.now() - eventTime) > 300000) {
return NextResponse.json({ error: 'Timestamp too old' }, { status: 401 });
}
const event = JSON.parse(body);
// Handle events
switch (event.type) {
case 'payment.succeeded':
await handlePaymentSucceeded(event.data);
break;
case 'payment.failed':
await handlePaymentFailed(event.data);
break;
case 'subscription.active':
await handleSubscriptionActive(event.data);
break;
case 'subscription.cancelled':
await handleSubscriptionCancelled(event.data);
break;
case 'refund.succeeded':
await handleRefundSucceeded(event.data);
break;
case 'dispute.opened':
await handleDisputeOpened(event.data);
break;
case 'license_key.created':
await handleLicenseKeyCreated(event.data);
break;
case 'credit.added':
await handleCreditAdded(event.data);
break;
case 'credit.deducted':
await handleCreditDeducted(event.data);
break;
case 'credit.balance_low':
await handleCreditBalanceLow(event.data);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
}
async function handlePaymentSucceeded(data: any) {
const { payment_id, customer, total_amount, product_id, subscription_id } = data;
// Update database
// Send confirmation email
// Grant access to product
console.log(`Payment ${payment_id} succeeded for ${customer.email}`);
}
async function handlePaymentFailed(data: any) {
const { payment_id, customer, error_message } = data;
// Log failure
// Notify customer
// Update UI state
console.log(`Payment ${payment_id} failed: ${error_message}`);
}
async function handleSubscriptionActive(data: any) {
const { subscription_id, customer, product_id, next_billing_date } = data;
// Grant subscription access
// Update user record
// Send welcome email
console.log(`Subscription ${subscription_id} activated for ${customer.email}`);
}
async function handleSubscriptionCancelled(data: any) {
const { subscription_id, customer, cancelled_at, cancel_at_next_billing_date } = data;
// Schedule access revocation
// Send cancellation confirmation
console.log(`Subscription ${subscription_id} cancelled`);
}
async function handleRefundSucceeded(data: any) {
const { refund_id, payment_id, amount } = data;
// Update order status
// Revoke access if needed
console.log(`Refund ${refund_id} processed for payment ${payment_id}`);
}
async function handleDisputeOpened(data: any) {
const { dispute_id, payment_id, amount, dispute_status } = data;
// Alert team
// Prepare evidence
console.log(`Dispute ${dispute_id} opened for payment ${payment_id}`);
}
async function handleLicenseKeyCreated(data: any) {
const { id, key, product_id, customer_id, expires_at } = data;
// Store license key
// Send to customer
console.log(`License key created: ${key.substring(0, 8)}...`);
}
async function handleCreditAdded(data: any) {
const { customer_id, credit_entitlement_id, amount, balance_after } = data;
// Update internal credit balance
// Log credit grant
console.log(`${amount} credits added for customer ${customer_id}, balance: ${balance_after}`);
}
async function handleCreditDeducted(data: any) {
const { customer_id, credit_entitlement_id, amount, balance_after } = data;
// Update internal credit balance
// Check if balance is getting low
console.log(`${amount} credits deducted for customer ${customer_id}, balance: ${balance_after}`);
}
async function handleCreditBalanceLow(data: any) {
const { customer_id, credit_entitlement_name, available_balance, threshold_percent } = data;
// Notify customer about low balance
// Suggest upgrading plan or purchasing more credits
console.log(`Low balance alert: ${available_balance} ${credit_entitlement_name} remaining for ${customer_id}`);
}
import express from 'express';
import crypto from 'crypto';
const app = express();
const WEBHOOK_SECRET = process.env.DODO_PAYMENTS_WEBHOOK_SECRET!;
// Use raw body for signature verification
app.post('/webhooks/dodo',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['webhook-signature'] as string;
const timestamp = req.headers['webhook-timestamp'] as string;
const payload = req.body.toString();
// Verify signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSig = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('base64');
const providedSig = signature?.split(',')[1];
if (!providedSig || !crypto.timingSafeEqual(
Buffer.from(expectedSig),
Buffer.from(providedSig)
)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(payload);
// Process event
try {
switch (event.type) {
case 'payment.succeeded':
await processPayment(event.data);
break;
case 'subscription.active':
await activateSubscription(event.data);
break;
// ... handle other events
}
res.json({ received: true });
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).json({ error: 'Processing failed' });
}
}
);
from fastapi import FastAPI, Request, HTTPException
import hmac
import hashlib
import base64
import time
app = FastAPI()
WEBHOOK_SECRET = os.environ["DODO_PAYMENTS_WEBHOOK_SECRET"]
def verify_signature(payload: bytes, signature: str, timestamp: str) -> bool:
signed_payload = f"{timestamp}.{payload.decode()}"
expected_sig = base64.b64encode(
hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload.encode(),
hashlib.sha256
).digest()
).decode()
provided_sig = signature.split(',')[1] if ',' in signature else ''
return hmac.compare_digest(expected_sig, provided_sig)
@app.post("/webhooks/dodo")
async def handle_webhook(request: Request):
body = await request.body()
signature = request.headers.get("webhook-signature", "")
timestamp = request.headers.get("webhook-timestamp", "")
if not verify_signature(body, signature, timestamp):
raise HTTPException(status_code=401, detail="Invalid signature")
# Check timestamp freshness
event_time = int(timestamp)
if abs(time.time() - event_time) > 300:
raise HTTPException(status_code=401, detail="Timestamp too old")
event = json.loads(body)
if event["type"] == "payment.succeeded":
await handle_payment_succeeded(event["data"])
elif event["type"] == "subscription.active":
await handle_subscription_active(event["data"])
# ... handle other events
return {"received": True}
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var webhookSecret = os.Getenv("DODO_PAYMENTS_WEBHOOK_SECRET")
func verifySignature(payload []byte, signature, timestamp string) bool {
signedPayload := timestamp + "." + string(payload)
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(signedPayload))
expectedSig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
parts := strings.Split(signature, ",")
if len(parts) < 2 {
return false
}
return hmac.Equal([]byte(expectedSig), []byte(parts[1]))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
signature := r.Header.Get("webhook-signature")
timestamp := r.Header.Get("webhook-timestamp")
if !verifySignature(body, signature, timestamp) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Check timestamp
ts, _ := strconv.ParseInt(timestamp, 10, 64)
if time.Since(time.Unix(ts, 0)) > 5*time.Minute {
http.Error(w, "Timestamp too old", http.StatusUnauthorized)
return
}
var event map[string]interface{}
json.Unmarshal(body, &event)
switch event["type"] {
case "payment.succeeded":
handlePaymentSucceeded(event["data"].(map[string]interface{}))
case "subscription.active":
handleSubscriptionActive(event["data"].(map[string]interface{}))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
Never process webhooks without signature verification to prevent spoofing.
Use webhook-id header to prevent duplicate processing:
const processedIds = new Set<string>();
if (processedIds.has(webhookId)) {
return NextResponse.json({ received: true }); // Already processed
}
processedIds.add(webhookId);
Return 200 immediately, process asynchronously if needed:
// Queue for async processing
await queue.add('process-webhook', event);
return NextResponse.json({ received: true });
Dodo Payments retries failed webhooks. Design handlers to be idempotent.
Keep detailed logs for debugging:
console.log(`[Webhook] ${event.type} - ${webhookId}`, {
timestamp: event.timestamp,
data: event.data
});
# Start ngrok tunnel
ngrok http 3000
# Use the ngrok URL as your webhook endpoint
# https://xxxx.ngrok.io/api/webhooks/dodo
You can trigger test webhooks from the Dodo Payments dashboard:
Weekly Installs
199
Repository
GitHub Stars
7
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykWarn
Installed on
opencode167
gemini-cli164
codex154
github-copilot145
claude-code134
cursor127
Skills CLI 使用指南:AI Agent 技能包管理器安装与管理教程
27,400 周安装
| Subscription cancelled |
subscription.failed | Subscription creation failed |
subscription.expired | Subscription term ended |
| Overage charges applied beyond zero balance |
credit.manual_adjustment | Manual credit/debit adjustment via dashboard or API |
credit.balance_low | Credit balance dropped below configured threshold |