---
title: "2026-07-26 · Proactive expiry warnings"
description: "A third balance alert — credit.expiring_soon fires before a credit block reaches its expires_at, so you can top a customer up or warn them while the credits still exist. Off by default; you set the lead time in hours."
order: 5
date: "2026-07-26"
---

# Proactive expiry warnings

Additive release, and a follow-up to [balance alert webhooks](/docs/changelog/2026-07-26-balance-alert-webhooks) from earlier the same day. That release told you a customer *had* run low. This one tells you before credits disappear on a timer.

## Added — `credit.expiring_soon`

Fires when a credit **block** enters a lead window you configure, ahead of its `expires_at`. Until now the first signal that a pack had lapsed was `credit.expired`, which arrives when the credits are already gone. This is the same story one lead window earlier, while you can still do something about it.

Off by default. The tenant `credit_expiring_soon_hours` starts at `0`, which disables the event.

```bash
# Lead time in hours (admin session — owner or admin role)
curl -X PATCH https://api.quotastack.io/v1/admin/tenants/{tenant_id}/config \
  -H "Cookie: qs_admin_session=..." \
  -H "Idempotency-Key: config-expiring-soon:{tenantId}" \
  -H "Content-Type: application/json" \
  -d '{"credit_expiring_soon_hours": 72}'
```

`72` is the recommended value for packs and annual grants; `8760` — one year — is the maximum. The setting is tenant-wide, and there is no per-customer override in this release.

## Per block, not per customer

Unlike `credit.low_balance` and `credit.exhausted`, which describe a customer's overall position, this event describes **one credit block**. A customer holding three packs that all lapse inside the window gets three events, each naming its own `block_id`.

Each block fires once and then stays quiet until its `expires_at` has passed. A fresh grant is a new block, so it gets its own event.

## Payload

| Field | Notes |
|---|---|
| `block_id` | The block that is about to expire. |
| `expiring_amount` | Millicredits still on that block — what is at risk. |
| `expires_at` | When it lapses. |
| `effective_balance` | `balance − reserved_balance − pending_balance` at the moment the event fired. |
| `spendable_after_expiry` | `max(0, effective_balance − expiring_amount)`. Each event counts only its own block, so with several blocks lapsing together this can understate the eventual total. |
| `hours_remaining`, `lead_time_hours` | Informational, omitted when zero. |

## How it fits with the events you already have

| Event | When |
|---|---|
| `credit.expiring_soon` | **Before** `expires_at`, once the block enters your lead window |
| `credit.expired` | When the block actually expires — the accounting record |
| `credit.exhausted` / `credit.low_balance` | After effective balance crosses, including as a result of that expiry |

Leaving `credit_expiring_soon_hours` at `0` keeps exactly the behaviour you have today: no new event, and `credit.expired` still fires on the actual lapse.

## Not included

Emission is best-effort — it never fails the expiry sweep or a credit mutation, so a delivery problem cannot block a customer's credits from expiring on schedule.

As with the other balance alerts, these are webhooks only. QuotaStack does not send email and does not automatically top anyone up. Per-customer lead windows and an overage-threshold event are not shipped.

See [`credit.expiring_soon`](/docs/api/events/credit.expiring_soon) for the full payload.
