silicon-hook-cli 1.1.0

The hook CLI: manage a Silicon's signed provider webhooks, signed in with Silicon Accounts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
# Silicon Hook API documentation

This document explains the Silicon Hook API contract (v3). The machine-readable
contract is [`openapi.yaml`](../../openapi.yaml).

## API conventions

### Base URL

Management, history and delivery operations use:

```text
https://api.hook.teamofsilicons.com/api/v3
```

Each hook a Silicon creates has a public endpoint:

```text
https://api.hook.teamofsilicons.com/silicon/{silicon}/{endpoint_key}
```

`{silicon}` is the Silicon's current id (`si:scout`) or its Silicon Accounts
uuid. The endpoint key is eight uppercase letters and digits, unique across Hook;
it routes the request and is never a credential: the hook's signature policy
decides what is accepted. A URL a provider already holds keeps working after the
Silicon changes its id: the uuid, the current id and every earlier id route to
the same hook, and the key decides which one.

### API version

Before anything else a client sends the unversioned handshake:

```http
GET /api/version
Silicon-Hook-Supported-API-Versions: v3
```

Hook answers with the highest shared major in the body (`service`,
`selected_api_version`, `supported_api_versions`, `build`, `commit`) and in
`Silicon-Hook-API-Version`. With no shared major it answers
`406 api_version_unsupported`. A client pins every versioned call with
`Silicon-Hook-API-Version: v3`; a pin that disagrees with the route is refused with
`400 api_version_mismatch`. API v1 and v2 are retired: every `/api/v1/...` and
`/api/v2/...` management route answers `410 api_version_sunset`. Provider ingress
under `/api/v1/silicon/...` and `/api/v2/silicon/...` keeps working.
[Contracts](../contracts.md) describes the lifecycle policy.

Unprefixed paths below are relative to `/api/v3`.

### Authentication

- **Bearer.** Every management, history and delivery route takes
  `Authorization: Bearer <access token>`: a Silicon Accounts access token issued
  to Hook (an EdDSA JWT, audience `hook`, issuer the Silicon Accounts URL Hook
  trusts). [Sign in to Hook]../accounts/README.md shows how Carbons and
  Silicons get one. Hook verifies it locally against Silicon Accounts' key set and
  refuses tokens issued before a sign-out Hook was told about. Routes that reveal
  a secret or change who has access also ask Silicon Accounts whether the token
  is still active.
- **Discovery.** `GET /auth/accounts` is public and returns Hook's `app_id`, the
  Silicon Accounts URL, the token's audience, issuer and key set URL, how to sign
  in, and whether delivery through Ting is on. `GET /auth/status` returns who the
  token belongs to: `{authenticated, app_id, uuid, id, kind}`.
- **Provider ingress** is public; each hook's signature policy decides what is
  accepted.
- **Hook's Silicon Accounts webhook** (`POST /webhook`, origin-relative) is
  authenticated by the Silicon Accounts signature.

Token refusals are `401` with a precise code: `token_expired`,
`token_wrong_audience`, `token_wrong_issuer`, `token_bad_signature`,
`token_malformed`, `token_unknown_key` and the other `token_*` codes of Silicon
Accounts' verifier, `session_ended` (signed out, or Hook's access removed) and
`account_deleted`. Every management
response carries `Cache-Control: private, no-store`. Hook exposes no endpoints for
other apps to act on someone's behalf.

### Who can do what

Hooks belong to a Silicon, keyed by its uuid.

| Caller | Can |
| --- | --- |
| The Silicon | everything with its hooks |
| Its custodian (the Carbon who looks after it) | everything, recorded as the custodian, never as the Silicon |
| An account granted `manage` | read; create, update, enable/disable, delete/restore, rotate, set secrets |
| An account granted `view` | read hooks, history, blocked requests and delivery status; subscribe to copies (Carbons) |
| Anyone else, including the Silicon's siblings | nothing (`403`) |

