Webhook events
The event catalogue and wire format. To set an endpoint up see Receive webhook events; to check a delivery is genuine see Verify a webhook signature.
Events
| Event | Sent when |
|---|---|
checkout.succeeded | A checkout payment on your account was finalized successfully |
checkout.failed | A checkout payment failed terminally (no further retry by SUQO) |
subscription.status_changed | A subscription moved from one status to another |
The two checkout events fire once per checkout session, whatever the item count. Payments taken through the legacy Create a subscription endpoint report through the same payload.
checkout.succeeded
{
"event": "checkout.succeeded",
"session_id": "cks_3n9k2xQ7m",
"amount": "5000.00",
"status": "succeeded",
"items": [
{ "pbp_id": "pbp_3n9k2x" },
{
"name": "Onboarding setup",
"amount": "2500.00",
"interval_type": "one_time",
"interval_count": 0
}
]
}status is always "succeeded". Sent once, after the payment is committed on our side.
Fields
| Field | Notes |
|---|---|
session_id | The cks_… returned as public_id when the session was opened. This is how a delivery is matched back to the session you created. |
amount | The session total, as a decimal string. |
items | What the session carries, echoing the shapes that were sent: pbp_id for a catalogue item, or name / amount / interval_type / interval_count for a partner-priced one. |
status | "succeeded". |
checkout.failed
{
"event": "checkout.failed",
"session_id": "cks_3n9k2xQ7m",
"amount": "5000.00",
"status": "failed",
"items": [
{ "pbp_id": "pbp_3n9k2x" }
]
}status is always "failed". The fields are the ones listed above. We intentionally do not expose the specific cause (gateway decline, verification error, timeout) — that detail stays in SUQO's logs.
Pending or transient failures do not produce a webhook. We keep retrying the payment internally and only emit checkout.failed once the outcome is final.
amount as a decimal. The session total and each
item's amount are sent as strings to avoid float rounding — do not read them into a float.subscription.status_changed
{
"event": "subscription.status_changed",
"subscription_id": "c5d91b6d-8948-4e35-bc55-da60b411ff51",
"previous_status": "inactive",
"current_status": "active",
"changed_at": "2026-08-17T10:24:03.381452+00:00"
}| Field | Notes |
|---|---|
subscription_id | UUID of the subscription that moved. |
previous_status / current_status | Both drawn from the subscription status set — see Subscription statuses. |
changed_at | ISO 8601, UTC, microsecond precision, explicit offset. |
Each delivery describes one transition, so a single subscription produces several of these across its life — activation after the first payment, active → due when a renewal comes round, → cancelled when it ends. Branch on the pair, not on the event name alone: inactive → active is a reactivation, pending_checkout → active is a first activation, and the two usually deserve different handling on your side.
Two things this event is not:
- It is not a payment signal. There is no
amountand no payment outcome in the body. A subscription reachingactivedoes not by itself mean money arrived —checkout.succeededis the event that says that. - It is not ordered. Deliveries are retried independently, so a later transition can land before an earlier one. Use
changed_atto decide which is newer — see Retries.
Request format
Every delivery is a POST with:
| Header | Value |
|---|---|
Content-Type | application/json |
X-SUQO-Signature | sha256=<hex HMAC-SHA256> |
X-SUQO-Timestamp | Unix timestamp in seconds |
User-Agent | SUQO-Webhooks/1.0 |
Example:
POST /hooks/suqo HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
X-SUQO-Signature: sha256=4f2a...c91b
X-SUQO-Timestamp: 1785312724
User-Agent: SUQO-Webhooks/1.0
{"event":"checkout.succeeded","subscription_id":"8f3c...","amount":"1500.00"}