Skip to content

Billing webhooks

Billing webhooks are outbound notifications that CloudPress sends to your billing system when something happens to a service on a billing plan. They let an external biller — a plan that invoices customers outside Stripe (payment_method: none) — mirror lifecycle changes into its own books without polling the API.

Three resource types emit billing webhooks: sites (subscription lifecycle), cPanel accounts (subscription lifecycle), and domain registrations (registry lifecycle). All three go to the same plan endpoint and share the same envelope and delivery behavior; they differ only in the payload block (site, cpanel_account_link, or domain).

Mailbox hosting does not emit billing webhooks

Mailspace (workspace mailbox hosting) has no billing webhook of its own — creating, resizing, or deleting a Mailspace never reaches your webhook_url. Reconcile mail products from the Subscriptions API.


Configuration

Billing webhooks are configured per billing plan by an account or reseller administrator in the CloudPress admin UI. There is no public API endpoint to manage billing plans or their webhook settings.

Three fields on the plan control delivery:

Plan Webhook Settings
  • webhook_enabled: Boolean | Master on/off switch (default false).
  • webhook_url: String | The HTTPS endpoint that receives the POST.
  • webhook_auth: String | Sent verbatim as the Authorization request header value. Supply the full value you want — e.g. Bearer <secret>, Token <secret>, or a raw token. Omitted entirely if blank.

A webhook fires only when both webhook_enabled is true and webhook_url is present. Otherwise the event is silently dropped — no retry, no error.


Delivery Semantics

  • Method / body: HTTP POST with a JSON body to webhook_url.
  • Headers: Authorization: <webhook_auth> (verbatim; omitted if blank) and Accept: application/json.
  • Timeout: 30 seconds per attempt.
  • Success: any HTTP 2xx. Anything else — including a connection failure or timeout — is treated as a failed delivery.
  • Retries: failed deliveries are retried with backoff. The wait before the next attempt is derived from the age of the event: under 2 minutes old → wait 5 minutes; under 5 minutes old → wait 10 minutes; otherwise 15 minutes. In practice the first retry lands about 5 minutes after the event and later ones follow at 15-minute intervals. CloudPress gives up 4 hours after the event was emitted — the cut-off is measured from the event timestamp, not from the first attempt.
  • Asynchronous: delivery happens out of band, after the originating work completes. It is not part of the triggering API call's HTTP response.

Make your receiver idempotent

Because failed deliveries are retried, the same event may be delivered more than once. De-duplicate on the payload (e.g. resource id + action, plus subscription.updated_at for sites or expires_at for domains). The cPanel block carries no timestamp of its own, so key it on cpanel_username + action and treat a repeat as the same event. Domain status/expiry events (registered, transferred_in, renewed, expired) are additionally claimed single-winner internally, so concurrent registry syncs emit each logical event only once — but retries still apply.


Events

Each delivery carries the event name in the top-level action field, and the resource block (site, cpanel_account_link, or domain) tells you which kind of event it is.

Site Subscription Events
  • created: A newly ordered site finished provisioning and its subscription became active.
  • resized: A site's plan or resources changed (a resize / plan-change completed).
  • owner_change: A site was transferred to a different owning account.
  • deleted: A site was deleted. See the caveats below.

cPanel-account events carry a cpanel_account_link block and use the same four action names as sites. cPanel hosting is enabled per workspace, so a plan whose workspaces don't have it will simply never see these.

cPanel Account Events
  • created: A newly ordered cPanel account finished provisioning. Fires on the new-account order only — a restored or resized account does not re-emit created.
  • resized: A package change was applied at the server. Fires once per real change; a retried apply that changed nothing is not re-sent.
  • owner_change: The account was transferred to a different owning workspace.
  • deleted: The account was deleted. See the caveats below.

Read this before relying on deleted

deleted (for both sites and cPanel accounts) has two behaviors worth designing around:

  • It fires when the service is soft-deleted — when it enters its recoverable pending-deletion window, not when it is finally purged at the end of the retention period. Purging does not re-emit it.
  • It is only emitted when the subscription is billed through Stripe. A service on a payment_method: none plan, or one with no Stripe subscription at all (e.g. a trial), is cancelled internally without a deleted webhook. If you are an external biller, treat the absence of deleted as unreliable and reconcile cancellations from the Subscriptions API.

There is also no "restored" event: if a customer recovers the service inside the retention window, you will have received deleted with nothing afterwards to reverse it.

Domain-registration events carry a domain block. Billable events include a populated price; lifecycle-only events have price: null.

