Webhooks
Lifecycle events for subscriptions and payments, pushed to your endpoints as soon as they happen.
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.