Webhooks and Data Management

The most straightforward Vertical Insure 'integration' is one where the drop-in component is placed in a checkout, and there is no 'second chance' to purchase coverage presented on the platform after that.

This concept doesn't work well for all platforms. For some platforms, coverage may actually be required, in which case the platform should let the user see which coverable items have a policy and which do not.

To track which items have a policy and which don't, you should track the Vertical Insure quote_id associated with each item, and then subscribe to Vertical Insure webhooks so that you can be notified when a policy is bound (purchased) for that quote_id, when its policy document is generated, when it is endorsed (updated) or canceled, and so on.

The steps below show one way to do this. The examples come from a sample registration management app built with the Next.js App Router, where each event registration can be covered by a registration-cancellation policy. The same pattern works with any stack.

1. Store the quote_id for the event

Keep one record per coverable item holding its current quote_id and the latest policy details you have heard about, such as the policy_number, policy_status and total. The record starts as QUOTED and moves to ACTIVE, CANCELED, BIND_FAILED and so on as webhooks arrive.

Send the quote_id to your backend from the drop-in component's offer-state-change event. It fires when the customer accepts or declines coverage, not when the offer first loads. This example requests one product for one participant, so the event carries at most one quote, and none when the customer declines. If your offer includes several products or participants, record each quote in state.quotes instead.

components/QuoteOffer.tsx
export default function QuoteOffer({ event, clientId }: Props) {
  const config = useMemo<Config>(/* see step 2 */);

  const eventHandlers = useMemo(
    () => ({
      onOfferStateChange: (state: OfferStateChangeEvent) => {
        // In this case, we are only expecting one quote, so just get the first
        const [quote] = state.quotes;
        if (quote) recordQuoteAction(event.id, quote.quote_id).catch(console.error);
      },
    }),
    [event.id],
  );

  return <VerticalInsure config={config} eventHandlers={eventHandlers} />;
}

On the backend, check that the item exists and store the quote_id against it, replacing any earlier quote for that item. The example uses a Next.js server action; with other stacks, POST the item id and quote_id to an endpoint of your own.

app/actions.ts
"use server";

export async function recordQuoteAction(eventId: number, quoteId: string) {
  if (!data.getEvent(eventId)) throw new Error("Event not found");
  // In a real app, also check that the signed-in user owns this event ID.
  data.recordQuote(eventId, quoteId);
}

When you configure the drop-in component for an item:

  • Set unique_offer_id to something stable for the item. The component then reuses the same quote when the page is reloaded in the same browser session, instead of creating a new one each time.
  • Put your item's id in the quote's metadata. Vertical Insure includes the metadata in every policy webhook, so you can match a webhook to the item even if you failed to record its quote_id.
  • Set the core metadata fields context_name (here, the organization) and context_event (the event). They power Partner Portal analytics, and context_name also appears in the purchase emails sent to customers.
components/QuoteOffer.tsx
const config = useMemo<Config>(
  () => ({
    client_id: clientId,
    unique_offer_id: `event-${event.id}`,
    payments: { enabled: true, button: true },
    product_config: {
      "registration-cancellation": [
        {
          customer: {
            email_address: event.customer_email,
            first_name: event.participant_first_name,
            last_name: event.participant_last_name,
            state: event.customer_state as StateType,
            postal_code: event.customer_postal_code,
          },
          policy_attributes: {
            event_start_date: event.start_date,
            event_end_date: event.end_date,
            insurable_amount: event.registration_cost_cents, // in cents
            participant: {
              first_name: event.participant_first_name,
              last_name: event.participant_last_name,
            },
          },
          metadata: {
            // Core metadata fields for analytics and customer emails
            context_name: event.organization_name,
            context_event: event.event_name,
            // Included in webhooks, so they can be matched to this item.
            registration_event_id: String(event.id),
          },
        },
      ],
    },
  }),
  [event, clientId],
);

3. Subscribe to webhooks

Add your endpoint in the Partner Portal (see Webhooks), or create it with the Create webhook API. Either way, store the webhook's signature_key securely; you need it to verify deliveries.

scripts/register-webhook.mjs
const auth = Buffer.from(`${process.env.VI_CLIENT_ID}:${process.env.VI_CLIENT_SECRET}`).toString("base64");

const res = await fetch("https://api.verticalinsure.com/v1/webhooks", {
  method: "POST",
  headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    webhook: "https://www.example.com/api/webhooks/vertical-insure",
    events: [
      "policy_bind_complete",
      "policy_bind_failed",
      "policy_payment_failed",
      "policy_purchase_invalid",
      "policy_canceled",
      "policy_endorsed",
      "policy_documents_generated",
    ],
  }),
});

const webhook = await res.json();
console.log(`Store this securely: VI_WEBHOOK_SIGNATURE_KEY=${webhook.signature_key}`);

Vertical Insure must be able to reach your endpoint. During local development, expose your machine with a tunnel such as ngrok and register the tunnel's public URL.

4. Receive and verify webhooks

Each delivery is a POST with a VerticalInsure-Signature header containing the hex-encoded HMAC-SHA256 of the request body, keyed with your webhook's signature_key. Compute it over the raw body exactly as received. Parsing the JSON and serializing it again can change the bytes and break the comparison.

