Webhooks

Receive signed events on your own endpoint, following the Standard Webhooks specification.

A webhook endpoint is a URL of yours that ResellerHQ posts to when something happens. Deliveries follow the Standard Webhooks specification.

Add an endpoint

In the dashboard, open Developers and choose New endpoint. Or with the API:

curl -X POST https://resellerhq.com/api/v1/webhook-endpoints \
  -H "Authorization: Bearer $RHQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/hooks/resellerhq","events":["number.message.received"]}'

The response includes the endpoint's signing secret, which starts with whsec_. It is shown once. Use ["*"] to receive every event.

The URL must be https and must resolve to a public address.

Events

EventWhen
number.order.completedAn order was filled
number.order.failedAn order could not be filled; nothing was charged
number.rental.createdA number was delivered
number.rental.renewedA number was extended
number.rental.renewal_failedAn automatic renewal could not be paid for
number.rental.cancelledA number was cancelled and refunded
number.rental.expiredA number reached the end of its rental
number.message.receivedA number received an SMS
domain.registeredA domain was registered
domain.registration_failedThe registrar refused a registration it had accepted; the hold was given back and nothing was charged
domain.renewedA domain was renewed
domain.renewal_pendingThe registrar accepted a renewal but has not finished it; the money is held until it does
domain.renewal_failedA renewal the registrar had accepted did not complete; the hold was given back
domain.expiringA domain is 60, 30, 7 or 1 days from expiry, sent once per threshold; autoRenew and balanceShort say whether it will renew itself and whether the wallet already falls short of what that would cost
domain.expiredA domain passed its expiry date and can no longer be changed until it is renewed
domain.needs_attentionA registration or renewal has been unresolved at the registrar long enough to need a person; the money is still held
domain.updatedA domain's settings changed; changed names which of nameservers, contact, contacts, locked, suspended, privacy, customerPrice and dns moved - dns being hosted DNS switched on or off, or a record added, changed or deleted. Changing nameservers while hosted DNS is on is refused with 409 dns_hosted unless the request also sends releaseHostedDns: true - see nameservers, contact and transfer lock
domain.host_createdA child nameserver (glue record) was registered under a domain; host is its name and addresses what it answers on
domain.host_updatedA child nameserver's addresses were replaced; addresses is what it answers on now and addressesBefore what it answered on until then
domain.host_deletedA child nameserver was deleted at the registrar; addressesBefore is what it answered on until then
domain.auth_code_generatedA transfer code was issued for a domain, so it can be moved to another registrar; via says whether an API key or the dashboard asked. The code itself is never in the event
domain.transferred_outAnother registrar finished taking a domain over. It is no longer renewed, charged for or changeable
domain.transfer.completedA domain transferring in from another registrar finished; it is now ours, with the hold for it captured
domain.transfer.failedA domain transferring in was rejected by the losing registrar, or failed for another reason - never sent for a transfer you cancelled yourself; the hold was given back and no domain was created
domain.restoredA domain in redemption was brought back; expiresAt is its new expiry and charged what the restore cost
domain.restore_failedA restore the registrar had accepted did not complete; the hold was given back and the domain is still in redemption
domain.deletedA registration was given up at the registrar; refunded is what came back to the wallet, 0 outside the registry grace period
domain.suspendedA domain was put on hold at the registrar: it stops resolving but stays registered and still renews
domain.unsuspendedA domain was taken off hold and resolves again
reseller.branded_nameservers_createdA branded nameserver set finished being created at the registrar; names are its hostnames
reseller.branded_nameservers_removedA branded nameserver set was removed; domains using it were moved back to the default nameservers first
subreseller.suspendedA sub-reseller was suspended; their calls answer 403 reseller_suspended until unsuspended
subreseller.unsuspendedA suspended sub-reseller was unsuspended
subreseller.margin_clawback_shortA sub-reseller's refund clawed back less of your margin than it owed, because your own balance fell short at the time; shortfall is what could not be clawed back
mailbox_domain.createdA domain was connected for mailboxes
mailbox_domain.activeIt points at its nameservers; mailboxes can be set up
mailbox_domain.failedIt never pointed at its nameservers in time
mailbox_domain.renewedThe next month was charged for its mailboxes
mailbox_domain.renewal_failedA month could not be paid for; sent once for that month
mailbox_domain.dns_brokenIts nameservers moved away from ours after it went active; mail may already be down
mailbox_domain.deletedIt was deleted, with every mailbox on it
mailbox.createdA mailbox was ordered
mailbox.setup_progressIts setup moved to a new stage
mailbox.activeIt is set up; its sign-in details are ready
mailbox.failedIt could not be set up; nothing was charged for it
mailbox.updatedIts name, address, picture or password changed
client_id.completedAn OAuth client ID was granted on a domain
client_id.failedA client ID grant failed
licence.createdA mailbox licence ordered before licences were retired was confirmed. Licences are no longer sold; the licence.* events are for licences you already hold
licence.updatedA licence you hold changed: mailboxes were added on its seats, it was set to end with the paid month, or its setup finished or stalled
licence.renewedA licence you hold was charged for the next month
licence.renewal_failedA month of a licence you hold could not be paid for; sent once for that month
licence.cancelledA licence you hold was cancelled, by you or after a week unpaid
price_list.updatedA price list changed; changed names the settings that moved and customerIds the customers on it
sale.below_costA sale's price was under wholesale: raised to it (raised: true) or sold at a loss
invoice.createdA customer was invoiced for what you sold them
invoice.paidYou marked an invoice paid
invoice.voidedAn invoice was voided, by you or because a number on it was cancelled and refunded; its sales can be invoiced again
credit_note.issuedA credit note was issued against an invoice, correcting it in part or in full
wallet.topped_upA card top-up was credited to the wallet
portal.order.placedA customer placed an order on account; it placed at least one line
portal.order.blockedA customer's order placed nothing at all - reason names why, never shown to the customer

