Webhooks & events
React to subscription and invoice events reliably.
Subscriptions and invoices move through their lifecycle asynchronously — trials end, renewals collect, payments fail and retry — so the source of truth for "what just happened" is the webhook stream, not your API call. This page covers the events VINR emits across that lifecycle and how to handle them idempotently so your system stays in sync.
Why events, not pollingAsk
A subscription is a long-lived object that changes on VINR's clock: the renewal at midnight, the dunning retry three days later, the SCA challenge the customer completes on their phone. None of these originate from a request you made, so polling can only ever tell you the current state — never reliably when it changed. Webhooks push each transition to you the moment it occurs, which is what lets you grant or revoke access in real time.
Events are also queryable after the fact via the Events API (evt_...). Webhooks are the live delivery channel; the API is your audit log and backfill tool.
The lifecycle eventsAsk
The events below cover the common subscription and invoice transitions. Most integrations only need to handle a handful — the renewal, the failure, and the cancellation.
| Event | When it fires | Typical action |
|---|---|---|
subscription.created | A subscription starts (incl. trialing). | Provision access. |
subscription.trial_will_end | 3 days before a trial converts. | Remind the customer; confirm a payment method exists. |
subscription.updated | Plan, quantity, or status changes. | Re-evaluate entitlements. |
subscription.deleted | Subscription is canceled and ends. | Revoke access. |
invoice.finalized | A draft invoice locks and amount due is set. | Record the statement. |
invoice.paid | Collection succeeds. | Grant/extend access for the period. |
invoice.payment_failed | A collection attempt fails. | Surface a banner; let dunning retry. |
invoice.payment_action_required | Payment needs SCA. | Send the customer the hosted confirmation link. |
Grant entitlements on invoice.paid, not on subscription.created. A subscription can exist in past_due while its first invoice is still failing — keying access off payment events keeps you from handing out unpaid service.
Verifying and routing the webhookAsk
Every delivery carries an x-vinr-signature header. Verify it against the raw request body before trusting anything — vinr.webhooks.verify throws if the signature is invalid or the timestamp is stale.
import { Vinr } from '@vinr/sdk';
import express from 'express';
const vinr = new Vinr({ secretKey: process.env.VINR_SECRET_KEY });
const app = express();
// IMPORTANT: verify against the *raw* body, not parsed JSON.
app.post('/webhooks/vinr', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = vinr.webhooks.verify(
req.body, // raw Buffer
req.headers['x-vinr-signature'],
process.env.VINR_WEBHOOK_SECRET, // from the endpoint (we_...)
);
} catch {
return res.status(400).send('invalid signature');
}
// Acknowledge fast; do real work async (see idempotency below).
enqueue(event);
res.status(200).send('ok');
});Handling events idempotentlyAsk
VINR guarantees at-least-once delivery, so the same event can arrive more than once (a slow ACK, a retry, a network blip). Deduplicate on event.id and make every handler safe to run twice. Persist the event ID inside the same transaction as the side effect — that closes the gap where a crash between "did the work" and "marked it seen" would otherwise double-apply.
async function handleEvent(event) {
// INSERT ... ON CONFLICT DO NOTHING — returns false if already processed.
const isNew = await db.markProcessed(event.id);
if (!isNew) return;
switch (event.type) {
case 'invoice.paid': {
const invoice = event.data.object; // inv_...
await grantAccess(invoice.subscription, invoice.period_end);
break;
}
case 'invoice.payment_failed':
await notifyPaymentFailed(event.data.object.customer);
break;
case 'subscription.deleted':
await revokeAccess(event.data.object.id); // sub_...
break;
default:
// Unhandled types are fine — return 200 so VINR stops retrying.
break;
}
}Always return 2xx once you've durably received the event, even for types you ignore. A non-2xx (or a timeout past 10s) marks the delivery failed and VINR retries with exponential backoff for up to 72 hours.
Ordering and edge casesAsk
Next stepsAsk
Webhooks overview
Endpoints, signing secrets, and retries.
Dunning & recovery
What happens after a failed collection.
Events API
Query and replay past events.
Last updated on