Granting, revoking and the allow-list belong to the Silicon and its custodian; a
grantee can leave. "Connect Silicon Accounts updates" belongs to the Silicon and
its custodian, because only they can set its Silicon Accounts webhook.

### Idempotency

Create, restore, secret rotation, endpoint rotation and the Silicon Accounts
updates hook require an `Idempotency-Key` of 8 to 255 visible ASCII characters.
Repeating a key with the same request returns the original result; reusing it
with different content returns `409 idempotency_conflict`. Secret-bearing answers
can be replayed for ten minutes; afterwards, rotate instead. Update and
activation express a desired state and need no key.

### Errors

Errors use one envelope with the request correlation id:

```json
{
  "error": {
    "code": "invalid_signature",
    "message": "The request contains invalid data.",
    "request_id": "0198...",
    "details": "unknown function md5 at byte 0"
  }
}
```

Authentication failures return `401`, authorization failures and blocked
addresses `403`, invisible resources `404`, request deadlines `408`, conflicts
`409` (including `delivery_disabled`), retired versions, expired recovery and
retired endpoints `410`, oversized requests `413`, unsupported media `415`,
invalid input `422` (with `details` where a safe explanation exists), redacted
internal failures `500`, and dependency outages `503` (`accounts_unavailable` when
Silicon Accounts did not answer a check the request needs, `proof_unavailable`
for a proof for Ting, `provider_unavailable` for the database).

## Silicons and access

### `GET /silicons`

The Silicons the caller can open: itself (a Silicon), the Silicons it looks after
(a custodian) and the ones granted to it. Each item has `silicon: {uuid, id}`,
`access` (`self`, `custodian`, `manage`, `view`) and the custodian's uuid.

### `GET /silicons/{silicon}/access`

Who has access: `silicon`, `you: {account, access}`, `custodian` and `grants`
(`account`, `level`, `granted_by`, `created_at`, `updated_at`).

### `PUT /silicons/{silicon}/access/{account}`

Grants `{"level": "view" | "manage"}` to a Carbon or Silicon named by its `c:` or
`si:` id or uuid (resolved with Silicon Accounts, stored by uuid, shown by current
id), or changes its level. Refusals: `account_not_found`, `already_has_access`
(the Silicon itself or its custodian), `silicon_not_reachable` (a Silicon looked
after by a different custodian that has not allowed the granting side).

### `DELETE /silicons/{silicon}/access/{account}`

Revokes a grant. `{account}` = `me` removes the caller's own grant.

### `GET`, `PUT`, `DELETE /silicons/{silicon}/allow-list[/{account}]`

The accounts a Silicon accepts grants from although their custodian is not its
custodian. Allowing a Carbon also covers the Silicons it looks after. Removing an
account stops new grants from it; grants that exist stay until revoked.

### `POST /silicons/{silicon}/hooks/accounts`

Creates (or restores) the hook that receives the Silicon's own Silicon Accounts
events, with the Silicon Accounts signature policy
(`concat(request.headers["x-accounts-timestamp"], ".", request.raw_body)`,
signature `request.headers["x-accounts-signature"]`, HMAC-SHA256, hex). The answer
is `{hook, next_steps}`: `set_webhook` is the exact `silicon-accounts` command for
the caller (`silicon-accounts webhook set <url>` for the Silicon,
`silicon-accounts silicon webhook set <si:id> <url>` for its custodian), and
`store_secret` says how to store the `whsec_` secret that command prints
(`PATCH /silicons/{silicon}/hooks/{hook_id}` with `{"signature": {"secret": ...}}`).
Until it is stored, deliveries are withheld as unverified. Requires
`Idempotency-Key`.

## Hook management

### `GET /silicons/{silicon}/hooks`

Lists a Silicon's hooks. Each item has `id`, `silicon: {uuid, id}`, `name`,
`endpoint_url`, `endpoint_key`, `status`, the signature policy without secret
material, `created_by: {uuid, kind, id}`, `last_received_at` (the last verified
request), `last_blocked_at` and lifecycle timestamps. `include_deleted=true` adds
hooks inside their 45-day recovery window. A Silicon can keep at most 1,000 hooks
including recoverable deleted ones.