What you receive

{
  "id": "evt_01JABCDEF...",
  "type": "number.message.received",
  "timestamp": "2026-10-07T11:04:31.454Z",
  "data": {
    "messageId": "msg_01JABCDEF...",
    "rentalId": "num_01JABCDEF...",
    "number": "15550100002",
    "sender": "WhatsApp",
    "body": "Your code is 428-113",
    "receivedAt": "2026-10-07T11:04:31.000Z"
  }
}

Three headers come with it: webhook-id, webhook-timestamp and webhook-signature.

Example payloads

data's shape depends on the event. One worked example per event family, with the same field names the code that sends it uses:

Domain - domain.updated:

{ "id": "dom_01JABCDEF...", "domain": "northwind-mail.com", "changed": ["nameservers"] }

Mailbox domain - mailbox_domain.created:

{
  "id": "mdm_01JABCDEF...",
  "domain": "northwind-mail.com",
  "status": "propagating",
  "nameservers": ["ns1.resellerhq-dns.com", "ns2.resellerhq-dns.com"]
}

Mailbox - mailbox.active:

{
  "id": "mbx_01JABCDEF...",
  "email": "[email protected]",
  "mailboxDomainId": "mdm_01JABCDEF...",
  "domain": "northwind-mail.com"
}

mailbox.created and mailbox.failed carry the same four fields, plus status on mailbox.created and code and reason on mailbox.failed. See a mailbox order, end to end below for mailbox.setup_progress, which has a different shape.

Client ID - client_id.completed:

{
  "id": "cid_01JABCDEF...",
  "mailboxDomainId": "mdm_01JABCDEF...",
  "domain": "northwind-mail.com",
  "clientId": "102938475610293847561",
  "delegated": false
}

Licence - licence.created:

{ "id": "lic_01JABCDEF...", "plan": "google-workspace-starter", "domain": "northwind-mail.com", "seats": 5 }

Invoice / credit note - invoice.created:

{
  "id": "inv_01JABCDEF...",
  "number": "INV-00042",
  "customerId": "cus_01JABCDEF...",
  "currency": "USD",
  "total": 4200,
  "status": "open"
}

invoice.paid and invoice.voided carry the same fields, with status changed. credit_note.issued adds invoiceId, invoiceNumber, subtotal, tax, total and reason alongside its own id, number, customerId and currency.

Wallet - wallet.topped_up:

{ "topupId": "wtp_01JABCDEF...", "amount": 10000 }

Portal - portal.order.placed:

