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.