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

> `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.

<!-- id: ui-webhooks-kit · area: ui · stability: preview · html: https://osysharp.com/reference/ui/webhooks-kit/ -->

## Summary        {#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](https://osysharp.com/reference/function/event/), raised
where they happen, and every kit's.

```osy syntax
[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      {#signature}
```osy syntax
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    {#description}

### What a receiver gets   {#document}
One `POST` per event per subscription, `Content-Type: application/json`:

```json syntax
{ "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](https://www.standardwebhooks.com) 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:

```osy syntax
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?   {#secret}
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   {#retries}
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](https://osysharp.com/reference/ui/notifications-kit/)). A failed delivery can be **sent again** by hand while its document is kept.

### What is kept, and for how long   {#retention}
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   {#security}
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?   {#egress}
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   {#page}
`/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       {#examples}

### A bike workshop tells its booking site   {#example-repairs}
```osy title="an event, a document, and where the event is raised" test app=ui-webhooks-kit
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       {#see-also}
- [publishing a REST API (app.Apis)](https://osysharp.com/reference/api/rest/) — the other direction: other systems calling your app; reading a signed request as it arrived
- [[Event] — what happens to a record, declared once](https://osysharp.com/reference/function/event/) — 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](https://osysharp.com/reference/ui/automations-kit/) — rules on the same events, inside the app
- [The Notifications kit — telling a person that something needs them](https://osysharp.com/reference/ui/notifications-kit/) — telling the admins a webhook was paused
- [Crypto.HmacSha256Hex and Crypto.FixedTimeEquals](https://osysharp.com/reference/function/crypto-hmac/) — the HMAC and constant-time comparison under the signature
- [package.osy](https://osysharp.com/reference/project/package/) — `egress consumer;` and the app's `egress "*";` grant
- [SettingsArea — an app's settings: sections, explicit save and discard, undo, who changed what](https://osysharp.com/reference/ui/settings/) — the settings area the page is announced to
