Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

AttemptDelay from failureAction
1+3 daysRetry charge + notify customer
2+7 daysRetry charge + escalate notification
3+14 daysRetry charge + warn of suspension
4+21 daysRetry charge + suspend if failed

After attempt 4 fails, the subscription moves to PastDue and eventually Cancelled.

How It Works

  1. Stripe webhook delivers payment_intent.payment_failed or invoice.payment_failed
  2. The DunningService creates a DunningSchedule for the subscription
  3. A background job (cron) calls check_due_attempts() daily and triggers retries
  4. On success: subscription returns to Active, schedule is cleared
  5. On exhaustion: subscription transitions to PastDue → (admin action) → Paused or Cancelled

Dunning Schedule

Each schedule tracks:

  • subscription_id — the subscription in arrears
  • invoice_id — the outstanding invoice
  • attempt_count — how many retries have been made (1-4)
  • next_attempt_at — when to fire the next retry
  • statusActive, 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)