UTC --:--:--
Docs·Concepts·Outgoing webhooks

Outgoing webhooks

Subscribe to events from your tenant: outcome reports and recalls, drift detections, billing state changes. Deliveries are signed with HMAC-SHA256. Delivery today is best-effort and single-shot; a durable retry queue and dead-letter visibility are on the roadmap.

Register a webhook

Mint webhook endpoints from the tenant portal at console.goable.io/portal/webhooks. Click "New endpoint", paste the destination URL, pick the events you want to receive, and submit.

The response surfaces a signing secret shown once. Copy it immediately into your destination's environment; the portal won't show it again. Use it to verify every incoming payload (see Signature verification below). If you lose it, revoke the endpoint and create a new one.

Event types

outcome.createdA tenant reported an outcome via POST /v1/score/:id/outcome (payload: { tenantId, sessionId, outcomeType, detail }) or POST /v1/outcomes (payload: { tenantId, occurredAt, activitySlug, outcomeType, spotId?, auditLogId?, equipmentType?, detail }).
outcome.voidedA previously reported outcome, or a batch of them, was voided via the lot-recall path (POST /v1/outcomes/void). Voided rows stay in the audit trail but drop out of the verification view and the cohort signal on the next refresh. Payload: { tenantId, voided, reason, batchRef?, auditLogId?, submittedByKeyId?, occurredFrom?, occurredTo?, outcomeType?, reasonCategory? }
drift.firedThe drift monitor opened a new event for a cell (severity = watch / warning / critical). Payload: { tenantId, eventId, activity, subSpotSlug, horizonH, severity, cusumValue, threshold, baselineBss, currentBss, nDaysInDecline, recalibrationTriggered }
drift.resolvedAn open drift event returned to baseline and was auto-resolved. Payload: { tenantId, eventId, activity, subSpotSlug, horizonH, severity, firedAt, resolvedAt, currentBss, baselineBss }
recommendation.completedPOST /v1/recommend-spot returned. Payload: { tenantId, activity, regionCenter, radiusKm, window, topK, totalCandidates, rankedCandidates, allGated, results[<=3], personalizationApplied, latencyMs }
billing.subscription_updatedStripe subscription state changed (plan upgrade/downgrade, status active/suspended, payment recovery). Payload: { tenantId, stripeCustomerId, stripeSubscriptionId, stripeEventType, plan?, status? }
calibration.completedReserved: the calibration pipeline runs internally today; outbound notification becomes available once the engine accumulates the cohort threshold (≥150 paired outcomes per cell).
underwriting.policy.boundA parametric policy was just bound via POST /v1/underwriting/policy/bind. Payload: { tenantId, policyId, quoteId, coverageYear, coverageWindow, payoutAmount, payoutCurrency, boundAt }
underwriting.policy.triggeredA bound policy accrued NEW payout events during an /evaluate call. Fires only when inserted>0 (not on repeated evaluations that surface the same event set). Payload: { tenantId, policyId, policyStatus, newEvents[], totalInsertedThisEvaluation }
underwriting.policy.settledA bound policy reached final settlement (admin /settle or the expire-windows cron sweep). Payload: { tenantId, policyId, settledAt, settlementReference, payoutAmount, payoutCurrency }

The underwriting.policy.* events are notifications about a parametric policy lifecycle, not evidence that Goable carries risk. Goable is not an insurer: any bound policy is operated by a licensed carrier, and these underwriting surfaces are still in research. Whether a given event fires depends on that surface being enabled for your tenant.

Delivery

Every delivery POSTs JSON to your registered URL with these headers:

Content-Type: application/json
User-Agent: goable-webhook/1.0
X-Goable-Event: drift.fired
X-Goable-Delivery: 8e3b2a…
X-Goable-Signature: 9f4c2a… (raw hex, HMAC-SHA256)

The body is always this envelope, regardless of event type:

{
 "id": "018f2b3a-...-uuid", // crypto-random, dedup on this
 "type": "drift.fired",
 "created": "2026-07-04T09:00:00.000Z",
 "data": { tenantId, ...event-specific fields }
}

Your endpoint must respond with a 2xx within 5 seconds. Anything else is recorded as a failure (today: one shot, no retry queue; a worker process will land that later; for now, treat 5xx as terminal). 200 with empty body is fine; we don't parse the response.

Signature verification

Verify every payload. Without signature checks a malicious third party could POST forged events to your endpoint. The signature is HMAC-SHA256 over the raw request body, hex-encoded.

Reference implementation in Node.js:

import { createHmac, timingSafeEqual } from "node:crypto"

function verify(rawBody: string, header: string, secret: string) {
 const expected = createHmac("sha256", secret)
   .update(rawBody)
   .digest("hex")
 const a = Buffer.from(expected)
 const b = Buffer.from(header)
 return a.length === b.length && timingSafeEqual(a, b)
}

if (!verify(rawBody, req.headers["x-goable-signature"] as string,
  process.env.SIGNING_SECRET!) ) {
 return res.status(401).send("bad sig")
}

For other languages: HMAC-SHA256 the raw request body with the signing secret; hex-encode and compare against the X-Goable-Signature header using constant-time comparison. The deduplication id is on the top-level id of the JSON body; store it to drop replays.

Retries

Today: best-effort, one shot. The deliverer fires once per endpoint with a 5-second timeout; non-2xx or timeout is recorded as a failure on the endpoint's delivery counters but the event is not retried automatically. A durable retry queue + dead-letter visibility ships with the worker-process rollout (tracked in the followups doc). For now, treat your endpoint like a payments webhook: keep it fast, idempotent, and respond 2xx even on internal failure if you've queued the work locally.

Delivery state per endpoint (visible from the portal's endpoint list): lastDeliveredAt, lastDeliveryOk, lastResponseCode, failureCount. There's no automatic suspension today; a persistently-failing endpoint keeps being retried on every new event until you revoke it yourself.

Revoking

Revoke a webhook from the portal at /portal/webhooks. Click the endpoint, then "Revoke". Revocation is immediate: in-flight deliveries finish, no new ones queue. The signing secret is invalidated; if you re-register the same URL you get a brand-new secret.