> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://docs.truvo.com/guides/webhooks/llms.txt. # Webhooks Quote requests run in the background, so you need a way to hear when one finishes. Instead of polling, you can subscribe a URL to the event types you care about, and Truvo sends a signed `POST` to it each time one of them happens. Every event is also in the event feed for 30 days, so a missed delivery is never lost. ## Subscribe Create a subscription with the URL that receives events and the exact event types you want. There's no wildcard, so list each type: ```bash curl https://api.truvo.com/v1/event-subscriptions \ -H "Authorization: Bearer $TRUVO_API_KEY" \ -H "Idempotency-Key: 5f0c7d8e-2b1a-4c3d-9e8f-0a1b2c3d4e5f" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.example.com/truvo", "event_types": ["quote_request.completed", "quote_request.failed"] }' ``` The answer is the subscription, with its signing secret: ```json { "id": "sub_...", "environment": "sandbox", "url": "https://hooks.example.com/truvo", "status": "active", "event_types": ["quote_request.completed", "quote_request.failed"], "signing_secret": "whsec_..." } ``` Store the `signing_secret` right away. It's shown once: after a create succeeds, a retry with the same request key returns the subscription with `signing_secret: null`. If you lose the secret, rotate it. If a create answers `503`, send it again with the same request key, and it finishes with the same secret. A create left unfinished for 10 minutes is dropped. A retry of its request key then gets `409` with the retry action `use_new_request_key`, so send the create again with a new key. A few rules apply to every subscription: * Only a server key can manage subscriptions. A widget key gets `403`. * The URL must use `https` and name a public host. A URL with a user name or a password, an IP address, `localhost`, or a `.local` or `.internal` host gets `422`. A port is fine. * An unknown event type gets `422` at that entry of `event_types`. * Each integration can hold 10 active subscriptions in each environment. * A sandbox key sees sandbox events, and a live key sees live events. To change the URL or the event types, send `PATCH /v1/event-subscriptions/{id}`. To stop deliveries, send `DELETE /v1/event-subscriptions/{id}`, which disables the subscription. `GET /v1/event-subscriptions` lists your subscriptions, 10 to a page by default. ### Rotate the signing secret To rotate (roll) the secret, send `POST /v1/event-subscriptions/{id}/rotate` with a new request key. The answer carries the new secret once, and `previous_secret_expires_at` says when the old secret stops working: 24 hours after the rotation. Until then, each delivery carries a signature made with each secret, so your endpoint accepts it with either one. Deploy the new secret before the old one expires. Only one rotation of a subscription can be in progress, so a second one gets `409` until the first finishes. A rotation left unfinished blocks new ones for up to 10 minutes. ## Verify each delivery Anyone can send a request to your URL, so check the signature before you act on a delivery. Truvo sends webhooks through Svix, and each delivery carries Svix's standard signature headers: | Header | Holds | | ---------------- | ------------------------------------------------------------------------------------------- | | `svix-id` | The ID of the delivered message. It stays the same on every retry of that message. | | `svix-timestamp` | When the message was signed, in Unix seconds. | | `svix-signature` | One or more signatures, separated by spaces. Accept the message if any one of them matches. | The signature is an HMAC-SHA256 over `{svix-id}.{svix-timestamp}.{body}`. Its key is the part of your signing secret after `whsec_`, decoded from base64. Each entry in `svix-signature` is `v1,` followed by a signature in base64. The signature follows the Standard Webhooks scheme. The `svix` libraries accept these headers directly. Any other Standard Webhooks library needs them renamed to `webhook-id`, `webhook-timestamp`, and `webhook-signature`, with the same values. The `svix` package on npm is the simplest: ```ts import { Webhook } from "svix"; const webhook = new Webhook(process.env.TRUVO_WEBHOOK_SECRET!); // rawBody is the request body exactly as it arrived, before any JSON parsing. export function verifiedEvent(rawBody: string, headers: Record) { return webhook.verify(rawBody, headers) as { id: string; type: string }; } ``` `verify` throws when no signature matches, or when the timestamp is more than 5 minutes away from your clock. Answer `400` in that case and do nothing else. Verify against the raw body: a body that you parsed and serialized again won't match. ## Handle retries and duplicates Answer with a `2xx` status within 15 seconds to confirm a delivery. Any other answer, a redirect included, counts as a failure, and the message is sent again. The first delivery goes out right away. After a failure, the message is sent again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and 10 hours. If every delivery to your URL fails for 5 days, delivery to that subscription turns off. The subscription still reads `active`, and its [health](#check-delivery-health) reads `disabled`. To turn delivery back on, send `PATCH /v1/event-subscriptions/{id}` with your URL, then [recover the missed events](#recover-missed-events) from the feed. Because of retries, the same event can arrive more than once, and events can arrive out of order. Handle both: * Store the `id` of each event you processed, and skip an `id` you've already seen. * Answer `2xx` first, and do the work afterward. A slow handler turns into retries. * Compare `resource.version` with the version you hold. Read the resource only when the event's version is higher, and never apply an older version over a newer one. An event never repeats an insurance action. Reading a resource because of an event is always safe. ## Read the resource Events are thin on purpose. An event says which resource changed and which version it reached, and it carries no applicant data: ```json { "id": "evt_...", "type": "quote_request.completed", "schema_version": "2026-09-26", "occurred_at": "2026-09-28T17:04:11.482Z", "environment": "sandbox", "line": "personal_auto", "resource": { "type": "quote_request", "id": "qreq_...", "version": 4 } } ``` To see what changed, read the resource. A `quote_request` event points to `GET /v1/quote-requests/{id}`, and a `quote` event points to `GET /v1/quotes/{id}`. The read gives you the current state, which may already be newer than the event. Truvo can add event types and fields without notice. Ignore a type or a field that you don't know, and still answer `2xx`. ## Event types | Type | Sent when | | -------------------------- | ---------------------------------------------------------------------------------------------------------- | | `quote_request.queued` | Truvo accepts a new quote request. | | `quote_request.updated` | A quote request starts, gets its first quotes, or changes in another way before it reaches a final status. | | `quote_request.completed` | Collection ends with results. A request with zero quotes can still complete. | | `quote_request.failed` | The request can't start, or no carrier answers by the deadline. | | `quote_request.cancelled` | You cancel a request while it's still `queued`. | | `quote.created` | Truvo saves a new quote. | | `quote.updated` | A quote changes in a way other than expiring. | | `quote.expired` | A quote reaches its expiry time. The expiry changes the quote. | | `purchase.updated` | A purchase is created, or changes without a more specific event. | | `purchase.requires_action` | A purchase needs an action from the customer, or that action changes. | | `purchase.completed` | A purchase is complete, with a verified policy. | | `purchase.needs_review` | A person at Truvo needs to review a purchase. | | `purchase.failed` | A purchase fails with no payment or bind left unresolved. | Purchases aren't in the API yet, so the five `purchase` types don't fire yet. A pet quote request sends only `quote_request` events. ## Recover missed events The event feed holds every event of the last 30 days, of every type, whether or not you subscribed to it. Each event has the same `id` in the feed as in a webhook, so you can use one handler for both. Read the feed after an outage, after you turn a subscription back on, or on a schedule as a safety net: ```bash curl "https://api.truvo.com/v1/events?cursor=$TRUVO_EVENT_CURSOR&limit=100" \ -H "Authorization: Bearer $TRUVO_API_KEY" ``` The answer has `events` and a `next_cursor`. Every page has a `next_cursor`, an empty one included. Process the events, store the `next_cursor` only after that work is saved, and send it on the next read. When a page comes back empty, you've caught up. * Leave out `cursor` to start at the oldest event the feed still holds. * `limit` is 10 by default and 100 at most. * A cursor that the feed didn't give you gets `400` with the code `invalid_cursor`. * A cursor gets `410` with the code `event_cursor_expired` when events after it are gone: the feed deleted them after 30 days, or a sandbox reset removed them. Read each resource that you track, then read the feed again without a cursor. ## Test your endpoint Send a test event to check that your endpoint gets deliveries and verifies their signatures: ```bash curl -X POST https://api.truvo.com/v1/event-subscriptions/sub_.../test \ -H "Authorization: Bearer $TRUVO_API_KEY" \ -H "Idempotency-Key: 9b2e4f60-1c7d-4a8e-b3f5-6d0e2a9c8b71" ``` The answer is `202`, with the event that Truvo queued: ```json { "subscription_id": "sub_...", "event": { "id": "evt_test...", "type": "webhook.test", "schema_version": "2026-09-26", "occurred_at": "2026-09-28T17:04:11.482Z", "environment": "sandbox", "subscription_id": "sub_..." } } ``` A test event goes only to that subscription, whatever event types it lists. It names no resource and carries no applicant data, and it isn't in the event feed. Answer it with `2xx`, as you answer any event. To retry after an error, send the same request key: it queues the same event, with the same `id`. A test can take up to 35 seconds to answer, so set your client's timeout to 40 seconds or more. If it answers `503`, wait for the `Retry-After` seconds and send it again with the same request key. A test to a disabled subscription gets `409`. So does a test while delivery to the subscription is off: [check its health](#check-delivery-health). ## See each delivery `GET /v1/event-subscriptions/{id}/deliveries` lists the events sent to a subscription in the last 30 days, newest first. The log shows every delivery sent to this subscription, test events included: ```json { "deliveries": [ { "event_id": "evt_...", "event_type": "quote_request.completed", "status": "retrying", "created_at": "2026-09-28T17:04:11.482Z", "next_attempt_at": "2026-09-28T17:09:12.000Z" } ], "next_cursor": null } ``` | `status` | Means | | ----------- | ---------------------------------------------------------------- | | `pending` | The first attempt hasn't run yet. | | `retrying` | An attempt failed. The next one runs at `next_attempt_at`. | | `succeeded` | Your endpoint answered `2xx`. | | `failed` | Every retry failed. | | `cancelled` | Retries stopped because delivery to the subscription turned off. | `created_at` is when Truvo sent the event to delivery. `limit` is 10 by default and 100 at most. Send the `next_cursor` of a page as `cursor` to read the next page. A page can come back empty with a `next_cursor`, for example after a sandbox reset deletes events, so keep reading until `next_cursor` is `null`. A cursor that this subscription's log didn't give you gets `400` with the code `invalid_cursor`. To see the attempts of one delivery, read `GET /v1/event-subscriptions/{id}/deliveries/{event_id}`. The answer is the same delivery, with up to 20 attempts, newest first: ```json { "event_id": "evt_...", "event_type": "quote_request.completed", "status": "retrying", "created_at": "2026-09-28T17:04:11.482Z", "next_attempt_at": "2026-09-28T17:09:12.000Z", "attempts": [ { "attempted_at": "2026-09-28T17:04:12.103Z", "status": "failed", "response_status_code": 500, "response_duration_ms": 212, "trigger": "scheduled" } ] } ``` `response_status_code` is `null` when your endpoint gave no HTTP answer, as after a timeout. `trigger` is `manual` for a test or a replay, and `scheduled` for the first attempt and each retry. ## Replay a delivery After you fix your endpoint, send an event from the delivery log again: ```bash curl -X POST https://api.truvo.com/v1/event-subscriptions/sub_.../replay \ -H "Authorization: Bearer $TRUVO_API_KEY" \ -H "Idempotency-Key: 4c1d8e2a-7b3f-4e9a-a6c5-0f2b9d1e3a57" \ -H "Content-Type: application/json" \ -d '{ "event_id": "evt_..." }' ``` The answer is `202`. The event arrives again with the same `id` and the same body, so your duplicate check still applies. You can replay only an event in the subscription's delivery log. Any other event ID gets `404`. Use a new request key for each replay, and the same key to retry after an error. To catch up on many events, read the [event feed](#recover-missed-events). ## Check delivery health `GET /v1/event-subscriptions/{id}/health` says whether deliveries get through: ```json { "subscription_id": "sub_...", "state": "failing", "last_attempt": { "attempted_at": "2026-09-28T17:04:12.103Z", "status": "failed", "response_status_code": 500 } } ``` | `state` | Means | What to do | | ------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `healthy` | The newest attempt succeeded, or nothing was sent yet. | Nothing. | | `failing` | The newest attempt failed. Retries continue. | Fix your endpoint, then read the delivery log. | | `disabled` | You disabled the subscription, or delivery turned off after 5 days of failures. | If the subscription reads `active`, send `PATCH /v1/event-subscriptions/{id}` to turn delivery back on. If you disabled it, create a new subscription. | | `no_endpoint` | Truvo has nowhere to send this subscription's events. | Create a new subscription. | ## Limits for the webhook tools * A subscription takes 10 test events a minute, and each event can be replayed 5 times a day. Only a test or a replay that Truvo hands to delivery counts. * An integration can send 60 tests and replays a minute in all. * Tests and replays don't use your quote request allowance, so they never slow down your quotes. * The delivery log, delivery, and health reads use the quote read allowance: one unit for a page of the delivery log, and up to two for a health read or a read of one delivery. The checks before a test use one unit, and the checks before a replay use two. * A `429` carries `Retry-After`. So does a `503`: send the same request again after that many seconds. An agent can use the same tools over MCP: `send_test_event`, `list_event_deliveries`, `get_event_delivery`, `replay_event_delivery`, and `get_event_subscription_health`. ## Try it in the sandbox Send a test event first. Then make a real change with your sandbox key: start a quote request, or run the `force_expiry` control to expire your sandbox quotes. Each change sends its events to your subscriptions and adds them to the feed. > Get a signed message when a quote request or a quote changes, and recover anything you miss from the event feed.