# Guide to Maxio Webhooks: Features and Best Practices

Maxio webhooks tell your systems what is happening to your subscribers: a signup succeeds, a renewal fails, a card is updated, a subscription moves into `past_due`, or an invoice is issued. If you bill on Maxio Advanced Billing, the platform formerly known as Chargify, webhooks are how those events reach your provisioning, CRM, accounting and dunning workflows without polling the API.

This guide covers how Maxio webhooks are configured, signed and retried, the events you can subscribe to, the quirks that come from Maxio's form-encoded format and Chargify heritage, and the best practices for running a Maxio receiver in production.

## What are Maxio webhooks?

Maxio webhooks are configured per site. You turn webhooks on for the site, add up to five endpoints by default, and choose which events each endpoint receives. When a subscribed event occurs, Maxio sends an HTTP `POST` to each endpoint that wants it.

The body is not JSON. Maxio sends `application/x-www-form-urlencoded` data with three top-level keys: `id`, a numeric webhook ID; `event`, the event key; and `payload`, the event's data written in square-bracket notation, such as `payload[subscription][product][name]=Basic`. The wire format still carries the Chargify name, in the header names and in keys such as `payload[chargify]`.

Every request is signed. `X-Chargify-Webhook-Signature-Hmac-Sha-256` holds a hex HMAC-SHA256 of the raw body, keyed with your site's Shared Key.

## Maxio webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Config > Settings > Webhooks** for each site |
| Endpoints | Up to 5 per site by default, each with its own event list |
| Body format | `application/x-www-form-urlencoded`, keys `id`, `event` and `payload[...]` |
| Signature header | `X-Chargify-Webhook-Signature-Hmac-Sha-256` |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | The raw HTTP body, nothing else |
| Secret | The site's Shared Key, one per site, used as a plain string |
| Other headers | `X-Chargify-Webhook-Id`, and the deprecated MD5 `X-Chargify-Webhook-Signature` |
| Replay window | None. No timestamp is signed |
| Acknowledgement | HTTP `200 OK` |
| Retries | Up to six attempts: the original plus five retries over about 20 minutes |
| Failing endpoints | Paused after 26 consecutive failures, disabled after 51 |
| Endpoint URL | Ports 80 and 443 only. HTTP is accepted only while the site is in test mode |

Maxio publishes no source IP list for webhooks, so the signature is the credential.

## Common events

Maxio's events reference lists more than 50 event keys. Which ones appear for your site depends on its configuration, and the definitive list for your site is the checkbox list under **Config > Settings > Webhooks**.

| Area | Events | Fires when |
| --- | --- | --- |
| Signups | `signup_success`, `signup_failure`, `delayed_signup_creation_success`, `delayed_signup_creation_failure`, `subscription_group_signup_success`, `subscription_group_signup_failure` | A subscription or group is created, fails to start, or a delayed subscription is or is not created |
| Payments | `payment_success`, `payment_failure`, `payment_not_attempted`, `direct_debit_payment_paid_out`, `direct_debit_payment_pending`, `direct_debit_payment_rejected` | A payment is attempted, cannot be attempted for lack of a payment method, or a Direct Debit payment changes state in the gateway |
| Renewals and dunning | `renewal_success`, `renewal_failure`, `upcoming_renewal_notice`, `trial_end_notice`, `dunning_step_reached`, `expiring_card` | A period renews or fails to, a notice goes out, or dunning advances |
| Subscription changes | `subscription_state_change`, `subscription_product_change`, `subscription_product_change_scheduled`, `upgrade_downgrade_success`, `upgrade_downgrade_failure`, `pending_cancellation_change`, `billing_date_change`, `expiration_date_change` | A subscription's state, product or dates change |
| Usage and components | `component_allocation_change`, `metered_usage`, `prepaid_usage`, `prepaid_subscription_balance_changed`, `item_price_point_changed` | Allocations, usage or prices change |
| Invoices and statements | `invoice_issued`, `invoice_pending`, `proforma_invoice_issued`, `statement_closed`, `statement_settled` | An invoice or statement is issued, changes state or closes |
| Customers and payment methods | `customer_create`, `customer_update`, `customer_delete`, `subscription_card_update`, `subscription_bank_account_update` | Customer records or payment profiles change |
| Refunds | `refund_success`, `refund_failure` | A refund succeeds, fails or is reversed by the bank |