### `POST /silicons/{silicon}/hooks`

Creates a hook.

- **Required header:** `Idempotency-Key`.
- **Input:** `name` (the provider name in received events), optional
  `description`, optional IANA `time_zone` (default `UTC`), optional `signature`.
- **Returns:** `201` with the hook and `signing_secret` (once).

Omitting `signature` produces the Standard Webhooks policy with a generated
secret (`v1.` followed by 32 letters and digits). Give that secret to the
provider. When the provider issues its own secret, supply it in
`signature.secret` and describe its scheme. Asymmetric algorithms take
`public_key` instead and return `signing_secret: null`.

### Bring your own secret (BYOS)

Use `signature.secret` on creation, or PATCH an existing hook at any time. The
secret is stored verbatim and encrypted at rest; `secret_encoding` decides how it
becomes key bytes. Changing only the secret keeps the URL, algorithm, payload,
signature locator and enabled/required settings.

```json
{"signature": {"secret": "your-provider-secret", "secret_encoding": "utf8"}}
```

Verification uses the new secret immediately; the old one stops working. PATCH,
read and list answers contain no secret. Omitting `secret` keeps the current
secret on PATCH and generates one on POST for symmetric algorithms. Secrets hold
1 to 4096 UTF-8 bytes without control characters and must decode to a non-empty
key in the selected encoding; otherwise `422`.

### `GET /silicons/{silicon}/hooks/{hook_id}`

Returns one hook. The signing secret is never returned after creation.

### `PATCH /silicons/{silicon}/hooks/{hook_id}`

Changes any subset of `name`, `description` (`null` clears it), `time_zone`,
`enabled` and `signature`. Signature members merge onto the current policy, so
`{"signature": {"required": false}}` turns verification off and keeps the rest.
`"public_key": null` clears the key; `secret` replaces the stored secret.
Requiring signatures for a symmetric algorithm needs a stored or supplied secret.

### `PATCH /silicons/{silicon}/hooks`

Enables or disables 1 to 1,000 unique hooks atomically
(`{"hook_ids": [...], "enabled": false}`). An unknown, deleted, foreign or
unauthorized member fails the whole request with no partial change.

### `DELETE /silicons/{silicon}/hooks/{hook_id}`

Soft-deletes a hook. Its endpoint stops accepting requests at once; the hook, its
secret and its logs stay recoverable for 45 days. Repeating the delete is `204`.
When a Silicon's account is deleted in Silicon Accounts, all its hooks are deleted
at once and their endpoints answer `410 account_deleted`.

### `POST /silicons/{silicon}/hooks/{hook_id}/restore`

Restores a deleted hook within its recovery window with the same endpoint and
secret. Requires `Idempotency-Key`.

### `POST /silicons/{silicon}/hooks/{hook_id}/secret/rotate`

Issues a new generated secret and returns it once; the previous one stops
verifying at once. Not for asymmetric policies. Requires `Idempotency-Key`.

### `POST /silicons/{silicon}/hooks/{hook_id}/endpoint/rotate`

Replaces the endpoint key with a fresh key never used before, and retires the
previous one for good: requests to it answer `410 endpoint_retired`. Returns the
hook with its new `endpoint_url`. Requires `Idempotency-Key`.

## Signature policy

A policy has six parts:

| Member | Meaning | Default |
| --- | --- | --- |
| `required` | Withhold requests that do not verify | `true` |
| `algorithm` | `HMAC-SHA1`, `HMAC-SHA256`, `HMAC-SHA384`, `HMAC-SHA512`, `SHA1`, `SHA256`, `SHA384`, `SHA512`, `Ed25519`, `ECDSA-SHA256`, `RSA-SHA1`, `RSA-SHA256` | `HMAC-SHA256` |
| `payload` | Expression producing the bytes the provider signed | `concat(request.headers["webhook-id"], ".", request.headers["webhook-timestamp"], ".", request.raw_body)` |
| `signature` | Expression locating the presented signature | `request.headers["webhook-signature"]` |
| `signature_encoding` | `hex`, `base64`, `base64url`, or `raw` | `base64` |
| `secret_encoding` | How the stored secret text becomes key bytes: `utf8`, `ascii`, `hex`, `base64`, `base64url`, `raw` | `utf8` |

