Webhooks & events

React to subscription and invoice events reliably.

View as MarkdownInstall skills

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.

EventWhen it firesTypical action
subscription.createdA subscription starts (incl. trialing).Provision access.
subscription.trial_will_end3 days before a trial converts.Remind the customer; confirm a payment method exists.
subscription.updatedPlan, quantity, or status changes.Re-evaluate entitlements.
subscription.deletedSubscription is canceled and ends.Revoke access.
invoice.finalizedA draft invoice locks and amount due is set.Record the statement.
invoice.paidCollection succeeds.Grant/extend access for the period.
invoice.payment_failedA collection attempt fails.Surface a banner; let dunning retry.
invoice.payment_action_requiredPayment 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

Was this page helpful?
Edit on GitHub

Last updated on

On this page