Maxio calls `subscription_state_change` the "workhorse" of the events, because it tells you when a subscription moves into a state such as `past_due`. The subscription object carries both `previous_state` and `state`. One action can fire several events: a new subscription that needs a payment typically fires both `signup_success` and `payment_success`.

`payment_not_attempted` is distinct from `payment_failure`. It fires when Maxio tries to collect and finds no payment method, so no gateway was contacted, and it never produces a `payment_failure`. It is opt-in per endpoint.

Each payload carries its own `payload[event_id]`, the site (`id` and `subdomain`), and the resources for that event, such as `subscription`, `transaction` or `invoice`. The resource objects usually hold the same information as the matching API resource. Not every field is populated on every event: Maxio's example is that `reason_code` is not sent in `signup_success`.

Timestamps use the site's configured timezone, except some newer events, such as `component_allocation_change` and `metered_usage`, whose `timestamp` is ISO 8601 in UTC.

## Setting up Maxio webhooks

1. Find your site's Shared Key: open the site switcher at the top of the left navigation, select **Edit Current Site**, and copy the **Shared Key** field. Store it server-side, for example as `MAXIO_SHARED_KEY`.
2. Go to **Config > Settings**, then select **Webhooks**.
3. Select the **Send webhooks to your webhook endpoints** checkbox.
4. Click **Add New Endpoint** and enter your URL. Use HTTPS: Maxio accepts HTTP only while the site is in test mode.
5. Under **Webhook Subscriptions**, select the events to send to this endpoint. **All On** and **All Off** toggle the whole list.
6. Click **Save**.

To test, choose **Test** from the endpoint's **Actions** drop-down. This sends a minimal body, `id=123456&event=test&payload[chargify]=testing`, that only proves the endpoint answers. To see the real shape of an event, use **Tools > Webhook Testing**, which sends dummy data shaped like the event you pick.

## Securing Maxio webhooks

