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.
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.
"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);
}2. Link the quote to the item when you show the offer
When you configure the drop-in component for an item:
- Set
unique_offer_idto 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 itsquote_id. - Set the core metadata fields
context_name(here, the organization) andcontext_event(the event). They power Partner Portal analytics, andcontext_namealso appears in the purchase emails sent to customers.
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.
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.
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"))
);
}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.
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.
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.
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.