---
title: "Webhooks"
description: "An event QuotaStack sends your server for each change — grants, low balance, renewals — so your application can react."
---

# Webhooks

QuotaStack sends you an event for each change. See the log, send a test, or retry one.

A [webhook](/docs/concepts/webhooks) is an event QuotaStack sends when
something changes: a grant, a low balance, a plan renewal.

Each event carries a signature. Your server can check it came from
QuotaStack. Set one URL per tenant. QuotaStack retries a failed delivery.

## Which call do I want?

- You want to prove your endpoint receives events → `POST /v1/webhooks/test`
- You want to see what QuotaStack tried to send → `GET /v1/webhooks/events`
- One delivery failed and you want QuotaStack to try again → `POST /v1/webhooks/events/{id}/redeliver`
  Set your endpoint URL and read your signing secret in the dashboard, not through this API. See [Account & provisioning](/docs/api/account).

3 of 3 operations documented in full.

## List webhook events

`GET /v1/webhooks/events`

- **Idempotency-Key:** Not used. This is a read.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Nothing. A read has no side effects.
- **Fires:** No webhooks.

This endpoint lists every [webhook](/docs/concepts/webhooks) event QuotaStack has queued for your tenant, in Sandbox and Live alike. QuotaStack keeps each one here forever; none of them ever get removed. A failed one stays on this list too, the same as one that worked. One URL and one secret serve both environments, so this list never splits them apart. Filter by `status` or `event_type`, if you want a smaller list. A bad `cursor` returns `400`, though openapi.yaml does not say so.

### Query parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `status` | string | optional | Only events in this state come back. An unknown value gives you an empty list, not an error. If you leave it out: All statuses come back. `pending` — Queued. QuotaStack has not sent this yet, or a retry is due soon. `delivered` — Your endpoint said yes. This one is done. `failed` — The last try failed. QuotaStack tries again soon, up to a point. `dead_letter` — Every try failed. QuotaStack gave up. Redeliver it once you fix the problem. |
| `event_type` | string | optional | Only events of this exact type, like `credit.granted`. QuotaStack matches the full string, with no partial match. If you leave it out: Every event type comes back. |
| `cursor` | string | optional | Opaque pagination cursor from the previous page's `pagination.next_cursor`. Omit to start at the first page. |
| `limit` | integer | optional | Page size. Default 20, max 100. |

### Response 200

```json
{
  "data": [
    {
      "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a51",
      "environment": "live",
      "event_type": "subscription.renewed",
      "payload": {
        "event_type": "subscription.renewed",
        "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
        "data": {
          "subscription_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a21"
        }
      },
      "status": "delivered",
      "attempts": 1,
      "next_retry_at": null,
      "last_error": null,
      "created_at": "2026-07-27T10:00:00Z",
      "delivered_at": "2026-07-27T10:00:01Z",
      "deliveries": [
        {
          "status_code": 200,
          "response_body": "OK",
          "error": null,
          "duration_ms": 118,
          "created_at": "2026-07-27T10:00:01Z"
        }
      ]
    },
    {
      "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a65",
      "environment": "live",
      "event_type": "credit.low_balance",
      "payload": {
        "event_type": "credit.low_balance",
        "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
        "data": {
          "balance_mc": 2000
        }
      },
      "status": "dead_letter",
      "attempts": 7,
      "next_retry_at": null,
      "last_error": "webhook returned status 500",
      "created_at": "2026-07-26T04:00:00Z",
      "delivered_at": null,
      "deliveries": [
        {
          "status_code": 500,
          "response_body": "{\"error\":\"internal server error\"}",
          "error": null,
          "duration_ms": 340,
          "created_at": "2026-07-26T04:33:00Z"
        }
      ]
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `data` | array | required | One row per event, newest first. |
| `data.id` | string (uuid) | required | This event’s own `id`. QuotaStack repeats it in the `webhook-id` header, and `redeliverWebhookEvent`’s `id` param takes it too. |
| `data.environment` | string | required | Sandbox or live. QuotaStack never filters this list by it — expect to see both. `live` `sandbox` |
| `data.event_type` | string | required | This event’s own type, like `credit.granted` or `subscription.renewed`. |
| `data.payload` | object | required | The exact JSON body QuotaStack sends your endpoint for this one. |
| `data.status` | string | required | What state this event is in right now. `pending` — Queued. QuotaStack has not sent this yet, or a retry is due soon. `delivered` — Your endpoint said yes. This one is done. `failed` — The last try failed. QuotaStack tries again soon, up to a point. `dead_letter` — Every try failed. QuotaStack gave up. Redeliver it once you fix the problem. |
| `data.attempts` | integer | required | How many times QuotaStack has tried to send this. Goes back to 0 when you redeliver it. |
| `data.next_retry_at` | string (date-time) | optional | When QuotaStack tries again next. Empty once the event is `delivered` or `dead_letter`. |
| `data.last_error` | string | optional | Why the last try failed, in plain words. Empty until the first one fails. |
| `data.delivered_at` | string (date-time) | optional | When your endpoint said yes. Empty until then. |
| `data.deliveries` | array | required | One row per try QuotaStack has made to send this, oldest first. |
| `data.deliveries.status_code` | integer | optional | The HTTP status your endpoint sent back. Empty when the request never got that far at all. |
| `data.deliveries.response_body` | string | optional | The first 4096 bytes of what your endpoint sent back. QuotaStack cuts off anything longer. |
| `data.deliveries.error` | string | optional | The network error, when QuotaStack got no answer at all. Empty on a real answer, even a bad one. |
| `data.deliveries.duration_ms` | integer | required | How long this one try took, start to end. Unit: milliseconds. |
| `pagination` | object | required | Cursor-based pagination envelope. Returned on every list endpoint. No `total` — cursor-only. |

### See also

- [Webhooks](/docs/concepts/webhooks)

## Redeliver a dead-lettered webhook event

`POST /v1/webhooks/events/{id}/redeliver`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Sets `attempts` back to 0 and `status` back to `pending`. QuotaStack tries to send it again on its usual schedule.
- **Fires:** No webhooks.

This endpoint sends one [webhook](/docs/concepts/webhooks) event again, if its `status` is `dead_letter` right now. Anything else returns `409`, and nothing about the event changes. QuotaStack looks up your URL again, fresh, every time it tries. A URL you fixed only a moment ago is the one it will use next. `attempts` goes back to 0 too, and QuotaStack starts the whole schedule over from the top.

### Header parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `Idempotency-Key` | string | required | Required idempotency key for this mutating admin/public operation. Reusing the same key with a different body returns `409 Conflict`; successful 2xx and client 4xx responses are cached for 24 hours. |

### Path parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required | This event’s own `id`. The delivery log shows the same value, and so did the `webhook-id` header your endpoint got. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Response 200

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a65",
  "environment": "live",
  "event_type": "credit.low_balance",
  "payload": {
    "event_type": "credit.low_balance",
    "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
    "data": {
      "balance_mc": 2000
    }
  },
  "status": "pending",
  "attempts": 0,
  "next_retry_at": "2026-07-27T12:00:00Z",
  "last_error": null,
  "created_at": "2026-07-26T04:00:00Z",
  "delivered_at": null
}
```

