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 POST with a JSON body
  • Return a 2xx within 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

EventSent when
checkout.succeededA checkout payment on your account was finalized successfully
checkout.failedA checkout payment failed terminally (no further retry by SUQO)
subscription.status_changedA 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.

Parse 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"
}
FieldNotes
subscription_idUUID of the subscription that moved.
previous_status / current_statusBoth drawn from the subscription status set — pending_checkout, active, due, cancelled, pending_cancellation, inactive. See Statuses.
changed_atISO 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, activedue when a renewal comes round, → cancelled when it ends. Branch on the pair, not on the event name alone: inactiveactive is a reactivation, pending_checkoutactive 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 amount and no payment outcome in the body. A subscription reaching active does not by itself mean money arrived — checkout.succeeded is 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_at to decide which is newer, and treat subscription_id + changed_at as 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:

HeaderValue
Content-Typeapplication/json
X-SUQO-Signaturesha256=<hex HMAC-SHA256>
X-SUQO-TimestampUnix timestamp in seconds
User-AgentSUQO-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:

  1. 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.
  2. 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

SymptomLikely cause
Signature never matchesBody 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 productionWrong secret in that environment, or a proxy/WAF rewriting the body.
Requests rejected as too oldServer clock drift on your side. Sync via NTP.
Security: Treat the signing secret as a password. Never commit it to source control, expose it in client-side code, or paste it into screenshots or support tickets.