Webhooks
A Watch posts every Event a selector matches to your URL, signed, retried for three days, ordered by sequence and replayable from any point.
A webhook is a Watch: a standing request to POST every Event a selector matches to a URL. Deliveries are signed, retried until acknowledged, numbered in order and replayable.
Events are the feed
Every Action that writes a record emits an Event named for the record and the past tense of its Verb: Order.placed, Order.delivered, Invoice.paid. Each Verb's reference names its Event, and reads emit none.
A Watch subscribes to those names, so there is no separate webhook catalog to learn. Anything a caller can do through the API, a Watch can hear.
Create a Watch
listingcraft.co is a Services-as-Software business that writes rental listings for property managers. Each property manager's software wants to know the moment its listing is ready, so it registers a Watch with its own key:
curl -X POST https: //api.sb/ listingcraft.co/ watches \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
-H "Content-Type: application/ json" \
-d '{"selector": "Order.delivered", "url": "https: //pm.example/ hooks/ listings"}'The response holds the Watch and its signing secret:
{
"api": "https: //api.sb/ listingcraft.co",
"watch": { "$id": "https: //api.sb/ listingcraft.co/ watches/ watch_1", "$type": "Watch", "selector": "Order.delivered", "url": "https: //pm.example/ hooks/ listings" },
"secret": "whsec_4f9c2a7e1b8d3f6a0c5e9b2d7a4f1c8e",
"links": { "self": "https: //api.sb/ listingcraft.co/ watches/ watch_1" }
}The secret appears once, and no read returns it again: store it beside the receiver.
A selector names one Event, Order.delivered, or every Event of a Noun, Order.*. A where clause narrows it with the list filter grammar: Order.delivered where total.amount > 200.
A Watch hears only what its key may read. The property manager's key reaches its own Tenant, so its Watch never sees another manager's Orders. A Watch belongs to one environment, and a test key's Watch hears only test Events.
The delivery
Each delivery is one Event, posted as JSON:
{
"$id": "https: //api.sb/ listingcraft.co/ events/ event_8812",
"$type": "Event",
"name": "Order.delivered",
"at": "2026-10-22T12: 00: 00Z",
"seq": 8812,
"data": {
"order": { "$id": "https: //api.sb/ listingcraft.co/ orders/ order_31", "$type": "Order", "status": "fulfilled" }
}
}data holds the record as the Action left it, in the shape the API returns. The $id names the Startup, so one receiver can serve many. seq is the Event's position in the namespace, increasing by one per Event.
A Watch created with "payload": sends only the record's versioned address, order_31@v4, and the receiver reads the rest. Choose thin deliveries when the receiver re-reads anyway, or when the record holds personal data that should not sit in a receiver's logs.
Verify the signature
Every delivery carries a StartupBuilder-Signature header: a timestamp t and one or more v1 signatures.
StartupBuilder-Signature: t= 1792670400, v1= 5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdverifyWebhook from the SDK checks it against the raw body and the Watch's signing secret, and returns the Event. It throws when no signature matches, or when the newest is older than five minutes.
import { verifyWebhook } from 'startup-builder'
export async function POST(request: Request) {
let event
try {
event = verifyWebhook(await request.text(), request.headers.get('StartupBuilder-Signature') ?? '', process.env.WATCH_SECRET ?? '')
} catch {
return new Response('bad signature', { status: 400 })
}
if (event.name === 'Order.delivered') console.log(event.data.order.$id)
return new Response(null, { status: 204 })
}In another language, check it by hand. Each v1 is a hex HMAC-SHA256 of t, a dot and the raw body, keyed with the signing secret. Compare in constant time, and accept the delivery when any v1 matches.
Verify against the raw bytes, before any JSON parser touches them. Reject anything older than five minutes, so a captured request replayed later fails.
To change a secret without downtime, rotate it. For the next 24 hours each delivery carries two v1 signatures, one per secret. Deploy the new secret to the receiver within that window, and nothing fails.
Acknowledge and retry
Answer any 2xx within 10 seconds to acknowledge a delivery. Queue the work and answer first: a slow handler looks like a failure and earns a retry.
An unacknowledged delivery retries 12 times over three days: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, then every 12 hours. Each try is recorded on the Watch with its status code and response time, so the receiver's owner sees exactly what failed.
A Watch whose deliveries have all failed for three days pauses itself and notifies the Startup's Reliability Engineer. Its Events keep queuing. Resume it once the receiver is fixed, and the held Events arrive oldest first.
Order, duplicates and replay
Deliveries arrive at least once, and retries can arrive out of turn. Store each Event's $id and skip one you have seen. When order matters, compare seq, or read the record itself.
To recover from an outage longer than three days, or to load a new receiver with history, replay the Watch from a point in time. Every matching Event since then arrives again, signed as the first time. Events are kept forever, so any point works.
Inbound webhooks
Outside services push into a Startup through the reverse door. Each Integration has one inbound URL, https:.
The platform checks the sender's signature with the Integration's credentials, stores the raw body, and writes an Event before any Workflow acts. A chargeback from the card network arrives as Order.disputed, and a forged request never becomes an Event. Inbound Events take the same selectors, Triggers, Watches and replays as any other.
Next
- Realtime: the same Events over an open connection, without a public URL.
- Errors and testing: fire test Events at a receiver on your laptop.
- Event streams: the capability every Watch reads from.