Plain `SHA*` algorithms digest the payload without a key, so the payload must
include `secret` itself, for example `concat(secret, request.raw_body)`.
Asymmetric algorithms verify with `public_key` (PEM `SubjectPublicKeyInfo`, PEM
`RSA PUBLIC KEY`, or raw hex/base64 key bytes; RSA moduli of at least 2048 bits)
and hold no secret.

The presented signature value is split on whitespace and commas, and a short
`label=` prefix is stripped from each token, so `sha256=<hex>`, `t=<ts>,v1=<hex>`
and `v1,<base64> v1,<base64>` all verify. Symmetric comparisons run in constant
time. With `signature_encoding: raw`, the expression supplies one exact byte
sequence that is never decoded, trimmed, split or stripped; for example
`signature: request.raw_body_bytes` reads a binary signature.

### Signature expressions

Blocks:

```text
request.raw_body            body as UTF-8 text        request.raw_body_bytes   exact bytes
request.body.<json path>    parsed JSON member        request.form.<key>       form field
request.multipart.<key>     multipart part            request.method           uppercase token
request.url  request.scheme  request.authority  request.host  request.hostname  request.port  request.path
request.query_string        raw query                 request.query.<key>      decoded value
request.headers.<name>      case-insensitive          request.cookies.<key>    cookie value
hook.id  hook.url           receiving hook            secret                   decoded key bytes
key.public                  DER SubjectPublicKeyInfo
```

Functions: `concat(...)`, `join(separator: "" | "." | ":" | "," | ";" | "\n" | " ", ...)`,
`sort(list, order: asc | desc)`, `sort_keys(object, order: asc | desc)`, `utf8`,
`ascii`, `url_encode`, `url_decode`, `percent_encode`, `percent_decode`,
`canonicalize_url`, `canonicalize_query`, `json_encode`, `form_encode`, `sha1`,
`sha256`, `sha384`, `sha512`, `hex`, `hex_decode`, `base64`, `base64_decode`,
`base64url`, `base64url_decode`, `lowercase`, `uppercase`, `trim`.

Bracket syntax addresses names with hyphens or JSON members:
`request.headers["x-hub-signature-256"]`, `request.body.items[0].id`. A missing
header evaluates to null and makes the payload unavailable, so a partial payload
is never signed. Expressions are limited to 4 KiB, 32 nesting levels and 512
nodes.

Examples:

```text
GitHub:           payload request.raw_body
                  signature request.headers["x-hub-signature-256"]    hex
Stripe:           payload concat(request.headers["stripe-timestamp"], ".", request.raw_body)
                  signature request.headers["stripe-signature"]       hex
Shopify:          payload request.raw_body
                  signature request.headers["x-shopify-hmac-sha256"]  base64
Silicon Accounts: payload concat(request.headers["x-accounts-timestamp"], ".", request.raw_body)
                  signature request.headers["x-accounts-signature"]   hex (secret: the whsec_ text, utf8)
```

## Ingress

### `ANY https://api.hook.teamofsilicons.com/silicon/{silicon}/{endpoint_key}`

Receives a provider request. `POST` is the common case, but every method is
captured because some providers verify endpoints with `GET`. An optional trailing
slash and the `/api/v1/silicon/...` and `/api/v2/silicon/...` forms are aliases.
Ingress never calls Silicon Accounts.

Processing order:

1. Find the hook by its key, then check the path's Silicon segment: the owning
   Silicon's uuid, current id or an earlier id. Unknown, disabled and deleted
   endpoints answer `404`; a retired key `410 endpoint_retired`; a deleted
   account's hooks `410 account_deleted`.
