Webhooks
QuotaStack sends you an event for each change. See the log, send a test, or retry one.
A webhook 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}/redeliverSet your endpoint URL and read your signing secret in the dashboard, not through this API. See Account & provisioning.
List webhook events
/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 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.
| Field | Type | Meaning |
|---|---|---|
statusoptional | string | 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.
|
event_typeoptional | string | Only events of this exact type, like If you leave it out: Every event type comes back. |
cursoroptional | string | Opaque pagination cursor from the previous page's |
limitoptional | integer | Page size. Default 20, max 100. |
{
"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
}
} | Field | Type | Meaning |
|---|---|---|
datarequired | array | One row per event, newest first. |
data.idrequired | string (uuid) | This event’s own |
data.environmentrequired | string | Sandbox or live. QuotaStack never filters this list by it — expect to see both.
|
data.event_typerequired | string | This event’s own type, like |
data.payloadrequired | object | The exact JSON body QuotaStack sends your endpoint for this one. |
data.statusrequired | string | What state this event is in right now.
|
data.attemptsrequired | integer | How many times QuotaStack has tried to send this. Goes back to 0 when you redeliver it. |
data.next_retry_atoptional | string (date-time) | When QuotaStack tries again next. Empty once the event is |
data.last_erroroptional | string | Why the last try failed, in plain words. Empty until the first one fails. |
data.delivered_atoptional | string (date-time) | When your endpoint said yes. Empty until then. |
data.deliveriesrequired | array | One row per try QuotaStack has made to send this, oldest first. |
data.deliveries.status_codeoptional | integer | The HTTP status your endpoint sent back. Empty when the request never got that far at all. |
data.deliveries.response_bodyoptional | string | The first 4096 bytes of what your endpoint sent back. QuotaStack cuts off anything longer. |
data.deliveries.erroroptional | string | The network error, when QuotaStack got no answer at all. Empty on a real answer, even a bad one. |
data.deliveries.duration_msrequired | integer | How long this one try took, start to end. Unit: milliseconds. |
paginationrequired | object | Cursor-based pagination envelope. Returned on every list endpoint. No |
See also
Redeliver a dead-lettered webhook event
/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 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.
| Field | Type | Meaning |
|---|---|---|
Idempotency-Keyrequired | string | Required idempotency key for this mutating admin/public operation. Reusing the same key with a different body returns |
| Field | Type | Meaning |
|---|---|---|
idrequired | string (uuid) | This event’s own If you leave it out: QuotaStack rejects the call. This path segment is required. |
{
"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 thisid. not-found409— QuotaStack returns this for two reasons. The event is notdead_letter— it may bepending,delivered, or still trying again asfailed. Reusing the sameIdempotency-Keywith a different body returns this too. Calling this twice on the same event is safe either way: the second call also gets409, since the event ispendingby then. conflict422— TheIdempotency-Keyheader is missing, oridis not a real UUID. The reply names the field. validation-error
Send a test webhook
/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 event. |
| Fires | test.ping |
This endpoint sends a test.ping webhook 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 for the shape of the event itself.
| Field | Type | Meaning |
|---|---|---|
Idempotency-Keyrequired | string | Required idempotency key for this mutating admin/public operation. Reusing the same key with a different body returns |
{
"event_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a51",
"event_type": "test.ping",
"status": "pending"
} | Field | Type | Meaning |
|---|---|---|
event_idrequired | string (uuid) | The new event’s own |
event_typerequired | string | Always |
statusrequired | string | Always |
Errors
409— QuotaStack returns this when nothing is set up to send to: no webhook URL, or no secret. Reusing the sameIdempotency-Keywith a different body returns this too. conflict422— TheIdempotency-Keyheader is missing. QuotaStack asks for it on every write. validation-error
Loading…