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
Authorizationrequest 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
POSTwith a JSON body towebhook_url. - Headers:
Authorization: <webhook_auth>(verbatim; omitted if blank) andAccept: 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: noneplan, or one with no Stripe subscription at all (e.g. a trial), is cancelled internally without adeletedwebhook. If you are an external biller, treat the absence ofdeletedas 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. Billable —
priceis 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 |
truewhen the site is locked behind an unpaid invoice. Notesubscription.statuscan still readactivewhile 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 asitepayload). - 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
- object: String | Always
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 asitepayload). - 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
nullwhen 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
nullfor lifecycle-only events.- amount_cents: Integer
- currency: String | Three-letter ISO code (the plan currency), e.g.
usd. - term: String | e.g.
yearly(forrestored, 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
deletedevent 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.