Each meaningful change of state in the payment lifecycle produces a webhook event from Opus Protocol. Point one or more HTTPS endpoints at it, using either the Dashboard or the Provider SDK, and the events arrive in your own systems.

Registering endpoints

Open Settings > Webhooks in the Dashboard and add your HTTPS endpoints. By default every event goes to every endpoint; you can restrict an endpoint to a chosen set of event types.

Doing the same from the SDK:

await opusprotocol.createWebhookEndpoint({
  url: "https://your-api.com/webhooks/opusprotocol",
  events: ["subscription.renewed", "subscription.payment_failed"],
  secret: "whsec_...", // optional; omit it and Opus Protocol generates one for you
});

Available events

Event Trigger
subscription.created A subscription is created
subscription.trial_started A trial starts
subscription.trial_ended The trial is over and billing begins with the first cycle
subscription.renewed A billing cycle has settled
subscription.payment_failed A collection attempt failed
subscription.paused A subscription moves into PAUSED status
subscription.cancelled A subscription has been cancelled
allowance.depleted An allowance has hit its spend cap
allowance.expiring An allowance will expire in under 24 hours
payment.completed The proof for a one-time payment has been verified

Payload format

Every event uses the same envelope:

{
  "id": "evt_01HX...",
  "type": "subscription.renewed",
  "created": 1750000000,
  "livemode": true,
  "data": { ... }
}

subscription.renewed

{
  "id": "evt_01HX...",
  "type": "subscription.renewed",
  "created": 1750000000,
  "livemode": true,
  "data": {
    "subscription": {
      "id": "sub_...",
      "subscriber": "0x4298e8aa4048cf8d437f9a90266a7e8c436a7bba",
      "plan": "0x7f3a...",
      "status": "ACTIVE",
      "cycleCount": 3,
      "nextBillingAt": 1752678400
    },
    "collection": {
      "amount": 49000000,
      "token": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
      "txHash": "0x8f4e..."
    }
  }
}

subscription.payment_failed

{
  "id": "evt_01HY...",
  "type": "subscription.payment_failed",
  "created": 1750000000,
  "livemode": true,
  "data": {
    "subscription": {
      "id": "sub_...",
      "subscriber": "0x4298e8aa4048cf8d437f9a90266a7e8c436a7bba",
      "plan": "0x7f3a...",
      "status": "PAUSED"
    },
    "failure": {
      "reason": "InsufficientFunds",
      "attemptCount": 3,
      "lastAttemptAt": 1750006400
    }
  }
}

allowance.depleted

{
  "id": "evt_01HZ...",
  "type": "allowance.depleted",
  "created": 1750000000,
  "livemode": true,
  "data": {
    "allowance": {
      "id": "alw_...",
      "granter": "0x4298e8aa4048cf8d437f9a90266a7e8c436a7bba",
      "grantee": "0x9ca41190a7c04f2f2ce6ee32e4b9b0e6b1d1f8a3",
      "maxAmount": 10000000,
      "spent": 10000000
    }
  }
}

Verifying signatures

Each delivery carries an X-OpusProtocol-Signature header. Its value is an HMAC-SHA256 of the raw request body, using your webhook secret as the key.

Check this signature before you act on any payload. Without that check you have no evidence of where the request came from.

import { createHmac, timingSafeEqual } from "crypto";

function verifyWebhook(payload: string, signature: string, secret: string): boolean {
  const expected = createHmac("sha256", secret)
    .update(payload)
    .digest("hex");

  const sig = signature.replace("sha256=", "");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}

app.post("/webhooks/opusprotocol", express.raw({ type: "application/json" }), (req, res) => {
  const valid = verifyWebhook(
    req.body.toString(),
    req.headers["x-opusprotocol-signature"],
    process.env.OPUSPROTOCOL_WEBHOOK_SECRET
  );

  if (!valid) {
    return res.status(400).send("Invalid signature");
  }

  const event = JSON.parse(req.body.toString());
  // handle event.type ...

  res.status(200).send("OK");
});

With the Provider SDK, verification and parsing happen together:

app.post("/webhooks/opusprotocol", express.raw({ type: "application/json" }), (req, res) => {
  const event = opusprotocol.parseWebhookPayload({
    payload: req.body.toString(),
    signature: req.headers["x-opusprotocol-signature"],
  });
  // throws WebhookSignatureError if the signature does not verify

  switch (event.type) {
    case "subscription.renewed":
      await grantAccess(event.data.subscription.subscriber);
      break;
    case "subscription.payment_failed":
      await suspendAccess(event.data.subscription.subscriber);
      break;
    case "allowance.depleted":
      await notifyAgentToTopUp(event.data.allowance.granter);
      break;
  }

  res.status(200).send("OK");
});

Retries

When an endpoint answers with a status outside the 2xx range, or does not answer within 30 seconds, Opus Protocol retries the delivery using exponential backoff:

Attempt Wait after previous attempt
1 Immediate
2 5 minutes
3 30 minutes
4 2 hours
5 8 hours

Once the fifth attempt fails, the event is flagged as undelivered. You can resend undelivered events from the Dashboard whenever you like.

Idempotency

Occasionally an event will reach you more than once, usually because of a network retry or an infrastructure restart. The id on each event is unique; use it to discard duplicates before you process anything.