Domain Registration Events
  • registered: A new registration completed. Billable (registration price).
  • transferred_in: An inbound transfer completed. Billable (transfer price).
  • renewed: The registry expiry advanced (auto-renew, manual renew, or recovery renew). Billable (renewal price).
  • restored: A redemption recovery completed. Billableprice is the combined restore + renewal amount (one recovery invoice covers both).
  • registrant_change: A registrant (owner contact) change completed. Billable (registrant-change price).
  • privacy_enabled: WHOIS privacy was turned on. Billable (privacy price; may be null/$0 on some TLDs).
  • local_contact_added: A required local/registry contact was added. Billable (local-contact price).
  • transferred_out: The domain was transferred away to another registrar. Lifecycle-only (price: null).
  • expired: The domain passed its expiry (OK → EXPIRED). Lifecycle-only.
  • redemption: The domain entered the redemption / recovery phase. Lifecycle-only.
  • purged: A lapsed domain was purged (deleted at the registry). Lifecycle-only.
  • privacy_disabled: WHOIS privacy was turned off. Lifecycle-only.
  • local_contact_removed: A local/registry contact was removed. Lifecycle-only.
  • auto_renew_enabled: Auto-renew was turned on (by the customer, or a change made at the registry and synced back). Lifecycle-only.
  • auto_renew_disabled: Auto-renew was turned off (by the customer, or automatically when the domain is locked for non-payment). Lifecycle-only.

Same endpoint, all three resource types

Site, cPanel-account and domain webhooks share one plan endpoint. Route on the resource block — a site key (no object field), a cpanel_account_link key carrying "object": "cpanel_account_link", or a domain key carrying "object": "domain_registration". Because site and cpanel_account_link reuse the same four action names, route on the block name, never on action alone.


Payload

The POST body is a JSON object with two top-level keys: action (the event name) and a resource block — site for site events, cpanel_account_link for cPanel account events, domain for domain events.

Site payload

For site events the resource block is site (the same shape returned by GET /api/sites/{site-id}).

{
  "action": "created",
  "site": {
    "id": "83426e2f-58f4-4e7e-99bc-b76b07a31574",
    "name": "adminsacc-dbe11dadb7eb9f9e",
    "primary_domain": "youthful-buck49235.example.com",
    "created_at": "2024-09-25T22:29:21.612Z",
    "updated_at": "2024-09-25T22:33:13.589Z",
    "location": "pdx",
    "region": "pdx01",
    "bunny_id": "1234567",
    "bunny_cname": "youthful-buck49235.b-cdn.net",
    "package": "Pro",
    "php_version": "8.3",
    "domain_cname": "youthful-buck49235.b-cdn.net",
    "sftp_base_path": "/home/sftpuser/apps/adminsacc-dbe11dadb7eb9f9e/wordpress",
    "dunning_suspended": false,
    "account": {
      "id": "16512906-c2b0-4c98-ac20-b1389838aa06",
      "name": "acme2"
    },
    "subscription": {
      "id": "6d8bf084-ed11-4378-8531-ef584da08439",
      "status": "active",
      "created_at": "2024-09-25T22:33:13.581Z",
      "updated_at": "2024-09-25T22:33:14.333Z",
      "price": { "amount_cents": 5000, "term": "monthly" },
      "product": { "id": "pro", "name": "Pro" }
    }
  }
}
Payload Fields
  • action: String | One of created, resized, owner_change, deleted.
  • site: Object
    • id: String | Site GUID.
    • name: String | Internal site name.
    • primary_domain: String | Current primary domain.
    • created_at: DateTime
    • updated_at: DateTime
    • location: String | Location short name (e.g. pdx).
    • region: String | Region short name (e.g. pdx01).
    • bunny_id: String | Bunny pull-zone ID.
    • bunny_cname: String | Bunny CDN CNAME.
    • package: String | Product / package short name.
    • php_version: String | Active PHP version.
    • domain_cname: String | CDN CNAME target (same value as bunny_cname).
    • sftp_base_path: String | SFTP base path.
    • dunning_suspended: Boolean | true when the site is locked behind an unpaid invoice. Note subscription.status can still read active while suspended.
    • account: Object | The owning account, unless overridden for sub-accounts (see below).
      • id: String
      • name: String
    • subscription: Object
      • id: String
      • status: String
      • created_at: DateTime
      • updated_at: DateTime
      • price: Object
        • amount_cents: Integer
        • term: String
      • product: Object
        • id: String
        • name: String

cPanel account payload

For cPanel-account events the resource block is cpanel_account_link. It is a deliberate subset of the site shape — it carries no CDN or infrastructure fields — plus "object": "cpanel_account_link" so receivers can route.