Read `X-Chargify-Webhook-Signature-Hmac-Sha-256`, compute a hex HMAC-SHA256 of the raw request body with your Shared Key as the key, and compare the two in constant time. Only then parse the form body.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyMaxioSignature(rawBody, signatureHeader, sharedKey) {
  if (!signatureHeader || !sharedKey) return false;
  const expected = crypto.createHmac('sha256', sharedKey).update(rawBody).digest('hex');
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

// express.raw, not express.urlencoded: the signature covers the exact bytes.
app.post('/webhooks/maxio', express.raw({ type: '*/*' }), (req, res) => {
  const sharedKey = process.env.MAXIO_SHARED_KEY;
  if (!sharedKey) return res.sendStatus(500); // fail closed

  const signature = req.get('x-chargify-webhook-signature-hmac-sha-256');
  if (!verifyMaxioSignature(req.body, signature, sharedKey)) {
    return res.sendStatus(401);
  }

  const form = new URLSearchParams(req.body.toString('utf8'));
  const webhook_id = form.get('id');
  const event = form.get('event');
  const product_name = form.get('payload[subscription][product][name]');
  // Store webhook_id, then process asynchronously. Maxio only treats 200 as success.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
from urllib.parse import parse_qs

def verify_maxio_signature(raw_body: bytes, signature_header, shared_key: str) -> bool:
    if not signature_header or not shared_key:
        return False
    expected = hmac.new(shared_key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# if verify_maxio_signature(raw_body,
#         request.headers.get("x-chargify-webhook-signature-hmac-sha-256"),
#         os.environ["MAXIO_SHARED_KEY"]):
#     form = parse_qs(raw_body.decode("utf-8"))
#     event = form["event"][0]

```

Maxio publishes a test vector. With the Shared Key `123` and the body `payload[chargify]=testing&event=test`, the signature is:

```
19826d51b9f866b26eda1f154de192593360f8d0bcb63df8a28540a5dcf733f1

```

Maxio also lets you put the signature in the endpoint URL with the `{signature_hmac_sha_256}` placeholder, for receivers that cannot read headers. The older MD5 signature is still sent for backward compatibility, but it is deprecated and may be removed, so validate only the HMAC-SHA256 one.

> **Make Maxio webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Chargify-Webhook-Signature-Hmac-Sha-256` header against your Shared Key, durably queues every billing event, and keeps your endpoint from being paused while your service is down.

## Maxio webhook limitations and pain points

### A form body that is easy to break

**The Problem:** Every delivery fails verification, even with the correct Shared Key.

**Why It Happens:** The signature covers the raw form-encoded bytes. Frameworks that parse `application/x-www-form-urlencoded` automatically, such as `express.urlencoded()` or Rails' params, hand your code a decoded object, and re-encoding it rarely reproduces the original bytes. Using a different secret, such as an API key, fails the same way.

**Workarounds:**

* Read the raw body on the webhook route and verify before any parsing.
* Use the Shared Key from **Edit Current Site**, not an API key.
* Test your verifier against Maxio's published test vector.

**How Hookdeck Can Help:** Hookdeck's Maxio source type verifies the signature against the body exactly as Maxio sent it, so your service receives only requests that passed the check.

### Repeated failures pause and then disable the endpoint

**The Problem:** After an outage, events stop arriving, and the ones generated meanwhile have to be sent by hand.

**Why It Happens:** Maxio counts consecutive failures per endpoint. From 26 to 50 failures, the endpoint is paused: webhooks are generated in a paused state and must be sent manually from the Webhooks Panel. The pause expires two hours after the last failure. At 51 failures, the endpoint is disabled and webhooks are no longer generated for it. Deleting an endpoint discards any paused webhooks that reference it.

**Workarounds:**

* Return `200` straight after verification, and do the work afterwards.
* Watch the endpoint status under **Config > Settings > Webhooks**.
* After an outage, resend paused webhooks from the Webhooks Panel and dedupe on `id`.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, then retries against your service on a schedule you control, so your downtime does not count against Maxio's failure limits.

### Only `200` counts as success

**The Problem:** Deliveries that your service handled are retried anyway.

**Why It Happens:** Maxio asks for an HTTP `200 OK`. It describes any other response, such as `500` or `404`, or a response that takes too long, as triggering a retry, and it does not publish the timeout. A framework default such as `201` or `204` is not what Maxio documents as success.

**Workarounds:**

* Return exactly `200` from the webhook route.
* Keep the handler fast, since the time limit is not published.

**How Hookdeck Can Help:** Hookdeck answers Maxio itself, so the status code your service returns only affects Hookdeck's own retries to your service.

### Event names vary by site

**The Problem:** A handler written against the docs never sees some events.

**Why It Happens:** Some sites send legacy names: `allocation_change` instead of `component_allocation_change`, and `subscription_plan_change` instead of `subscription_product_change`, on sites with productless subscriptions disabled. Some events only exist once a feature is enabled, such as `proforma_invoice_issued` on Relationship Invoicing sites. Maxio's own documentation has also renamed events over time.

**Workarounds:**

* Build your router from the checkbox list for your own site, not from a generic list.
* Accept both the current and legacy names where your site may send either.
* Log and acknowledge unknown event keys rather than failing them.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can match on the `event` field and route current and legacy names to the same destination.

## Best practices

### Verify the raw body, then parse

Mount `express.raw()` on the webhook route, verify, then parse with `URLSearchParams`. In FastAPI, read `await request.body()` and parse with `urllib.parse.parse_qs`. Remember that nested keys arrive flattened, as `payload[subscription][state]`, unless you use a parser that understands bracket notation.

### Deduplicate on the webhook `id`

Maxio's `id` is the same across retries and replays, and it is also sent in the `X-Chargify-Webhook-Id` header, so you can check it before parsing the body. Record it before you process the event, and skip any `id` you have already handled. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Treat webhooks as notifications

When in doubt, Maxio recommends checking the current state of the resource through the API. Some payment methods, such as ACH and Direct Debit, can fail after a `payment_success`, so confirm state before acting on anything irreversible.

### Expect several events per action

A single signup can produce `signup_success` and `payment_success`, and a statement can produce `statement_closed` and later `statement_settled`. Make each handler independent of the others' arrival order.

### Acknowledge fast and process asynchronously

Verify, persist the event, return `200`, and do the real work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Maxio signs every webhook with a hex HMAC-SHA256 of the raw form-encoded body, keyed with the site's Shared Key and sent in `X-Chargify-Webhook-Signature-Hmac-Sha-256`. Verify before parsing, return exactly `200`, dedupe on the webhook `id`, and build your event router from your own site's event list. Maxio retries a failed delivery five times over about 20 minutes and then pauses and disables endpoints that keep failing, so a long outage on your side needs manual recovery.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every Maxio signature, and holds each event until your service has processed it, so an outage on your side does not pause your endpoint or cost you events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Maxio webhooks reliably in minutes.