lib/vertical-insure.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhookSignature(rawBody: string, signatureHeader: string | null) {
  const key = process.env.VI_WEBHOOK_SIGNATURE_KEY;
  if (!key || !signatureHeader) return false;
  const expected = createHmac("sha256", key).update(rawBody, "utf8").digest("hex");
  const received = signatureHeader.trim().toLowerCase();
  return (
    received.length === expected.length &&
    timingSafeEqual(Buffer.from(received, "utf8"), Buffer.from(expected, "utf8"))
  );
}
app/api/webhooks/vertical-insure/route.ts
export async function POST(request: Request) {
  // Read the raw body first; verify before parsing.
  const rawBody = await request.text();
  if (!verifyWebhookSignature(rawBody, request.headers.get("VerticalInsure-Signature"))) {
    return Response.json({ error: "invalid signature" }, { status: 401 });
  }

  const payload: { event: string; data: unknown } = JSON.parse(rawBody);
  // ...handle the event (step 5)

  return Response.json({ received: true });
}

5. Update the item from each event

For policy_* events, data is the policy, including its quote_id, policy_number, policy_status, total and the metadata you set in step 2. For policy_endorsed, data holds previous_policy and current_policy; you'll want to update the policy you have on record with the new information. payout_* events have no policy (data is null).

Find the item by quote_id, falling back to the id in metadata, and store the latest policy details on it. If the webhook is for a different quote than the one you stored, replace it, unless the stored policy is ACTIVE and the new one isn't. That stops a late webhook for an abandoned quote (for example, a declined card in another browser tab) from erasing a policy the customer actually bought.

app/api/webhooks/vertical-insure/route.ts
function eventIdFromMetadata(policy: Policy) {
  const id = Number(policy.metadata?.registration_event_id);
  return Number.isInteger(id) && id > 0 ? id : null;
}

function recordPolicy(policy: Policy) {
  // Stores the latest state on the quote's item, following the rules above
  savePolicy({
    quote_id: policy.quote_id,
    event_id: eventIdFromMetadata(policy), // only used if we haven't linked this quote yet
    policy_number: policy.policy_number,
    policy_status: policy.policy_status,
    total: policy.total,
    currency: policy.currency,
    is_test: policy.is_test,
  });
}

// Inside POST, after verifying the signature:
const policy =
  payload.event === "policy_endorsed"
    ? (payload.data as { current_policy: Policy }).current_policy
    : (payload.data as Policy | null);

switch (payload.event) {
  case "policy_bind_complete": // policy issued (ACTIVE)
  case "policy_bind_failed": // purchase went through but binding failed (BIND_FAILED)
  case "policy_payment_failed": // card declined (REQUIRES_PAYMENT)
  case "policy_purchase_invalid": // batch purchase with failures (PENDING)
  case "policy_canceled": // CANCELED
  case "policy_endorsed": // policy changed; current_policy is the new version
  case "policy_documents_generated": // the policy document can now be viewed
    if (policy) recordPolicy(policy);
    break;
  default:
    // payout_* and any future events: acknowledge and ignore.
    break;
}

Because you only store the latest state, handling the same delivery twice is harmless. Keep a log of raw deliveries as well; it makes debugging much easier. The Partner Portal also shows webhook logs.

6. Show which items have a policy

With the status stored on each item, your list can show coverage at a glance, link covered items to their policy, and offer a quote for the rest.

app/page.tsx
export default async function EventsPage() {
  const events = listEvents().map((event) => ({ event, policy: getPolicyForEvent(event.id) }));

  return (
    <table>
      <tbody>
        {events.map(({ event, policy }) => {
          const insured = policy?.policy_status === "ACTIVE";
          return (
            <tr key={event.id}>
              <td>{event.event_name}</td>
              <td>
                {insured ? (
                  <Link href={`/events/${event.id}/policy`}>View policy</Link>
                ) : (
                  <>
                    {policy && <span>{policy.policy_status.replace(/_/g, " ")}</span>}{" "}
                    <Link href={`/events/${event.id}/quote`}>Get a quote</Link>
                  </>
                )}
              </td>
            </tr>
          );
        })}
      </tbody>
    </table>
  );
}

To show a policy, call Retrieve Policy Document from your backend. It returns a link to Vertical Insure's hosted policy viewer, which you can open or embed in an iframe. The link expires (after two days in test mode), so request a fresh one each time rather than storing it. The document can take a short time to exist after purchase; until then the endpoint returns 404, and the policy_documents_generated webhook tells you when it's ready.

app/events/[id]/policy/page.tsx
export default async function PolicyPage({ params }: PageProps<"/events/[id]/policy">) {
  const { id } = await params;
  const policy = getPolicyForEvent(Number(id));

  let documentUrl: string | null = null;
  if (policy?.policy_number) {
    try {
      // GET /v1/policies/{policy_number}/document with your API credentials
      documentUrl = await getPolicyDocumentUrl(policy.policy_number);
    } catch {
      // Not generated yet; ask the user to check back shortly.
    }
  }

  return documentUrl ? (
    <iframe src={documentUrl} title={`Policy ${policy?.policy_number}`} />
  ) : (
    <p>The policy document isn't available yet.</p>
  );
}

If you use the drop-in component's purchase button, it also fires a purchase-completed event in the browser. You can use it to update your page immediately, but treat the webhook as the source of truth. If you do record a purchase from the browser, re-read the policy from Get Policy Details on your backend first.

On this page