{
  "orderId": "pord_01JABCDEF...",
  "customerId": "cus_01JABCDEF...",
  "status": "placed",
  "invoiceId": "inv_01JABCDEF..."
}

Reseller / sub-reseller - subreseller.suspended:

{ "subResellerId": "rsl_01JABCDEF...", "reason": "Unpaid for 30 days" }

Price list / sale - price_list.updated:

{
  "id": "prl_01JABCDEF...",
  "name": "Wholesale",
  "changed": ["customerPrice"],
  "customers": 12,
  "customerIds": ["cus_01JABCDEF..."],
  "items": 37
}

The webhook payload example generator builds a sample envelope for any event type, and signs it with a secret you paste in.

A mailbox order, end to end

Ordering several mailboxes on a domain you don't already hold sends a run of events, in this order - here, five mailboxes on a domain registered with us:

  1. mailbox_domain.created
  2. domain.updated, with changed: ["nameservers"], if the domain was registered with us (we point it at its mail nameservers ourselves)
  3. mailbox.created × 5
  4. mailbox_domain.active, once the domain's nameservers have propagated
  5. mailbox.setup_progress, several times per mailbox, as each moves through Google's own setup steps
  6. mailbox.active × 5, as each finishes

mailbox.setup_progress is frequent - several per mailbox - and purely informational: safe to ignore if all you want is mailbox.active or mailbox.failed. Its setupStage is an internal stage name from the mailbox provider, not a fixed list to match against, and the sandbox and live providers use different ones. In the sandbox, a mailbox walks scheduled, configuring_workspace, configuring_dns, configuring_dkim, configuring_auth and finalizing_setup on its way to active. Live, against Google Workspace, stages observed include seat while a mailbox is set up on its own, and domain_dns_zone, domain_verify and domain_customer while it waits on its shared domain. Treat any value you don't recognise the same way: informational, nothing to act on.

Verify the signature

Always verify before trusting a delivery. Any Standard Webhooks library does it:

import { Webhook } from 'standardwebhooks';

const webhook = new Webhook(process.env.RHQ_WEBHOOK_SECRET);

export async function POST(request: Request) {
  const body = await request.text(); // the raw body, not parsed JSON
  let event;
  try {
    event = webhook.verify(body, Object.fromEntries(request.headers));
  } catch {
    return new Response('bad signature', { status: 400 });
  }
  // handle the event
  return new Response(null, { status: 204 });
}

The signature is an HMAC-SHA256 over webhook-id.webhook-timestamp.body. Libraries also reject timestamps that are too old, which stops replays.

Retries

Answer with any 2xx within 10 seconds. Anything else is retried after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 14 hours, and then given up on - about 32 hours in all. An event is never dropped at that point: it is marked failed and can still be sent again, by you, whenever you are ready (see Resending a failed delivery below).

Ordering

Deliveries about the same subject - a domain, a number, a mailbox, a licence, an order or an invoice - go out in the order the events happened, one at a time: the next one waits until the one before it has succeeded or finally failed, rather than racing ahead of it on a retry. Events about different subjects, or with no subject in common, are never held up by each other.

De-duplication

Because of retries, the same event can arrive more than once. webhook-id is the same each time, so store the ids you have handled and skip repeats. A delivery you resend from the dashboard or the API carries that same id too, so a receiver that already dedupes on it sees no difference from the original - resending is safe to do more than once.

Resending a failed delivery

A failed or blocked delivery can be sent again. In the dashboard, open Developers, find the endpoint under Webhook endpoints and choose View deliveries from its menu, then Resend on the delivery. Or with the API:

curl -X POST https://resellerhq.com/api/v1/webhook-deliveries/whd_01K6XJ4Q.../redeliver \
  -H "Authorization: Bearer $RHQ_API_KEY"

This queues a fresh delivery of the same event, ahead of anything else waiting for that endpoint and subject. The original delivery's own record is untouched; the new one gets its own id and its own retry schedule if it fails again.

Unhealthy and paused endpoints

An endpoint whose deliveries have all failed for a day is marked unhealthy; after a week of nothing but failures it is paused, shown as a banner on its row in Developers. Pausing stops new deliveries being attempted - it does not stop them being made: events for a paused endpoint still queue, nothing is lost, and they start delivering again as soon as the endpoint answers successfully once more (including to a delivery you resend by hand).

In the API reference