Summary#
use Osysharp.Webhooks@0; lets your app tell other systems — an order system, a chat room, a data warehouse — that
something happened. What can be subscribed to is what already happens in the app: its events, raised
where they happen, and every kit's.
[Event("A bike is ready")] public sealed class ReadyForPickup { public Repair Repair; }
Raise(new ReadyForPickup { Repair = r }); // where it happens — and an endpoint subscribed to it is toldThe event goes out under its own name — ready-for-pickup for the app's own, knowledge-base.article-published for a kit's
([[function-event#wire-name]]) — and nothing else is written for the kit. The app adds a WebhookDocument<T> only to say
what a document about its record carries; without one it is sent as the record's type and id.
Signature#
use Osysharp.Webhooks@0 { egress "*"; } // endpoints are chosen at run time — any public host
policy ManagesWebhooks => IsAdmin; // who adds, tests, pauses and removes them, and reads the log
app.Webhooks = new WebhooksSetup { // optional — every member has a default
Documents = [new RepairDocument()], // what a document about a kind of record carries
Attempts = 10, // tries in total, the first included
FirstWait = TimeSpan.FromMinutes(1), // then 2, 4, 8 … minutes
LongestWait = TimeSpan.FromHours(6),
KeepBodies = TimeSpan.FromDays(3), // a document, after its delivery ends
KeepLog = TimeSpan.FromDays(30), // a log line
PauseAfter = 5, // deliveries given up on in a row before a subscription pauses
};
public class RepairDocument : WebhookDocument<Repair> {
public override string Data(Entity record, Event e) { … } // the document's `data`, as JSON — default: type and id
public override string Line(Entity record) { … } // "#1042" on the log line — never a name
public override string Sample(string name) { … } // what "Send a test" sends
}
[On] void H(WebhookPaused e) { … } // an endpoint kept failing and was paused — tell somebody
bool VerifyWebhook(string secret, string id, string timestamp, string signature, string body, TimeSpan? tolerance = null);Description#
What a receiver gets#
One POST per event per subscription, Content-Type: application/json:
{ "type": "ready-for-pickup", "timestamp": "2030-05-06T09:00:00Z", "data": { "Number": 1042, "Bike": "Cargo bike" } }data is your document's Data — your own class, serialized — or, with no document for the record's type, its type
and id, which a receiver reads back through your API. It is built once, after the commit that raised the event, as the
app, and every retry sends the same bytes. ⚠ JsonSerializer writes an enum field by its stored key; a receiver wants
the member's name, so serialize with new JsonSerializerOptions { EnumsAsNames = true }.
Three headers carry the signature, in the Standard Webhooks scheme:
| header | what it is |
|---|---|
webhook-id | the delivery's own id — the same on every retry, so a receiver drops a repeat |
webhook-timestamp | when this try was sent, in seconds since 1970 |
webhook-signature | v1, and the base64 of HMAC-SHA256 over id.timestamp.body, keyed by the secret's bytes (the base64 after whsec_). Two, space-separated, for a day after the secret is rolled |
Any Standard Webhooks library checks it. An Osy# receiver calls VerifyWebhook, which is true only for the exact
bytes that were signed, under that secret, for that id, within five minutes of now:
var ok = VerifyWebhook(Secret.RepairsHook, Request.Header("webhook-id") ?? "", Request.Header("webhook-timestamp") ?? "",
Request.Header("webhook-signature") ?? "", Request.RawBody ?? "");Read the body AS RECEIVED (Raw = true on the endpoint — [[api-rest#raw-and-headers]]): a body re-serialized from bound
parameters is not the bytes that were signed.
Why is the secret shown only once?#
Adding a subscription answers its secret (whsec_…) once — the page shows it in that moment and never again. It is
kept sealed with the app's own key, as is the endpoint's URL (a hook URL is often a credential by itself); the page
shows the host, the URL's last characters and the secret's last four. Roll the secret answers a new one, and the
old one keeps signing beside it for a day, so a receiver moves over without missing a delivery.
Retries, giving up, and pausing#
Anything but a 2xx — or no answer at all — is a refusal. The document is tried again after FirstWait, then twice as
long each time, never longer than LongestWait; after Attempts tries it is given up on. An endpoint that answers
410 Gone is given up on at once. A subscription whose deliveries are given up on PauseAfter times in a row is
paused — nothing more is queued for it, its row says why, and the kit raises WebhookPaused, which says itself:
hear it with [On] to tell somebody (NotifyAll(admins, e) with The Notifications kit — telling a person that something needs them). A failed delivery can be sent again by hand while its document is kept.
What is kept, and for how long#
A document carries personal data. The delivery log keeps it KeepBodies after the delivery ends — delivered or given
up on — and then removes it, together with what the endpoint answered; the line (what, where, when, how it went, how
many tries) stays KeepLog, then leaves the log. Name each line with Line — a number, never a name: the line
outlives the document.
Who is sent what#
A subscription is made in its maker's own name, and is sent a document about a record only when its maker may read
that record under their own rules — the record's own security { }, asked as them. A record they may not read is not
sent — not even that something happened to it. Only ManagesWebhooks sees the subscriptions and the log, and nobody edits what was sent.
Why may the kit reach any public host?#
Endpoints are chosen by your administrators while the app runs, so the kit declares egress consumer; and your app
grants it egress "*"; — any public host ([[project-package#egress-any]]). The platform still refuses internal
addresses, and an egress deny your app or its organization sets still applies.
What the settings page shows#
/settings/webhooks, announced to your settings area for ManagesWebhooks: each endpoint with how it is doing
("Last delivered …", "Retrying", "Paused — failing" with the last answer), Add, Edit, Send a test, Pause or Resume,
Roll the secret, Remove; and the delivery log, newest first, narrowed to what failed or to one endpoint, each line
opened to show the document and what the endpoint answered.
Examples#
A bike workshop tells its booking site#
app Workshop {
use Osysharp.Identity@0;
use Osysharp.Webhooks@0 { egress "*"; }
use Osysharp.Workflow;
use Osysharp.Ui;
model "model/**/*.osy";
}
using Osysharp.Identity;
using Osysharp.Webhooks;
[Role] enum WorkshopRole { Authenticator, Mechanic, Owner }
policy IsMechanic => RoleGrant.Any(g => g.Member == user && (g.Role == WorkshopRole.Mechanic || g.Role == WorkshopRole.Owner));
policy ManagesWebhooks => RoleGrant.Any(g => g.Member == user && g.Role == WorkshopRole.Owner);
entity Repair {
[Required, MaxLength(120)] string Bike;
[Classification(Personal.Contact), MaxLength(200)] string CustomerEmail = "";
bool Ready;
security { allow read, create, update when IsMechanic; }
}
/// The bike is ready — "ready-for-pickup", for an endpoint subscribed to it.
[Event("A bike is ready")]
public sealed class ReadyForPickup { public Repair Repair; }
app.Webhooks = new WebhooksSetup { Documents = [new RepairDocument()] };
public class RepairData {
public string Bike = "";
public string CustomerEmail = "";
public bool Ready;
}
/// What the booking site is sent about a repair.
public class RepairDocument : WebhookDocument<Repair> {
public override string Data(Entity record, Event e) {
var r = (Repair)record;
return JsonSerializer.Serialize(new RepairData { Bike = r.Bike, CustomerEmail = r.CustomerEmail, Ready = r.Ready });
}
}
/// The bike is ready — and the booking site is told.
public void MarkReady(Guid id) {
var r = Repair.Single(x => x.Id == id);
r.Ready = true;
Raise(new ReadyForPickup { Repair = r });
UnitOfWork.Commit();
}See also#
- publishing a REST API (app.Apis) — the other direction: other systems calling your app; reading a signed request as it arrived
- [Event] — what happens to a record, declared once — what can be subscribed to, and the name it goes out under
- The Automations kit — rules that do an app's routine work when something happens — rules on the same events, inside the app
- The Notifications kit — telling a person that something needs them — telling the admins a webhook was paused
- Crypto.HmacSha256Hex and Crypto.FixedTimeEquals — the HMAC and constant-time comparison under the signature
- package.osy —
egress consumer;and the app'segress "*";grant - SettingsArea — an app's settings: sections, explicit save and discard, undo, who changed what — the settings area the page is announced to