Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

The Webhooks kit — telling other systems, signed, when something happens

use Osysharp.Webhooks@0 { egress "*"; } · policy ManagesWebhooks => …; · [Event] classes are what can be subscribed to · app.Webhooks = new WebhooksSetup { Documents = [new MyDocument()] }; · WebhookDocument<TRecord> · WebhookPaused · VerifyWebhook(secret, id, timestamp, signature, body) · /settings/webhooks

`Osysharp.Webhooks` sends other systems a signed JSON document each time an event happens to a record — any public event of the app or its kits, under its own name. An administrator subscribes an endpoint to some events on the settings page; each document is built after the commit that raised the event, signed the Standard Webhooks way under that subscription's secret, retried with doubling waits while the endpoint is down, given up on in the end, and logged. Documents are personal data, so the log keeps them only a short, declared while.

preview1 example compiled by CIuikitwebhooksintegration

Source on GitHub: Osysharp.Webhooks — github.com/osysharp/webhooks ↗

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 told

The 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:

headerwhat it is
webhook-idthe delivery's own id — the same on every retry, so a receiver drops a repeat
webhook-timestampwhen this try was sent, in seconds since 1970
webhook-signaturev1, 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#

Related

[Event] — what happens to a record, declared once

An event is a class marked `[Event]`: something that happens to a record, declared once by the code that owns the…

publishing a REST API (app.Apis)

`app.Apis` publishes your app to a third party over HTTP. You never write a controller or an endpoint handler — you…

The Automations kit — rules that do an app's routine work when something happens

`Osysharp.Automations` runs rules — WHEN something happens to a record (any event about it, or every hour), IF the…

Crypto.HmacSha256Hex and Crypto.FixedTimeEquals

HMAC-SHA-256 authenticates a message under a shared key — proving it came from a key holder and was not altered, which…

package.osy

The manifest that makes a git repository a publishable Osy# package. It names the package, says which of the…

SettingsArea — an app's settings: sections, explicit save and discard, undo, who changed what

The kit's settings vocabulary: `SettingsArea` lays out an app's settings — its sections in a grouped nav, the current…

JsonSerializer

Turn a value into a JSON string and a JSON string into a typed object — the C#-faithful System.Text.Json spelling…