2. Check the client address against the hook's block list. A blocked address
   receives `403 ip_blocked` (with `Retry-After`) and nothing it sent is stored.
3. Capture the exact method, URL, headers (at most 128 fields, 64 KiB) and body
   (at most 1 MiB). Multipart bodies are parsed for expressions.
4. If the policy requires signatures, verify. A verified request is stored, and,
   when delivery through Ting is on, its Ting send commits in the same
   transaction. An unverified request goes to the blocked log and counts against
   the address.
5. Answer `200 {"status": "webhook.ok", "receipt_id": "..."}`, identical for
   verified and withheld requests so the answer cannot be used as a signature
   oracle.

Behind a load balancer the deployment sets `HOOK_TRUSTED_PROXY_HOPS` so the
blocked address is the real sender.

### Safety

Twenty unverified requests from one address to one endpoint block that address
from the endpoint for one day. Counting restarts after each block. Blocks are per
endpoint, so a misconfigured provider cannot lock a Silicon out of its other
hooks.

## History

### `GET /silicons/{silicon}/events` and `GET /silicons/{silicon}/hooks/{hook_id}/events`

Return the last `n` verified requests (`limit` 1 to 10,000, default 100), newest
first, for the whole Silicon or one hook. Each record has `id`,
`silicon: {uuid, id}`, `hook_id`, `provider`, `delivery_sequence`, `summary`,
`received_at` and the captured `request` (`method`, `url`, `path`,
`query_string`, `headers`, `content_type`, `body` or `body_base64`,
`remote_ip`). A 16 MiB page budget may shorten a page; follow `next_cursor`.
Kept 14 days.

### `GET /silicons/{silicon}/blocked-requests` and `GET /silicons/{silicon}/hooks/{hook_id}/blocked-requests`

Withheld requests in the same shape, with `reason_code` (for example
`signature_mismatch`, `signature_missing`, `payload_unavailable`) and
`reason_detail`. Kept 14 days.

Cursors are authenticated and bound to the Silicon, the collection and the
filter; an events cursor is refused on the blocked list.

### `GET /silicons/{silicon}/events/{event_id}`

One retained event (the same object as history), for hydrating a Ting reference
or any authorized lookup. Expired or foreign events answer `404`.

## Delivery through Ting

Delivery is optional. When the operator sets `HOOK_TING_URL`, every verified
request is queued for its Silicon (and for Carbons who subscribed) and sent to
Ting as a compact reference; Hook retries until Ting confirms it stored the send,
reusing the same bytes and key. Hook signs its calls to Ting with Silicon
Accounts proofs: an App verification proof (scopes `tings.send`, `sent.query`)
for sends and receipts, and a User verification proof made from the caller's own
token (scope `tings.subscribe`) for enrolment.

When `HOOK_TING_URL` is unset, Hook still receives, verifies and stores every
event, queues nothing, and says so: `/readyz` reports it, `GET /delivery` answers
`{"enabled": false, "reason": ...}`, the routes below that need Ting answer
`409 delivery_disabled`, and publication status is `delivery_disabled`.

The reference Ting carries:

```json
{
  "type": "new_event",
  "data": {
    "sender": "stripe",
    "metadata": {
      "id": "0198c21a-6330-7000-8000-000000000001",
      "silicon": {"uuid": "Sx1", "id": "si:scout"},
      "hook_id": "0198c21a-6330-7000-8000-000000000002",
      "delivery_sequence": 42,
      "received_at": "2026-09-22T10:00:00Z",
      "summary": "stripe triggered at 10:00:00 22-09-2026 UTC"
    }
  }
}
```

The Ting type is `hook.webhook.received`; the send names the recipient as
`{uuid, id}` and the producer key `hook:{event id}:{sha256(recipient uuid)}`. No
provider body, header or secret travels through Ting. Use `metadata.id` to
deduplicate and `metadata.silicon.uuid` to hydrate. See
[Receiving through Ting](../client/relay.md).

