Soba Docs

Integrate

Webhooks

How your server hears about a checkout, a renewal, a failed payment, an exhausted allowance or a machine going offline, and why a webhook is a notification rather than a source of truth.

Runs are synchronous: you call, you stream, you know what happened. Everything else in Soba is not. A card fails at 3am, a plan renews on the 1st, a laptop closes mid-week. Webhooks are how your server hears about those.

TypeScript
import { verifyWebhook } from "@soba-so/sdk";

export async function POST(req: Request) {
  const event = verifyWebhook(await req.text(), req.headers, process.env.SOBA_WEBHOOK_SECRET);

  switch (event.type) {
    case "plan.activated":   await grantAccess(event.data.userId, event.data.planId); break;
    case "plan.past_due":    await nudge(event.data.userId); break;
    case "compute.offline":  await markDegraded(event.data.userId); break;
  }

  return new Response(null, { status: 204 });
}

The events#

Event When
plan.activated Checkout completed, or a code redeemed. The entitlement is live
plan.renewed A new period began. Allowances reset here
plan.past_due A payment failed. Dunning has started; the entitlement has not been withdrawn yet
plan.cancelled The entitlement ends, either now or at period end
allowance.exhausted The user reached includedUnits. What happens next is the plan's overage
compute.connected A user paired a machine, or completed an OAuth connect
compute.offline A connected machine stopped answering
compute.signed_out A machine is up but its CLI is signed out, so its runtime is withheld
run.failed A run ended on an error rather than a done

compute.* is the set worth wiring first, and the reason is <ComputeStatus />: when the compute is the user's own machine, the machine's state is user-facing. These events are how you tell someone their laptop went quiet before they discover it through a slow run.

Delivery#

At least once. A delivery that does not get a 2xx inside ten seconds is retried with exponential backoff for 24 hours. Your handler will occasionally see the same event twice, so key it on event.id and make the handler idempotent.

Not ordered. A retry can land after an event that came later. Every event carries the object's state, so apply what arrives rather than reconstructing a sequence from it; if two events disagree, the higher event.created wins.

Fast, then work. Acknowledge with 204 and do the work after. A handler that provisions an account before answering will eventually time out, get retried, and provision twice.

Verifying#

Soba-Signature: t=1735689600,v1=<hex hmac>

verifyWebhook checks an HMAC of t.body against your endpoint's secret, in constant time, and rejects a timestamp older than five minutes. Do the check on the raw body: parsing and re-serialising JSON changes bytes and the signature will not match.

A rejected signature is not a bug to work around

It means either the secret is wrong or the payload is not from Soba. There is no "skip verification" flag, for the same reason there is no bypass mode in the protocol.

A webhook is not the source of truth#

It is a notification that something changed, and it can be lost, delayed or duplicated.

Entitlement and usage live in Soba's ledger, which is authoritative and readable through the API whenever you need it. Payments live in your own Stripe account. So use webhooks to react (send the email, flip the banner, mark the machine degraded) and read the ledger when you need to decide. An app that gates a feature on a webhook it may never receive is one dropped delivery away from locking out a paying customer.

The same reasoning applies to usage: bill from what the dashboard and the API report, never from a count you accumulated by tallying webhooks.

Environments#

A webhook endpoint belongs to an environment, so development and preview traffic never reaches your production handler. Development events go to whatever local URL you register, which means testing the handler needs no tunnel from the internet into your laptop.

© 2026 Soba resolved = machine grant ∩ broker request