Webhooks
Webhooks let your system react to events on your SUQO account without polling. When something happens — a checkout succeeds or fails, a subscription changes status — SUQO sends a signed HTTP POST to a URL you control.
Registering and managing endpoints is done in the dashboard: see Webhooks in the user guide. This page covers the wire format and how to verify a delivery.
How it works
Event on your SUQO account
│
▼
SUQO signs the JSON payload with your account signing secret
│
▼
POST → your endpoint URL (10s timeout)
│
├─ you reply 2xx → done
└─ anything else → retried up to 5 times, 60s apart
└─ still failing → we email your account address
You register one endpoint URL per event type, and your account has one signing secret that signs every webhook we send you. Both are managed from the SUQO dashboard, where you can also copy the secret and fire a test delivery to your endpoint.
What your endpoint must do
- Be publicly reachable over HTTPS
- Accept
POSTwith a JSON body - Return a
2xxwithin 10 seconds — acknowledge first, process asynchronously - Verify the signature before trusting the payload
Retries
A delivery counts as successful only if you return 2xx. Any other status, a connection error, or a response slower than 10 seconds is a failure and is retried — up to 5 attempts, 60 seconds apart. After that we stop and email your account's registered email address. Those events are not re-delivered later.
Failure emails are throttled to at most one per 24 hours per webhook, so a permanently-dead endpoint nags you daily rather than on every event.
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 |
checkout.succeeded
{
"event": "checkout.succeeded",
"subscription_id": "8f3c2b10-4d5e-4a91-9b77-1c2d3e4f5a6b",
"amount": "1500.00",
"status": "succeeded"
}
status is always "succeeded". Sent once, after the payment is committed on our side.
checkout.failed
{
"event": "checkout.failed",
"subscription_id": "8f3c2b10-4d5e-4a91-9b77-1c2d3e4f5a6b",
"amount": "1500.00",
"status": "failed"
}
status is always "failed". 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. It is sent as a string to avoid float rounding — do not read it 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 — pending_checkout, active, due, cancelled, pending_cancellation, inactive. See 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 (see Retries), so a later transition can land before an earlier one. Use
changed_atto decide which is newer, and treatsubscription_id+changed_atas the idempotency key so a retry of a delivery you already handled is a no-op.
Test deliveries
A test fired from the dashboard is a real, fully-signed delivery to your endpoint, with the body:
{ "event": "checkout.succeeded", "data": { "test": true } }
Note the shape differs from a live event — the fields sit under data. Use it to confirm signature verification and reachability, not to exercise your payload parsing.
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"}
Verifying the signature
Always verify before acting on a payload. Anyone can POST to your URL; the signature is what proves the request came from SUQO.
The signature is:
HMAC-SHA256( key = your signing secret,
message = "<X-SUQO-Timestamp>" + "." + <raw request body> )
hex-encoded and prefixed with sha256=.
Two rules that matter:
- Use the raw request body bytes — exactly as received. Do not parse to JSON and re-serialize; key order and whitespace would change and the signature would not match.
- Compare in constant time (
hmac.compare_digest,crypto.timingSafeEqual) — not==.
The timestamp is part of the signed message, so a captured request cannot be replayed under a new time. Reject requests whose timestamp is more than ~5 minutes old.
Store the signing secret as a server-side secret — never in frontend code or a public repo.
Python
import hashlib
import hmac
def verify(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Node
const crypto = require("crypto")
function verify(rawBody, signature, timestamp, secret) {
const expected =
"sha256=" +
crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody) // Buffer — raw bytes
.digest("hex")
const a = Buffer.from(expected)
const b = Buffer.from(signature)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
Common signature problems
| Symptom | Likely cause |
|---|---|
| Signature never matches | Body was parsed and re-serialized before verifying — use the raw bytes. In Express, a JSON body parser mounted before your route is the usual culprit. |
| Matches locally, fails in production | Wrong secret in that environment, or a proxy/WAF rewriting the body. |
| Requests rejected as too old | Server clock drift on your side. Sync via NTP. |