Webhooks let Subby's subscription engine tell your system when something happens — a subscription starts, a renewal succeeds, a charge fails, or a customer is paused. Instead of polling, you register an HTTPS endpoint and Subby sends it a signed POST request for each event.
Webhook deliveries are never metered. Only the API requests you make to manage endpoints are.
Set up an endpoint
- Build a route on your server that accepts
POSTrequests with a JSON body. - Register it in Dashboard → Developers → Webhooks, or with the API:
curl "https://api.mysubbyapp.com/v1/webhook-endpoints" \
-H "Authorization: Bearer $SUBBY_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.yourplatform.com/webhooks/subby",
"description": "Production billing handler",
"enabled_events": ["subscription.*", "charge.failed", "charge.succeeded"]
}'
- Store the
secretfrom the response. It starts withwhsec_and is only shown once.
enabled_events accepts exact event types, wildcards like subscription.*, or ["*"] for everything. You can have up to 16 endpoints per environment.
What Subby sends
POST /webhooks/subby HTTP/1.1
Content-Type: application/json
User-Agent: Subby-Webhooks/1.0
Subby-Signature: t=1726581731,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Subby-Event-Id: evt_3Lk9qPz1Tx
Subby-Delivery-Attempt: 1
{
"id": "evt_3Lk9qPz1Tx",
"object": "event",
"type": "subscription.past_due",
"api_version": "v1",
"livemode": true,
"created_at": "2026-10-01T06:00:04Z",
"data": {
"object": {
"id": "sub_9fK3mQ2xLp",
"object": "subscription",
"status": "past_due",
"customer": "cus_4kQ2mT",
"plan": "plan_8ZpR1v",
"current_period_end": "2026-10-01T00:00:00Z",
"retry": { "attempt": 1, "max_attempts": 3, "next_retry_at": "2026-10-02T06:00:00Z" },
"metadata": { "your_user_id": "u_1024" }
},
"previous_attributes": { "status": "active" }
}
}
data.objectis the object as it was when the event happened.data.previous_attributesappears on*.updated-style events and shows the fields that changed.
Verifying signatures
Always verify the signature before trusting a webhook. Anyone can send a POST to your URL, but only Subby knows your whsec_ secret.
The Subby-Signature header contains a timestamp t and a signature v1. To verify:
- Take the raw request body exactly as received. Don't parse and re-serialise it.
- Build the signed payload:
{t}.{raw_body}. - Compute an HMAC-SHA256 of the signed payload using your endpoint secret, as lowercase hex.
- Compare it to
v1using a constant-time comparison. - Reject the event if
tis more than 300 seconds from your current time, to stop replay attacks.
During a secret roll the header may contain two v1 values. Accept the event if either matches.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/webhooks/subby', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('Subby-Signature') ?? '';
const parts = header.split(',').map((p) => p.split('='));
const t = parts.find(([k]) => k === 't')?.[1];
const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) {
return res.status(400).send('Invalid timestamp');
}
const expected = crypto
.createHmac('sha256', process.env.SUBBY_WEBHOOK_SECRET)
.update(`${t}.${req.body.toString('utf8')}`)
.digest('hex');
const valid = signatures.some(
(sig) => sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
);
if (!valid) return res.status(400).send('Invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
queue.add('subby-event', event); // process asynchronously
res.sendStatus(200);
});
import hmac, hashlib, time, os, json
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks/subby")
def subby_webhook():
raw = request.get_data()
parts = [p.split("=", 1) for p in request.headers.get("Subby-Signature", "").split(",") if "=" in p]
t = next((v for k, v in parts if k == "t"), None)
sigs = [v for k, v in parts if k == "v1"]
if not t or abs(time.time() - int(t)) > 300:
abort(400)
expected = hmac.new(
os.environ["SUBBY_WEBHOOK_SECRET"].encode(),
f"{t}.".encode() + raw,
hashlib.sha256,
).hexdigest()
if not any(hmac.compare_digest(s, expected) for s in sigs):
abort(400)
event = json.loads(raw)
enqueue(event) # process asynchronously
return "", 200
<?php
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_SUBBY_SIGNATURE'] ?? '';
$t = null; $sigs = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', $part, 2), 2, null);
if ($k === 't') $t = $v;
if ($k === 'v1') $sigs[] = $v;
}
if (!$t || abs(time() - (int)$t) > 300) { http_response_code(400); exit; }
$expected = hash_hmac('sha256', $t . '.' . $raw, getenv('SUBBY_WEBHOOK_SECRET'));
$valid = false;
foreach ($sigs as $sig) { if (hash_equals($expected, $sig)) { $valid = true; } }
if (!$valid) { http_response_code(400); exit; }
$event = json_decode($raw, true);
enqueue($event); // process asynchronously
http_response_code(200);
Responding to webhooks
- Return any 2xx status within 10 seconds. Anything else, or a timeout, counts as a failed delivery.
- Acknowledge first, process later. Put the event on a queue and return
200straight away. - Don't follow redirects. Subby treats
3xxas a failure.
Retries
If a delivery fails, Subby retries up to 5 more times, at roughly 5 minutes, 1 hour, 6 hours, 24 hours and 72 hours after the first attempt. The Subby-Delivery-Attempt header tells you which attempt you're receiving.
If every delivery to an endpoint fails for 5 days in a row, Subby disables the endpoint and emails your account owners. Fix the problem, then re-enable it in the dashboard. You can resend any event from the last 30 days in Dashboard → Developers → Webhooks → Deliveries.
Duplicates and ordering
- You may receive the same event more than once. Store each processed
event.idand skip events you've already handled. - Events can arrive out of order. Use
created_aton the event and the object's current state. When in doubt, fetch the latest object with aGETrequest, which is free.
async function handleEvent(event) {
const seen = await db.processedEvents.findByPk(event.id);
if (seen) return;
switch (event.type) {
case 'subscription.paused':
case 'subscription.cancelled':
await revokeAccess(event.data.object.metadata.your_user_id);
break;
case 'subscription.recovered':
case 'subscription.resumed':
case 'subscription.renewed':
await grantAccess(event.data.object.metadata.your_user_id);
break;
case 'subscription.escalated':
if (event.data.object.escalation.action === 'restrict') await setReadOnly(event.data.object);
break;
}
await db.processedEvents.create({ id: event.id });
}
Testing webhooks
- Send a test event from the dashboard, or call
POST /webhook-endpoints/{id}/test. It sends awebhook_endpoint.testevent. - Trigger realistic events in sandbox with
POST /test/events/trigger. See Testing in sandbox. - Inspect deliveries in the dashboard: request body, response status, response body (first 2 KB) and timing for every attempt.
Save to Buy events
Save to Buy uses the separate savings_plan.* namespace. An endpoint subscribed only to subscription.* will not receive these events. Add savings_plan.*, the individual event names or * to enabled_events.
| Event | Meaning |
|---|---|
savings_plan.completed |
The buyer paid the complete product price. The merchant can deliver the product. |
savings_plan.overdue |
An instalment is over 24 hours late, or the deadline passed with a balance remaining. |
savings_plan.fulfilled |
The merchant marked the product delivered. |
savings_plan.cancelled |
The buyer, merchant or first-payment expiry cancelled the plan. Review any applicable refund. |
{
"id": "evt_3Lk9qPz1Tx",
"object": "event",
"type": "savings_plan.completed",
"livemode": true,
"created_at": 1791120000,
"data": {
"object": {
"id": "SB-7F3K9Q",
"object": "savings_plan",
"status": "completed",
"product": "Power bank 20,000mAh",
"target_amount": 30000,
"amount_paid": 30000,
"currency": "NGN",
"frequency": "weekly",
"customer": {
"name": "Tola Ade",
"email": "tola@example.com",
"phone": "08100000000"
},
"delivery": {
"address": "5 Allen Ave",
"city": "Ikeja",
"state": "Lagos",
"note": "Call on arrival"
},
"deadline": "2027-01-04T10:00:00.000Z",
"completed_at": "2026-10-04T10:00:00.000Z"
}
}
}
Each event type is emitted at most once for a Save to Buy plan, except savings_plan.overdue. If a merchant extends a plan and it becomes overdue again, Subby sends another overdue event for the new extension period. Signing, delivery retries, duplicate handling and ordering follow the standard webhook rules on this page.
Rolling the signing secret
Roll a secret from Dashboard → Developers → Webhooks → endpoint → Roll secret, or POST /webhook-endpoints/{id}/roll-secret. For 24 hours Subby signs with both the old and new secret, so the header contains two v1 values. Deploy the new secret within that window.
IP addresses
Webhooks come from a fixed set of IP addresses, listed at https://api.mysubbyapp.com/v1/webhook-ips (JSON, free to fetch). Signature verification is still required, so use the IP list as an extra layer, not a replacement.
Event types
subscription.created
A subscription was created, through the API or a completed checkout session.
subscription.updated
A subscription's plan, quantity, metadata, payment method or cancel_at_period_end changed. Includes previous_attributes.
subscription.trial_will_end
The subscription's trial ends in 3 days.
subscription.renewed
A renewal payment succeeded and a new period started.
subscription.past_due
A renewal payment failed. Retries and reminders have started.
subscription.escalated
All retries failed and the plan's escalation action was applied. escalation.action is pause, restrict, hold or none.
subscription.recovered
A past_due or escalated subscription was paid and is active again.
subscription.paused
The subscription was paused by you, by the customer, or by the pause_service escalation.
subscription.resumed
A paused subscription was resumed.
subscription.cancelled
The subscription ended. cancellation_reason is customer_request, merchant_request, nonpayment or checkout_expired.
charge.succeeded
A charge was paid.
charge.failed
A charge failed. See failure_code and whether it will be retried.
charge.retry_scheduled
Subby scheduled the next automatic retry. Includes next_retry_at.
checkout_session.completed
The customer finished hosted checkout. For subscription mode, the subscription field is set.
checkout_session.expired
A checkout session expired without being completed.
customer.created
A customer was created.
customer.updated
A customer's details changed. Includes previous_attributes.
customer.deleted
A customer was deleted. Their subscriptions were cancelled first.
customer.payment_method_updated
A customer added or replaced their default payment method, for example through a nudge link.
nudge.sent
A reminder was handed to the SMS, WhatsApp or email provider.
nudge.delivered
The channel confirmed delivery.
nudge.failed
The nudge could not be delivered. See failure_reason. If a fallback channel exists, a new nudge follows.
plan.created
A plan was created.
plan.updated
A plan changed. Existing subscriptions keep the price they started on unless migrated.
plan.archived
A plan was archived and can't be used for new subscriptions.
usage.threshold_reached
Your account reached 80% or 100% of its write request or nudge allowance for the period. threshold and meter tell you which.
invoice.created
Subby created your monthly invoice for API plan fees and overage.
invoice.paid
Your Subby invoice was paid.
webhook_endpoint.test
A test event you sent from the dashboard or with POST /webhook-endpoints/{id}/test.
savings_plan.completed
Save to Buy: the customer paid the product price in full. Deliver the product.
savings_plan.overdue
Save to Buy: an instalment is over 24 hours late, or the deadline passed with a balance remaining. Sent again if an extended plan falls behind again.
savings_plan.fulfilled
Save to Buy: the merchant marked the product delivered.
savings_plan.cancelled
Save to Buy: the buyer, merchant or first-payment expiry cancelled the plan. Review any applicable refund.
test_clock.ready
A test clock finished advancing and is ready. Sandbox only.
Sandbox only.