### `GET /delivery`

Whether this Hook delivers through Ting.

### `POST /delivery/recipient`

Enrols the caller with Ting (empty body). Returns `recipient: {uuid, id}`,
`ting_subscription_id` and `required_delivery` (automation delivery the recipient
opts into in its own app).

### `GET`, `POST`, `DELETE /silicons/{silicon}/delivery/subscription`

A Carbon with `view` (or more) on the Silicon, or its custodian, inspects, starts
or stops copies of the Silicon's future events (no backfill; at most 100
observers per Silicon, `409 observer_limit_reached` beyond). POST enrols the
Carbon with Ting first. Hook re-checks the Carbon's access before every send, and
removes the subscription when access ends. DELETE works even after access ended.
Answers: `{"receiving": bool, "subscription": {id, silicon_uuid, recipient_uuid, created_at} | null}`.

### `GET /silicons/{silicon}/events/{event_id}/publication`

Where the event's send to its Silicon stands: `state` is `delivery_disabled`,
`not_queued` (received while delivery was off, or before the Silicon was linked
to Silicon Accounts), `pending`, `accepted_by_ting`, `accepted_silently`
(ordinary delivery while notifications are muted) or `not_delivered_legacy`
(queued before Hook 1.0 and never accepted). Queued sends also report
`recipient`, `delivery` (`ordinary` | `required`), `silent`, `attempts`,
`ting_id`, `last_error_code`, `accepted_at`, `next_attempt_at`, `expires_at`, and,
when available, Ting's `recipient_receipt` (`read`, per-destination
`delivery_acked` and `read_acked`). None of these mean the Silicon finished its
work.

## Hook's Silicon Accounts webhook

### `POST /webhook` (also `/webhook/`, origin-relative)

Silicon Accounts tells Hook about account changes. Hook verifies
`X-Accounts-Signature` over `{X-Accounts-Timestamp}.{raw body}` before parsing,
refuses deliveries older than five minutes (`401`), ignores a repeated
`event_id`, and acts on: `account.id_changed` (the new id is shown and routes
too), `account.updated`, `silicon.custodian_changed` (access moves to the new
custodian), `membership.signed_out` (except `app_revoked`, tokens issued before
it are refused), `membership.access_removed` and `account.deleted` (the account's
tokens stop working; a deleted Silicon's hooks are deleted). Other types are
acknowledged with `204`. A token's issue time has whole seconds, so a token from
the very second of a sign-out (a Silicon signing in again right after its STK
was rotated) is accepted only if Silicon Accounts confirms it is still active.

## Complete flows

### Provider event

```text
Provider POSTs to the endpoint URL
  -> Hook finds the hook by key and checks the address
  -> Hook captures the exact request and verifies the signature policy
  -> verified: stored for 14 days (and, with Ting on, queued in the same transaction)
     withheld: appended to the blocked log and counted against the address
  -> Hook answers 200 webhook.ok
  -> with Ting on: Hook sends the compact reference with an App verification proof
  -> the Silicon's app hydrates the event from Hook with the Silicon's own token
```

### A Silicon's own Silicon Accounts events

```text
The Silicon (or its custodian) calls POST /silicons/{silicon}/hooks/accounts
  -> Hook creates the "Silicon Accounts" hook and returns its URL and the command
  -> silicon-accounts webhook set <url> points the Silicon's webhook at it and prints whsec_
  -> PATCH the hook with that secret
  -> Silicon Accounts signs every event about the Silicon; Hook verifies and stores it
```

## Deliberately deferred operations

- No public operation to send a historical event through Ting again.
- No timestamp-tolerance option in signature policies; a signed timestamp is
  authenticated but not checked for freshness. Recipients that need replay
  protection deduplicate by provider event id.
- No public permanent purge; the worker purges deleted hooks after 45 days and
  logs after 14 days.
- No unblock operation for addresses; blocks expire after one day.