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.