Dunning
Dunning is the process of automatically retrying failed payments. When a Stripe charge fails, the billing platform schedules up to 4 retry attempts before marking the subscription as past due and eventually cancelling.
Retry Schedule
| Attempt | Delay from failure | Action |
|---|---|---|
| 1 | +3 days | Retry charge + notify customer |
| 2 | +7 days | Retry charge + escalate notification |
| 3 | +14 days | Retry charge + warn of suspension |
| 4 | +21 days | Retry charge + suspend if failed |
After attempt 4 fails, the subscription moves to PastDue and eventually Cancelled.
How It Works
- Stripe webhook delivers
payment_intent.payment_failedorinvoice.payment_failed - The
DunningServicecreates aDunningSchedulefor the subscription - A background job (cron) calls
check_due_attempts()daily and triggers retries - On success: subscription returns to
Active, schedule is cleared - On exhaustion: subscription transitions to
PastDue→ (admin action) →PausedorCancelled
Dunning Schedule
Each schedule tracks:
subscription_id— the subscription in arrearsinvoice_id— the outstanding invoiceattempt_count— how many retries have been made (1-4)next_attempt_at— when to fire the next retrystatus—Active,Resolved,Exhausted
Webhook Integration
Configure your Stripe webhook endpoint to receive payment failure events:
# [curl]
# Configure in Stripe Dashboard:
# Endpoint URL: https://api.bill.sh/v1/webhooks/stripe
# Events: payment_intent.*, invoice.*
# Verify the webhook is registered
curl https://api.bill.sh/admin/v1/webhooks \
-H "Authorization: Bearer $ADMIN_TOKEN"
# [Python]
# Listen for dunning-related webhook events
import hmac
import hashlib
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "your-webhook-secret"
@app.route("/billing/webhooks", methods=["POST"])
def handle_billing_webhook():
payload = request.get_data()
signature = request.headers.get("X-Billing-Signature", "")
# Verify signature
expected = hmac.new(
WEBHOOK_SECRET.encode(), payload, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(400, "Invalid signature")
event = request.get_json()
if event["type"] == "subscription.past_due":
sub_id = event["data"]["subscription_id"]
customer_id = event["data"]["customer_id"]
print(f"Subscription {sub_id} for customer {customer_id} is past due")
# Send notification email, update your DB, etc.
elif event["type"] == "payment.failed":
invoice_id = event["data"]["invoice_id"]
attempt = event["data"].get("dunning_attempt", 1)
print(f"Payment failed for invoice {invoice_id} (attempt {attempt})")
return "", 200
// [Node.js]
import express from "express";
import { createHmac, timingSafeEqual } from "crypto";
const app = express();
app.post("/billing/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-billing-signature"] ?? "";
const expected = createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(req.body.toString());
if (event.type === "subscription.past_due") {
const { subscription_id, customer_id } = event.data;
console.log(`Subscription ${subscription_id} for customer ${customer_id} is past due`);
// Update your DB, send notification, etc.
} else if (event.type === "payment.failed") {
const { invoice_id } = event.data;
console.log(`Payment failed for invoice ${invoice_id}`);
}
res.status(200).send("ok");
});
// [Go]
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
)
func handleBillingWebhook(w http.ResponseWriter, r *http.Request) {
payload, _ := io.ReadAll(r.Body)
signature := r.Header.Get("X-Billing-Signature")
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write(payload)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(signature)) {
http.Error(w, "Invalid signature", http.StatusBadRequest)
return
}
var event map[string]interface{}
json.Unmarshal(payload, &event)
switch event["type"] {
case "subscription.past_due":
data := event["data"].(map[string]interface{})
log.Printf("Subscription %v past due for customer %v",
data["subscription_id"], data["customer_id"])
case "payment.failed":
data := event["data"].(map[string]interface{})
log.Printf("Payment failed for invoice %v", data["invoice_id"])
}
w.WriteHeader(http.StatusOK)
}
The endpoint verifies the HMAC-SHA256 signature against STRIPE_WEBHOOK_SECRET before processing. Invalid signatures return 400.
Manual Recovery
Once dunning is exhausted and the customer resolves the payment manually:
# [curl]
# Mark the invoice as paid
curl -X POST https://api.bill.sh/admin/v1/invoices/$INV_ID/pay \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "actor_id": "finance-001", "actor_name": "Alex Finance" }'
# Resume the subscription
curl -X POST https://api.bill.sh/admin/v1/subscriptions/$SUB_ID/resume \
-H "Authorization: Bearer $ADMIN_TOKEN"
# [Python]
# Mark the invoice as paid (manual payment received)
pay_resp = requests.post(
f"https://api.bill.sh/admin/v1/invoices/{inv_id}/pay",
headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
json={"actor_id": "finance-001", "actor_name": "Alex Finance"},
)
print("Invoice paid:", pay_resp.json()["status"])
# Resume the subscription
resume_resp = requests.post(
f"https://api.bill.sh/admin/v1/subscriptions/{sub_id}/resume",
headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
)
print("Subscription resumed:", resume_resp.json()["status"])
// [Node.js]
// Mark invoice as paid
const payResp = await fetch(
`https://api.bill.sh/admin/v1/invoices/${invId}/pay`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${ADMIN_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ actor_id: "finance-001", actor_name: "Alex Finance" }),
}
);
console.log("Invoice paid:", (await payResp.json()).status);
// Resume the subscription
const resumeResp = await fetch(
`https://api.bill.sh/admin/v1/subscriptions/${subId}/resume`,
{
method: "POST",
headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` },
}
);
console.log("Subscription resumed:", (await resumeResp.json()).status);
// [Go]
// Mark invoice as paid
payBody, _ := json.Marshal(map[string]string{
"actor_id": "finance-001",
"actor_name": "Alex Finance",
})
req, _ := http.NewRequest("POST",
"https://api.bill.sh/admin/v1/invoices/"+invID+"/pay",
bytes.NewReader(payBody))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)
// Resume the subscription
req2, _ := http.NewRequest("POST",
"https://api.bill.sh/admin/v1/subscriptions/"+subID+"/resume", nil)
req2.Header.Set("Authorization", "Bearer "+adminToken)
http.DefaultClient.Do(req2)