Outbound webhooks
useWebhooks adds a subscription registry and a job-queue-backed
dispatcher to the app. Subscribers ask for an event by URL;
publishers fire payloads via dispatchWebhook. Each delivery
runs as a separate background job so failures retry without
blocking the request thread.
Wiring
import gebweb;
let app = gebweb.app([HomeController()]);
gebweb.useJobs(app, jobsConn);
gebweb.useWebhooks(app, {
"deadLetter": func(string event, dict<string, any> payload, string lastError): void {
deadLetterRepo.save(event, payload, lastError);
}
});
useWebhooks registers its own @Job("gebweb.webhook.send")
handler internally; you do not need to mention it when you call
useJobs(app, ..., [yourHandlers]).
The job queue must already be wired - the dispatcher refuses to run without it.
Subscriptions
gebweb.subscribeWebhook(app, "user.created",
"https://customer.example.com/hooks/user", "shared-secret-1");
gebweb.subscribeWebhook(app, "user.created",
"https://otheraccount.example.com/webhooks/user", "shared-secret-2");
The 1.1 line stores subscriptions in memory on the app. Persist them to a database in your own model and seed the registry at startup by replaying them; a built-in persistent store is scoped for 1.2.
gebweb.unsubscribeWebhook(app, event, url) removes a single
subscription and returns true when it was present.
Dispatch
class SignupController {
@Post("/signup")
func signup(SignupForm form): dict<string, any> {
let user = userRepo.create(form);
gebweb.dispatchWebhook(app, "user.created", {
"id": user.id,
"email": user.email,
"at": datetime.nowUnix()
});
return {"id": user.id};
}
}
dispatchWebhook returns the number of subscribers that were
queued. Each delivery becomes its own job; the request returns
immediately.
The outgoing body is:
{
"event": "user.created",
"data": {"id": "u-1", "email": "...", "at": 1748880000},
"at": 1748880001
}
Signatures
By default every outgoing request carries:
X-Gebweb-Signature: sha256=<hex>- HMAC-SHA256 over the body with the subscription's shared secret.X-Gebweb-Timestamp: <unix-seconds>- the time the request was built.
A subscription with an empty secret omits the signature.
Override the header name with opts.signatureHeader (use
X-Hub-Signature-256 to mimic GitHub) and replace the signer
entirely by passing opts.signer:
gebweb.useWebhooks(app, {
"signatureHeader": "X-Hub-Signature-256",
"signer": func(string body, string secret): string {
return "sha256=" + crypt.hmacSha256(secret, body);
}
});
The receiver-side helper:
class WebhookReceiver {
@Post("/hooks/inbound")
func receive(dict<string, any> request): dict<string, any> {
let body = request["body"] as string;
let sig = (request["headers"] as dict<string, any>)["X-Gebweb-Signature"] as string;
if (!gebweb.verifyWebhookSignature(body, "shared-secret", sig)) {
throw gebweb.unauthorized("bad signature");
}
return {"ok": true};
}
}
verifyWebhookSignature is a constant-time compare under the
hood so timing oracles cannot leak the expected signature.
Retries and dead-lettering
Deliveries inherit the job queue's retry policy. The default
schedule is [1s, 5s, 30s, 2m, 10m] for five attempts before the
job is marked failed. Each attempt that returns 2xx counts as
success; any other status (or network error) raises an
exception, triggering the next retry.
On the final attempt the configured deadLetter callback fires
(when supplied) with the event name, original payload, and the
last error message. Persist these to a database, file, or
operational queue so they can be replayed by hand.
Reference
| Helper | Purpose |
|---|---|
gebweb.useWebhooks(app, opts) |
Mount the dispatcher and register the built-in job handler. |
gebweb.subscribeWebhook(app, event, url, secret) |
Add a subscription. |
gebweb.unsubscribeWebhook(app, event, url) |
Remove a subscription. |
gebweb.dispatchWebhook(app, event, payload) |
Queue one job per matching subscription. |
gebweb.verifyWebhookSignature(body, secret, headerValue) |
Receiver-side HMAC check. |
gebweb.webhooks.deliverNow(cfg, sub, event, payload) |
Synchronous one-shot delivery for tests / debugging. |