{
  "action": "created",
  "cpanel_account_link": {
    "object": "cpanel_account_link",
    "id": "12",
    "guid": "acme123",
    "cpanel_username": "acme123",
    "primary_domain": "example.com",
    "account": {
      "id": "16512906-c2b0-4c98-ac20-b1389838aa06",
      "name": "acme2"
    },
    "subscription": {
      "id": "9f2c1f7a-1b64-4a7e-8f31-2c6b0d3e5a41",
      "status": "active",
      "price": { "amount_cents": 5000, "term": "monthly" },
      "product": { "id": "cpanel_basic", "name": "Basic" }
    }
  }
}
cPanel Payload Fields
  • action: String | One of created, resized, owner_change, deleted.
  • cpanel_account_link: Object
    • object: String | Always cpanel_account_link (use to distinguish from a site payload).
    • id: String | An opaque internal record id. Not a route key — address the account by cpanel_username.
    • guid: String | Same value as cpanel_username.
    • cpanel_username: String | The cPanel account username.
    • primary_domain: String | The account's primary domain.
    • account: Object | The owning workspace, unless overridden for sub-accounts (see below).
      • id: String
      • name: String
    • subscription: Object | Omitted entirely when the account has no subscription — cPanel accounts are subscription-optional, so do not assume this key is present.
      • id: String
      • status: String
      • price: Object
        • amount_cents: Integer
        • term: String
      • product: Object
        • id: String
        • name: String

No timestamps in this block

Unlike the site block, the cPanel block carries no created_at / updated_at — not on the account and not on its subscription. Use the delivery order plus your own receipt time for sequencing.

Domain payload

For domain events the resource block is domain. It carries "object": "domain_registration" so receivers can route, and a price block that is populated for billable events and null otherwise.

{
  "action": "registered",
  "domain": {
    "id": "b2c3d4e5-6789-4abc-9def-0123456789ab",
    "object": "domain_registration",
    "name": "example.com",
    "status": "OK",
    "expires_at": "2026-09-25T00:00:00.000Z",
    "auto_renew": true,
    "privacy_protect": false,
    "tld": "com",
    "recovery_phase": null,
    "account": {
      "id": "16512906-c2b0-4c98-ac20-b1389838aa06",
      "name": "acme2"
    },
    "price": {
      "amount_cents": 1200,
      "currency": "usd",
      "term": "yearly"
    }
  }
}
Domain Payload Fields
  • action: String | One of the domain events listed above.
  • domain: Object
    • id: String | Domain registration GUID.
    • object: String | Always domain_registration (use to distinguish from a site payload).
    • name: String | The domain name.
    • status: String | Registry status (e.g. OK, EXPIRED, PENDING_TRANSFER).
    • expires_at: DateTime | Current registry expiry.
    • auto_renew: Boolean | Whether auto-renew is enabled.
    • privacy_protect: Boolean | Whether WHOIS privacy is active.
    • tld: String | The TLD (e.g. com).
    • recovery_phase: String | Redemption/recovery phase, or null when not in recovery.
    • account: Object | The owning account, unless overridden for sub-accounts (see below).
      • id: String
      • name: String
    • price: Object | The amount billed for this event, or null for lifecycle-only events.
      • amount_cents: Integer
      • currency: String | Three-letter ISO code (the plan currency), e.g. usd.
      • term: String | e.g. yearly (for restored, the renewal term).

Sub-account billing

When the owning account is a sub-account of a different billing account, the payload identifies both: account is set to the billing (parent) account and an extra sub_account block (inside the resource block) names the actual owner. This applies to all three payloads — site, cpanel_account_link and domain.

{
  "action": "resized",
  "site": {
    "id": "83426e2f-58f4-4e7e-99bc-b76b07a31574",
    "account":     { "id": "<billing-account-guid>", "name": "Parent Co" },
    "sub_account": { "id": "<owner-account-guid>",   "name": "Child Team" },
    "subscription": { "...": "..." }
  }
}

What does not trigger a webhook

Billing webhooks fire for the site, cPanel-account and domain events listed above. The following do not emit a billing webhook — even when paid:

  • Mailbox hosting (Mailspace). No mail product emits a billing webhook. Reconcile it from the Subscriptions API.
  • Restoring a soft-deleted site or cPanel account. Only the deleted event exists; recovery is silent.
  • Backups, restores, cache, Shield, and other per-site task operations. Track these via task polling or a per-request completion callback.
  • Order / invoice mechanics (charges, refunds, payment status). Billing webhooks describe service lifecycle, not invoice state; for order-level detail use the Orders API and the registrar processes.

Billing webhooks vs. task completion callbacks

CloudPress has two distinct push mechanisms. Don't confuse them:

Billing webhook Task completion callback
Configured on The billing plan (admin UI) Per request, in the callback object on POST /api/orders, POST /api/orders/domain or POST /api/cpanel_accounts
Scope All site, cPanel-account and domain lifecycle events under that plan The single task / order it was attached to
Fires for Site events (created, resized, …), cPanel-account events (created, resized, deleted, owner_change) and domain events (registered, renewed, …) Completion of that specific async task
Body { "action", "site" }, { "action", "cpanel_account_link" } or { "action", "domain" } { "timestamp", "success", "data" } (the task result)

See Callbacks for the per-request callback contract.