Send signed outgoing webhooks

Fed by the records themselves, delivered only to public HTTPS addresses
checked at every attempt, retried, and switched off after repeated failures.
A submitter's details travel only to webhooks that opted into them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 09:42:39 +02:00
parent f93775e549
commit 9bdbf834f6
23 changed files with 1325 additions and 0 deletions

View File

@@ -144,6 +144,8 @@ tags:
description: What is wrong with the site, and what is left to translate
- name: Batch
description: Several operations in one, and importing records from JSON or CSV
- name: Webhooks
description: Telling other systems what happened here
- name: Users
description: Local user administration
- name: Media
@@ -3812,6 +3814,167 @@ paths:
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/webhooks:
get:
tags: [Webhooks]
summary: List webhooks
parameters:
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: Paginated webhooks
content:
application/json:
schema: { $ref: "#/components/schemas/WebhookCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Webhooks]
summary: Create a webhook
description: |
Somewhere to POST to when something happens here. HTTPS to a public
address only — checked now and again at every delivery — and redirects are
not followed. The `secret` signing the deliveries is generated unless sent,
and is in this answer's `secret` once, never again.
Each delivery is a JSON body `{id, event, created_at, data}` with headers
`X-Webhook-Event`, `X-Webhook-Delivery`, `X-Webhook-Timestamp` and
`X-Webhook-Signature`: `sha256=` and the HMAC-SHA256 of
`{timestamp}.{body}` with the secret. Failed deliveries are retried five
times over about two and a half hours; twenty failures in a row switch the
webhook off.
Events: `application.received` (its sender's details only with
`include_personal_data`), `content.published`, `content.unpublished`,
`review.requested`, `review.returned` — or `*`.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/WebhookWrite" }
responses:
"201":
description: Webhook created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Webhook" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/webhooks/{webhook}:
parameters:
- name: webhook
in: path
required: true
schema: { type: integer }
get:
tags: [Webhooks]
summary: Show a webhook
responses:
"200":
description: The webhook
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Webhook" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Webhooks]
summary: Update a webhook
description: "Partial update. `rotate_secret` makes a new secret, in this answer's `secret` once. Switching it back on resets its failure count."
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/WebhookWrite" }
responses:
"200":
description: Webhook updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Webhook" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Webhooks]
summary: Delete a webhook
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/webhooks/{webhook}/deliveries:
get:
tags: [Webhooks]
summary: Deliveries of a webhook
description: Newest first.
parameters:
- name: webhook
in: path
required: true
schema: { type: integer }
- $ref: "#/components/parameters/PerPage"
- name: status
in: query
description: "`pending`, `succeeded` or `failed`."
schema: { type: string }
responses:
"200":
description: Paginated deliveries
content:
application/json:
schema: { $ref: "#/components/schemas/WebhookDeliveryCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/webhooks/{webhook}/ping:
post:
tags: [Webhooks]
summary: Send a test delivery
description: A `ping` event, to check the receiving end.
parameters:
- name: webhook
in: path
required: true
schema: { type: integer }
responses:
"202":
description: Ping queued
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/WebhookDelivery" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/webhook-deliveries/{delivery}/redeliver:
post:
tags: [Webhooks]
summary: Send a delivery again
description: As a new delivery of the same event and payload.
parameters:
- name: delivery
in: path
required: true
schema: { type: integer }
responses:
"202":
description: Delivery queued
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/WebhookDelivery" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/media:
get:
tags: [Media]
@@ -5640,6 +5803,66 @@ components:
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
Webhook:
type: object
properties:
id: { type: integer }
name: { type: string }
url: { type: string, format: uri }
events: { type: array, items: { type: string } }
include_personal_data: { type: boolean }
is_active: { type: boolean }
consecutive_failures: { type: integer }
last_delivered_at: { type: string, format: date-time, nullable: true }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
WebhookWrite:
type: object
required: [name, url, events]
properties:
name: { type: string, maxLength: 120 }
url: { type: string, format: uri, description: "HTTPS, to a public address." }
events:
type: array
items: { type: string, enum: ["*", application.received, content.published, content.unpublished, review.requested, review.returned] }
secret: { type: string, minLength: 16, description: On creation only; generated when left out. }
rotate_secret: { type: boolean, description: On update only. }
include_personal_data: { type: boolean, default: false }
is_active: { type: boolean }
WebhookCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Webhook" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
WebhookDelivery:
type: object
properties:
id: { type: integer }
uuid: { type: string, description: "Sent as `X-Webhook-Delivery`." }
event: { type: string }
status: { type: string, enum: [pending, succeeded, failed] }
attempts: { type: integer }
response_status: { type: integer, nullable: true }
error: { type: string, nullable: true }
payload: { type: object, additionalProperties: true }
delivered_at: { type: string, format: date-time, nullable: true }
created_at: { type: string, format: date-time }
WebhookDeliveryCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/WebhookDelivery" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
MediaFor:
type: string
enum: [pages, technologies, projects, members, clients, testimonials]