### Errors

- `404` — No event here has this `id`. See [not-found](/docs/api/errors/not-found).
- `409` — QuotaStack returns this for two reasons. The event is not `dead_letter` — it may be `pending`, `delivered`, or still trying again as `failed`. Reusing the same `Idempotency-Key` with a different body returns this too. Calling this twice on the same event is safe either way: the second call also gets `409`, since the event is `pending` by then. See [conflict](/docs/api/errors/conflict).
- `422` — The `Idempotency-Key` header is missing, or `id` is not a real UUID. The reply names the field. See [validation-error](/docs/api/errors/validation-error).

### See also

- [Webhooks](/docs/concepts/webhooks)
- [Delivery guarantees and the retry schedule](/docs/concepts/webhooks#delivery-guarantees)

## Send a test webhook

`POST /v1/webhooks/test`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Queues one new `test.ping` event. QuotaStack sends it the same way it sends a real [webhook](/docs/concepts/webhooks) event.
- **Fires:** [`test.ping`](/docs/api/events/test.ping)

This endpoint sends a `test.ping` [webhook](/docs/concepts/webhooks) event to your own endpoint. Sending one is how you prove your endpoint works, before you write any real code for it. QuotaStack needs a URL and a secret set up first; without both, this call returns `409` and sends nothing. Check the result with `listWebhookEvents`, filtered to `event_type=test.ping`. See [test.ping](/docs/api/events/test.ping) for the shape of the event itself.

### Header parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `Idempotency-Key` | string | required | Required idempotency key for this mutating admin/public operation. Reusing the same key with a different body returns `409 Conflict`; successful 2xx and client 4xx responses are cached for 24 hours. |

### Response 202

```json
{
  "event_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a51",
  "event_type": "test.ping",
  "status": "pending"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `event_id` | string (uuid) | required | The new event’s own `id`. Look it up in `listWebhookEvents`, or wait for it at your endpoint. |
| `event_type` | string | required | Always `test.ping`. This call only ever queues this one type. |
| `status` | string | required | Always `pending`. QuotaStack queued the event, and has not sent it yet. |

### Errors

- `409` — QuotaStack returns this when nothing is set up to send to: no [webhook](/docs/concepts/webhooks) URL, or no secret. Reusing the same `Idempotency-Key` with a different body returns this too. See [conflict](/docs/api/errors/conflict).
- `422` — The `Idempotency-Key` header is missing. QuotaStack asks for it on every write. See [validation-error](/docs/api/errors/validation-error).

### See also

- [Webhooks](/docs/concepts/webhooks)
- [test.ping](/docs/api/events/test.ping)
