---
title: "credit.expiring_soon"
description: "QuotaStack sends this before a [credit block](/docs/concepts/credits) runs out, once it enters your lead window. One event covers one block. A customer holding three expiring packs gets three events. A QuotaStack job raises it. No API call does."
---

# credit.expiring_soon

Fired once per credit block when that block's remaining balance will expire within the tenant's `credit_expiring_soon_hours` lead window (default 0 = off; recommended 72). Distinct from `credit.expired` (fires when the block actually expires) and from post-expiry `credit.exhausted` / `credit.low_balance`. Debounced per (customer, block_id); emission is best-effort.

## When it fires

QuotaStack sends this before a [credit block](/docs/concepts/credits) runs out, once it enters your lead window. One event covers one block. A customer holding three expiring packs gets three events. A QuotaStack job raises it. No API call does.

## When it does not fire

QuotaStack sends nothing while `credit_expiring_soon_hours` is 0, which is where every account starts. QuotaStack does not send this twice for one block. A fresh grant makes a new block, so that one gets its own event.

## Payload — `data`

| Field | Type | Required | Meaning |
|---|---|---|---|
| `block_id` | string (uuid) | required |  |
| `expiring_amount` | integer (int64) | required | Remaining millicredits on this block that will expire. |
| `expires_at` | string (date-time) | required |  |
| `hours_remaining` | integer | optional | Approximate whole hours until expires_at (ceil). |
| `lead_time_hours` | integer | optional | Tenant-configured lead window that matched this block. |
| `effective_balance` | integer (int64) | required | What the customer can spend today, before this block runs out. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `spendable_after_expiry` | integer (int64) | required | max(0, effective_balance − expiring_amount) for this block. Multi-block same-window expiry may understate aggregate spendable. |

### Example payload

```json
{
  "event_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a51",
  "event_type": "credit.expiring_soon",
  "tenant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a01",
  "environment": "live",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "external_customer_id": "user_42",
  "created_at": "2026-07-28T08:00:00Z",
  "idempotency_key": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a32",
  "data": {
    "block_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a31",
    "expiring_amount": 10000,
    "expires_at": "2026-07-31T08:00:00Z",
    "hours_remaining": 72,
    "lead_time_hours": 72,
    "effective_balance": 10000,
    "spendable_after_expiry": 0
  }
}
```

## What to do

Warn the customer, or top them up, while the credits are still there. Read `spendable_after_expiry` to see what would be left. That figure counts this block alone, so it can read high when several blocks lapse together.

## Which calls fire this

- QuotaStack's scheduler, on a timer — no API call involved.

## See also

- [Credits](/docs/concepts/credits)
- [Low balance alerts](/docs/cookbook/low-balance-alerts)

## Delivery

QuotaStack guarantees **at-least-once** delivery. An event may be delivered more than once if your endpoint returns a non-2xx response, the connection fails, or the request exceeds the delivery timeout.

**Delivery timeout:** 5 seconds per attempt. If your endpoint does not return a 2xx within 5 seconds, the attempt is treated as a failure and retried. Not configurable today.

**One webhook URL per tenant.** Multiple URLs and per-event routing are not supported. Configure the URL via the tenant config endpoint.

### Retry schedule

If delivery fails (non-2xx response, timeout, or network error), QuotaStack retries with exponential backoff:

| Attempt | Delay after previous |
|---|---|
| 1 | Immediate |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 8 hours |
| 7 | 24 hours |

After 7 failed attempts, the event is moved to a dead letter queue. Dead-lettered events are not lost — you can requeue them yourself, from the dashboard (Activity → Webhooks → Redeliver) or the API:

```bash
curl -X POST https://api.quotastack.io/v1/webhooks/events/{event_id}/redeliver \
  -H "X-API-Key: $QS_KEY" \
  -H "Idempotency-Key: redeliver:{event_id}"
```

Redelivery resets the event to `pending` with a fresh retry schedule (7 new attempts). The next attempt signs with your **current** secret — useful when the event dead-lettered because of a secret rotation or an endpoint outage you have since fixed. Only `dead_letter` events can be redelivered; the call returns `409` for events in any other status.

### Handling duplicates

Because delivery is at-least-once, your webhook handler should be idempotent. Use the `webhook-id` header for deduplication — if you have already processed an event with that ID, return 200 and skip processing.
